diff --git a/docs/source/data.rst b/docs/source/data.rst index 7f59e587ec..0de5e0c347 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -312,6 +312,11 @@ PatchWSIDataset .. autoclass:: monai.data.PatchWSIDataset :members: +MaskedPatchWSIDataset +~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.MaskedPatchWSIDataset + :members: + SlidingPatchWSIDataset ~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.SlidingPatchWSIDataset diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py index 34cabac50c..ce22b86d49 100644 --- a/monai/apps/pathology/transforms/spatial/array.py +++ b/monai/apps/pathology/transforms/spatial/array.py @@ -211,7 +211,7 @@ def __call__(self, image: NdarrayOrTensor) -> NdarrayOrTensor: constant_values=self.background_val, ) - # extact tiles + # extract tiles x_step, y_step = self.step, self.step h_tile, w_tile = self.tile_size, self.tile_size c_image, h_image, w_image = img_np.shape diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 40ee3cfc29..293f058acf 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -104,7 +104,7 @@ worker_init_fn, zoom_affine, ) -from .wsi_datasets import PatchWSIDataset, SlidingPatchWSIDataset +from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader with contextlib.suppress(BaseException): diff --git a/monai/data/utils.py b/monai/data/utils.py index 93f96feae9..6f18e566e0 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -247,7 +247,7 @@ def iter_patch( start_pos: Sequence[int] = (), overlap: Union[Sequence[float], float] = 0.0, copy_back: bool = True, - mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, + mode: Optional[Union[NumpyPadMode, str]] = NumpyPadMode.WRAP, **pad_opts: Dict, ): """ @@ -262,10 +262,8 @@ def iter_patch( overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. copy_back: if True data from the yielded patches is copied back to `arr` once the generator completes - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function. Defaults to ``"wrap"``. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + mode: One of the listed string values in ``monai.utils.NumpyPadMode`` or ``monai.utils.PytorchPadMode``, + or a user supplied function. If None, no wrapping is performed. Defaults to ``"wrap"``. pad_opts: padding options, see `numpy.pad` Yields: @@ -285,19 +283,28 @@ def iter_patch( patch_size_ = get_valid_patch_size(arr.shape, patch_size) start_pos = ensure_tuple_size(start_pos, arr.ndim) + # set padded flag to false if pad mode is None + padded = True if mode else False # pad image by maximum values needed to ensure patches are taken from inside an image - arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) - - # choose a start position in the padded image - start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) - - # choose a size to iterate over which is smaller than the actual padded image to prevent producing - # patches which are only in the padded regions - iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + if padded: + arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) + # choose a start position in the padded image + start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) + + # choose a size to iterate over which is smaller than the actual padded image to prevent producing + # patches which are only in the padded regions + iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + else: + arrpad = arr + start_pos_padded = start_pos + iter_size = arr.shape - for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap): + for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap, padded=padded): # compensate original image padding - coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + if padded: + coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + else: + coords_no_pad = tuple((coord.start, coord.stop) for coord in slices) yield arrpad[slices], np.asarray(coords_no_pad) # data and coords (in numpy; works with torch loader) # copy back data from the padded image if required diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 5e4cae6d90..689e25d8ca 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -10,6 +10,7 @@ # limitations under the License. import inspect +import os from typing import Callable, Dict, Optional, Sequence, Tuple, Union import numpy as np @@ -17,10 +18,11 @@ from monai.data import Dataset from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader -from monai.transforms import Randomizable, apply_transform -from monai.utils import ensure_tuple_rep +from monai.transforms import ForegroundMask, Randomizable, apply_transform +from monai.utils import CommonKeys, ProbMapKeys, ensure_tuple_rep +from monai.utils.enums import WSIPatchKeys -__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset"] +__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset", "MaskedPatchWSIDataset"] class PatchWSIDataset(Dataset): @@ -33,6 +35,9 @@ class PatchWSIDataset(Dataset): size: the size of patch to be extracted from the whole slide image. level: the level at which the patches to be extracted (default to 0). transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output metadata from the input data reader: the module to be used for loading whole slide imaging. If `reader` is - a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. @@ -47,8 +52,8 @@ class PatchWSIDataset(Dataset): .. code-block:: python [ - {"image": "path/to/image1.tiff", "location": [200, 500], "label": 0}, - {"image": "path/to/image2.tiff", "location": [100, 700], "size": [20, 20], "level": 2, "label": 1} + {"image": "path/to/image1.tiff", "patch_location": [200, 500], "label": 0}, + {"image": "path/to/image2.tiff", "patch_location": [100, 700], "patch_size": [20, 20], "patch_level": 2, "label": 1} ] """ @@ -56,64 +61,73 @@ class PatchWSIDataset(Dataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, transform: Optional[Callable] = None, + include_label: bool = True, + center_location: bool = True, + additional_meta_keys: Optional[Sequence[str]] = None, reader="cuCIM", **kwargs, ): super().__init__(data, transform) # Ensure patch size is a two dimensional tuple - if size is None: - self.size = None + if patch_size is None: + self.patch_size = None else: - self.size = ensure_tuple_rep(size, 2) + self.patch_size = ensure_tuple_rep(patch_size, 2) # Create a default level that override all levels if it is not None - self.level = level + self.patch_level = patch_level # Set the default WSIReader's level to 0 if level is not provided - if level is None: - level = 0 + if patch_level is None: + patch_level = 0 # Setup the WSI reader self.wsi_reader: Union[WSIReader, BaseWSIReader] - self.backend = "" if isinstance(reader, str): - self.backend = reader.lower() - self.wsi_reader = WSIReader(backend=self.backend, level=level, **kwargs) + self.wsi_reader = WSIReader(backend=reader, level=patch_level, **kwargs) elif inspect.isclass(reader) and issubclass(reader, BaseWSIReader): - self.wsi_reader = reader(level=level, **kwargs) + self.wsi_reader = reader(level=patch_level, **kwargs) elif isinstance(reader, BaseWSIReader): self.wsi_reader = reader else: raise ValueError(f"Unsupported reader type: {reader}.") + self.backend = self.wsi_reader.backend + + self.include_label = include_label + self.center_location = center_location + self.additional_meta_keys = additional_meta_keys or [] # Initialized an empty whole slide image object dict self.wsi_object_dict: Dict = {} def _get_wsi_object(self, sample: Dict): - image_path = sample["image"] + image_path = sample[CommonKeys.IMAGE] if image_path not in self.wsi_object_dict: self.wsi_object_dict[image_path] = self.wsi_reader.read(image_path) return self.wsi_object_dict[image_path] def _get_label(self, sample: Dict): - return np.array(sample["label"], dtype=np.float32) + return np.array(sample[CommonKeys.LABEL], dtype=np.float32) def _get_location(self, sample: Dict): - size = self._get_size(sample) - return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + if self.center_location: + size = self._get_size(sample) + return [sample[WSIPatchKeys.LOCATION][i] - size[i] // 2 for i in range(len(size))] + else: + return sample[WSIPatchKeys.LOCATION] def _get_level(self, sample: Dict): - if self.level is None: - return sample.get("level", 0) - return self.level + if self.patch_level is None: + return sample.get(WSIPatchKeys.LEVEL, 0) + return self.patch_level def _get_size(self, sample: Dict): - if self.size is None: - return ensure_tuple_rep(sample.get("size"), 2) - return self.size + if self.patch_size is None: + return ensure_tuple_rep(sample.get(WSIPatchKeys.SIZE), 2) + return self.patch_size def _get_data(self, sample: Dict): # Don't store OpenSlide objects to avoid issues with OpenSlide internal cache @@ -131,12 +145,16 @@ def _transform(self, index: int): # Extract patch image and associated metadata image, metadata = self._get_data(sample) + output = {CommonKeys.IMAGE: image, CommonKeys.METADATA: metadata} + + # Include label in the output + if self.include_label: + output[CommonKeys.LABEL] = self._get_label(sample) - # Get the label - label = self._get_label(sample) + for key in self.additional_meta_keys: + metadata[key] = sample[key] - # Apply transforms and output - output = {"image": image, "label": label, "metadata": metadata} + # Apply transforms and return it return apply_transform(self.transform, output) if self.transform else output @@ -172,7 +190,7 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): [ {"image": "path/to/image1.tiff"}, - {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} ] """ @@ -180,18 +198,32 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, + mask_level: int = 0, overlap: Union[Tuple[float, float], float] = 0.0, offset: Union[Tuple[int, int], int, str] = (0, 0), offset_limits: Optional[Union[Tuple[Tuple[int, int], Tuple[int, int]], Tuple[int, int]]] = None, transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.SIZE, ProbMapKeys.COUNT), reader="cuCIM", map_level: int = 0, seed: int = 0, **kwargs, ): - super().__init__(data=[], size=size, level=level, transform=transform, reader=reader, **kwargs) + super().__init__( + data=data, + patch_size=patch_size, + patch_level=patch_level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) self.overlap = overlap self.set_random_state(seed) # Set the offset config @@ -221,12 +253,12 @@ def __init__( else: self.offset = ensure_tuple_rep(offset, 2) - self.map_level = map_level + self.mask_level = mask_level # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data for sample in self.image_data: - patch_samples = self._evaluate_patch_coordinates(sample) + patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) def _get_offset(self, sample): @@ -238,49 +270,133 @@ def _get_offset(self, sample): return tuple(self.R.randint(low, high) for low, high in offset_limits) return self.offset - def _evaluate_patch_coordinates(self, sample): - """Define the location for each patch in a sliding-window manner""" + def _evaluate_patch_locations(self, sample): + """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) - level = self._get_level(sample) - offset = self._get_offset(sample) - + patch_level = self._get_level(sample) wsi_obj = self._get_wsi_object(sample) + + # calculate the locations wsi_size = self.wsi_reader.get_size(wsi_obj, 0) - ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, level) - patch_size_ = tuple(p * ratio for p in patch_size) # patch size at level 0 - locations = list( - iter_patch_position( - image_size=wsi_size, patch_size=patch_size_, start_pos=offset, overlap=self.overlap, padded=False + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + offset = self._get_offset(sample) + patch_locations = np.array( + list( + iter_patch_position( + image_size=wsi_size, patch_size=patch_size_0, start_pos=offset, overlap=self.overlap, padded=False + ) ) ) - n_patches = len(locations) - sample["size"] = np.array(patch_size) - sample["level"] = level - sample["num_patches"] = n_patches - sample["map_level"] = self.map_level - sample["map_size"] = np.array(self.wsi_reader.get_size(wsi_obj, self.map_level)) + # convert locations to mask_location + mask_locations = np.round((patch_locations + patch_size_0 // 2) / float(mask_ratio)) + + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - {**sample, "location": np.array(loc), "map_center": self.downsample_center(loc, patch_size, ratio)} - for loc in locations + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) ] - def downsample_center(self, location: Tuple[int, int], patch_size: Tuple[int, int], ratio: float) -> np.ndarray: - """ - For a given location at level=0, evaluate the corresponding center position of patch at level=`level` - """ - center_location = [int((l + p // 2) / ratio) for l, p in zip(location, patch_size)] - return np.array(center_location) - def _get_location(self, sample: Dict): - return sample["location"] +class MaskedPatchWSIDataset(PatchWSIDataset): + """ + This dataset extracts patches from whole slide images at the locations where foreground mask + at a given level is non-zero. - def _transform(self, index: int): - # Get a single entry of data - sample: Dict = self.data[index] - # Extract patch image and associated metadata - image, metadata = self._get_data(sample) - for k in ["num_patches", "map_level", "map_size", "map_center"]: - metadata[k] = sample[k] - # Create put all patch information together and apply transforms - patch = {"image": image, "metadata": metadata} - return apply_transform(self.transform, patch) if self.transform else patch + Args: + data: the list of input samples including image, location, and label (see the note below for more details). + size: the size of patch to be extracted from the whole slide image. + level: the level at which the patches to be extracted (default to 0). + mask_level: the resolution level at which the mask is created. + transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output metadata from the input data + reader: the module to be used for loading whole slide imaging. Defaults to cuCIM. If `reader` is + + - a string, it defines the backend of `monai.data.WSIReader`. + - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, + - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class + + Note: + The input data has the following form as an example: + + .. code-block:: python + + [ + {"image": "path/to/image1.tiff"}, + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} + ] + + """ + + def __init__( + self, + data: Sequence, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, + mask_level: int = 7, + transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.NAME), + reader="cuCIM", + **kwargs, + ): + super().__init__( + data=data, + patch_size=patch_size, + patch_level=patch_level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) + + self.mask_level = mask_level + # Create single sample for each patch (in a sliding window manner) + self.data = [] + self.image_data = data + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) + self.data.extend(patch_samples) + + def _evaluate_patch_locations(self, sample): + """Calculate the location for each patch based on the mask at different resolution level""" + patch_size = self._get_size(sample) + patch_level = self._get_level(sample) + wsi_obj = self._get_wsi_object(sample) + + # load the entire image at level=mask_level + wsi, _ = self.wsi_reader.get_data(wsi_obj, level=self.mask_level) + + # create the foreground tissue mask and get all indices for non-zero pixels + mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) + mask_locations = np.vstack(mask.nonzero()).T + + # convert mask locations to image locations at level=0 + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + patch_locations = np.round((mask_locations + 0.5) * float(mask_ratio) - patch_size_0 // 2).astype(int) + + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = mask.shape + return [ + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) + ] diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 434edfd9cc..0bb2de987c 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,8 +18,7 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst -from monai.utils import ensure_tuple, optional_import, require_pkg +from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") OpenSlide, _ = optional_import("openslide", name="OpenSlide") @@ -56,9 +55,10 @@ class BaseWSIReader(ImageReader): supported_suffixes: List[str] = [] backend = "" - def __init__(self, level: int, **kwargs): + def __init__(self, level: int, channel_dim: int = 0, **kwargs): super().__init__() self.level = level + self.channel_dim = channel_dim self.kwargs = kwargs self.metadata: Dict[Any, Any] = {} @@ -136,14 +136,18 @@ def get_metadata( level: the level number. Defaults to 0 """ + if self.channel_dim >= len(patch.shape) or self.channel_dim < -len(patch.shape): + ValueError(f"The desired channel_dim ({self.channel_dim}) is out of bound for image shape: {patch.shape}") + channel_dim: int = self.channel_dim + (len(patch.shape) if self.channel_dim < 0 else 0) metadata: Dict = { "backend": self.backend, - "original_channel_dim": 0, - "spatial_shape": np.asarray(patch.shape[1:]), - "path": self.get_file_path(wsi), - "patch_location": np.asarray(location), - "patch_size": np.asarray(size), - "patch_level": level, + "original_channel_dim": channel_dim, + "spatial_shape": np.array(patch.shape[:channel_dim] + patch.shape[channel_dim + 1 :]), + "num_patches": 1, + WSIPatchKeys.PATH.value: self.get_file_path(wsi), + WSIPatchKeys.LOCATION.value: np.asarray(location), + WSIPatchKeys.SIZE.value: np.asarray(size), + WSIPatchKeys.LEVEL.value: level, } return metadata @@ -173,7 +177,7 @@ def get_data( and second element is a dictionary of metadata """ patch_list: List = [] - metadata = {} + metadata_list: List = [] # CuImage object is iterable, so ensure_tuple won't work on single object if not isinstance(wsi, List): wsi = [wsi] @@ -195,7 +199,7 @@ def get_data( # Verify size if size is None: if location != (0, 0): - raise ValueError("Patch size should be defined to exctract patches.") + raise ValueError("Patch size should be defined to extract patches.") size = self.get_size(each_wsi, level) else: if size[0] <= 0 or size[1] <= 0: @@ -211,40 +215,30 @@ def get_data( "`WSIReader` is designed to work only with 2D images with color channel." ) # Check if there are four color channels for RGBA - if mode == "RGBA" and patch.shape[0] != 4: - raise ValueError( - f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." - ) + if mode == "RGBA": + if patch.shape[self.channel_dim] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}." + ) # Check if there are three color channels for RGB - elif mode in "RGB" and patch.shape[0] != 3: + elif mode in "RGB" and patch.shape[self.channel_dim] != 3: raise ValueError( - f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) - # Create a list of patches + # Get patch-related metadata + metadata: dict = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) + # Create a list of patches and metadata patch_list.append(patch) - - # Set patch-related metadata - each_meta = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) - - if len(wsi) == 1: - metadata = each_meta - else: - if not metadata: - metadata = { - "backend": each_meta["backend"], - "original_channel_dim": each_meta["original_channel_dim"], - "spatial_shape": each_meta["spatial_shape"], - } - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k] = [each_meta[k]] - else: - if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: - raise ValueError("original_channel_dim is not consistent across wsi objects.") - if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): - raise ValueError("spatial_shape is not consistent across wsi objects.") - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k].append(each_meta[k]) - + metadata_list.append(metadata) + if len(wsi) > 1: + if len({m["original_channel_dim"] for m in metadata_list}) > 1: + raise ValueError("original_channel_dim is not consistent across wsi objects.") + if len({tuple(m["spatial_shape"]) for m in metadata_list}) > 1: + raise ValueError("spatial_shape is not consistent across wsi objects.") + for key in WSIPatchKeys: + metadata[key] = [m[key] for m in metadata_list] return _stack_images(patch_list, metadata), metadata def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: @@ -267,18 +261,19 @@ class WSIReader(BaseWSIReader): Args: backend: the name of backend whole slide image reader library, the default is cuCIM. level: the level at which patches are extracted. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional arguments to be passed to the backend library """ - def __init__(self, backend="cucim", level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, backend="cucim", level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) self.backend = backend.lower() self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": - self.reader = CuCIMWSIReader(level=level, **kwargs) + self.reader = CuCIMWSIReader(level=level, channel_dim=channel_dim, **kwargs) elif self.backend == "openslide": - self.reader = OpenSlideWSIReader(level=level, **kwargs) + self.reader = OpenSlideWSIReader(level=level, channel_dim=channel_dim, **kwargs) else: raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.") self.supported_suffixes = self.reader.supported_suffixes @@ -360,6 +355,7 @@ class CuCIMWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `cucim.CuImage` module: https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h @@ -368,8 +364,8 @@ class CuCIMWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -457,14 +453,15 @@ def get_patch( # Convert to numpy patch = np.asarray(patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode in "RGB": - if patch.shape[0] not in [3, 4]: + if patch.shape[self.channel_dim] not in [3, 4]: raise ValueError( - f"The image is expected to have three or four color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three or four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) patch = patch[:3] @@ -479,6 +476,7 @@ class OpenSlideWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `openslide.OpenSlide` module. """ @@ -486,8 +484,8 @@ class OpenSlideWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -577,7 +575,7 @@ def get_patch( # Convert to numpy patch = np.asarray(pil_patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) return patch diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py index 03588e605b..5c15704d50 100644 --- a/monai/handlers/probability_maps.py +++ b/monai/handlers/probability_maps.py @@ -71,9 +71,9 @@ def attach(self, engine: Engine) -> None: # Initialized probability maps for all the images for sample in image_data: - name = sample[ProbMapKeys.PRE_PATH.value] - self.counter[name] = sample[ProbMapKeys.COUNT.value] - self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE.value], dtype=self.dtype) + name = sample[ProbMapKeys.NAME] + self.counter[name] = sample[ProbMapKeys.COUNT] + self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE], dtype=self.dtype) if self._name is None: self.logger = engine.logger @@ -91,8 +91,8 @@ def __call__(self, engine: Engine) -> None: """ if not isinstance(engine.state.batch, dict) or not isinstance(engine.state.output, dict): raise ValueError("engine.state.batch and engine.state.output must be dictionaries.") - names = engine.state.batch["metadata"][ProbMapKeys.PATH.value] - locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION.value] + names = engine.state.batch["metadata"][ProbMapKeys.NAME] + locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION] probs = engine.state.output[self.prob_key] for name, loc, prob in zip(names, locs, probs): self.prob_map[name][loc] = prob diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 43ed2df62a..9cbd4f4310 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2210,7 +2210,6 @@ def __init__( raise ValueError( f"Threshold for at least one channel of RGB or HSV needs to be set. {self.thresholds} is provided." ) - self.invert = invert def _set_threshold(self, threshold, mode): diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 92cbf8b580..e157959a90 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2637,7 +2637,9 @@ class GridPatch(Transform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ @@ -2647,42 +2649,38 @@ class GridPatch(Transform): def __init__( self, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): self.patch_size = ensure_tuple(patch_size) - self.offset = ensure_tuple(offset) - self.pad_mode: NumpyPadMode = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) + self.offset = ensure_tuple(offset) if offset else (0,) * len(self.patch_size) + self.pad_mode: Optional[NumpyPadMode] = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else None self.pad_kwargs = pad_kwargs self.overlap = overlap self.num_patches = num_patches self.sort_fn: Optional[Callable] if isinstance(sort_fn, str): - if sort_fn == GridPatchSort.RANDOM.value: - self.sort_fn = np.random.random - elif sort_fn == GridPatchSort.MIN.value: - self.sort_fn = self.get_patch_sum - elif sort_fn == GridPatchSort.MAX.value: - self.sort_fn = self.get_negative_patch_sum - else: - raise ValueError( - f'sort_fn should be one of the following values, "{sort_fn}" was given:', - [enum.value for enum in GridPatchSort], - ) + self.sort_fn = GridPatchSort.get_sort_fn(sort_fn) else: self.sort_fn = sort_fn - @staticmethod - def get_patch_sum(x): - return x[0].sum() + self.threshold = threshold + if threshold: + self.filter_fn = self.threshold_fn + else: + self.filter_fn = self.one_fn @staticmethod - def get_negative_patch_sum(x): - return -x[0].sum() + def one_fn(patch): + return True + + def threshold_fn(self, patch): + return patch.sum() < self.threshold def __call__(self, array: NdarrayOrTensor): # create the patch iterator which sweeps the image row-by-row @@ -2696,18 +2694,25 @@ def __call__(self, array: NdarrayOrTensor): mode=self.pad_mode, **self.pad_kwargs, ) + if self.sort_fn is not None: - output = sorted(patch_iterator, key=self.sort_fn) - else: - output = list(patch_iterator) + patch_iterator = sorted(patch_iterator, key=self.sort_fn) + + output = [ + (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices[..., 0], dst=array)[0]) + for patch, slices in patch_iterator + if self.filter_fn(patch) + ] + if self.num_patches: output = output[: self.num_patches] if len(output) < self.num_patches: - patch = np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)) - slices = np.zeros((3, len(self.patch_size))) - output += [(patch, slices)] * (self.num_patches - len(output)) - - output = [convert_to_dst_type(src=patch, dst=array)[0] for patch in output] + patch = convert_to_dst_type( + src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), + dst=array, + )[0] + start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] + output += [(patch, start_location)] * (self.num_patches - len(output)) return output @@ -2731,7 +2736,9 @@ class RandGridPatch(GridPatch, RandomizableTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ @@ -2746,6 +2753,7 @@ def __init__( num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): @@ -2755,6 +2763,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 4a38dbbb59..a1dd60b6a0 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -62,6 +62,7 @@ InterpolateMode, NumpyPadMode, PytorchPadMode, + WSIPatchKeys, ensure_tuple, ensure_tuple_rep, fall_back_tuple, @@ -2170,7 +2171,7 @@ class GridSplitd(MapTransform): keys: keys of the corresponding items to be transformed. grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) size: a tuple or an integer that defines the output patch sizes, - or a dictionary that define it seperately for each key, like {"image": 3, "mask", (2, 2)}. + or a dictionary that define it separately for each key, like {"image": 3, "mask", (2, 2)}. If it's an integer, the value will be repeated for each dimension. The default is None, where the patch size will be inferred from the grid shape. allow_missing_keys: don't raise exception if key is missing. @@ -2220,7 +2221,9 @@ class GridPatchd(MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2228,7 +2231,7 @@ class GridPatchd(MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of upper left patch) @@ -2240,10 +2243,11 @@ def __init__( self, keys: KeysCollection, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2255,6 +2259,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) @@ -2272,9 +2277,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output @@ -2300,7 +2305,9 @@ class RandGridPatchd(RandomizableTransform, MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2308,7 +2315,7 @@ class RandGridPatchd(RandomizableTransform, MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of the first patch) @@ -2326,6 +2333,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2338,6 +2346,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) @@ -2368,9 +2377,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 0d5d8bf92d..33b2a5fa2a 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -40,6 +40,7 @@ TransformBackends, UpsampleMode, Weight, + WSIPatchKeys, ) from .jupyter_utils import StatusMembers, ThreadContainer from .misc import ( diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 9fb4e480f6..51d2772414 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random from enum import Enum from typing import Optional @@ -273,6 +274,7 @@ class CommonKeys: LABEL = "label" PRED = "pred" LOSS = "loss" + METADATA = "metadata" class PostFix: @@ -332,7 +334,7 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] -class ProbMapKeys(Enum): +class ProbMapKeys(str, Enum): """ The keys to be used for generating the probability maps from patches """ @@ -340,11 +342,10 @@ class ProbMapKeys(Enum): LOCATION = "mask_location" SIZE = "mask_size" COUNT = "num_patches" - PATH = "path" - PRE_PATH = "image" + NAME = "name" -class GridPatchSort(Enum): +class GridPatchSort(str, Enum): """ The sorting method for the generated patches in `GridPatch` """ @@ -352,3 +353,37 @@ class GridPatchSort(Enum): RANDOM = "random" MIN = "min" MAX = "max" + + @staticmethod + def min_fn(x): + return x[0].sum() + + @staticmethod + def max_fn(x): + return -x[0].sum() + + @staticmethod + def get_sort_fn(sort_fn): + if sort_fn == GridPatchSort.RANDOM: + return random.random + elif sort_fn == GridPatchSort.MIN: + return GridPatchSort.min_fn + elif sort_fn == GridPatchSort.MAX: + return GridPatchSort.max_fn + else: + raise ValueError( + f'sort_fn should be one of the following values, "{sort_fn}" was given:', + [e.value for e in GridPatchSort], + ) + + +class WSIPatchKeys(str, Enum): + """ + The keys to be used for metadata of patches extracted from whole slide images + """ + + LOCATION = "patch_location" + LEVEL = "patch_level" + SIZE = "patch_size" + COUNT = "num_patches" + PATH = "path" diff --git a/tests/test_grid_patch.py b/tests/test_grid_patch.py index c1d73f262f..8a105afcd2 100644 --- a/tests/test_grid_patch.py +++ b/tests/test_grid_patch.py @@ -44,6 +44,7 @@ A, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] @@ -61,6 +62,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatch(unittest.TestCase): diff --git a/tests/test_grid_patchd.py b/tests/test_grid_patchd.py index a9eec8a2f6..8f1e238b42 100644 --- a/tests/test_grid_patchd.py +++ b/tests/test_grid_patchd.py @@ -44,7 +44,7 @@ {"image": A}, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] - +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -61,6 +61,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatchd(unittest.TestCase): diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py index 38ab7044b2..399657ac60 100644 --- a/tests/test_handler_prob_map_producer.py +++ b/tests/test_handler_prob_map_producer.py @@ -42,7 +42,11 @@ def __init__(self, name, size): ] ) self.image_data = [ - {"image": name, ProbMapKeys.COUNT.value: size, ProbMapKeys.SIZE.value: np.array([size, size])} + { + ProbMapKeys.NAME.value: name, + ProbMapKeys.COUNT.value: size, + ProbMapKeys.SIZE.value: np.array([size, size]), + } ] def __getitem__(self, index): @@ -50,7 +54,7 @@ def __getitem__(self, index): "image": np.zeros((3, 2, 2)), ProbMapKeys.COUNT.value: self.data[index][ProbMapKeys.COUNT.value], "metadata": { - "path": self.data[index]["image"], + ProbMapKeys.NAME.value: self.data[index]["image"], ProbMapKeys.SIZE.value: self.data[index][ProbMapKeys.SIZE.value], ProbMapKeys.LOCATION.value: self.data[index][ProbMapKeys.LOCATION.value], }, diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py new file mode 100644 index 0000000000..c1d202f8f4 --- /dev/null +++ b/tests/test_masked_patch_wsi_dataset.py @@ -0,0 +1,86 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import MaskedPatchWSIDataset +from monai.utils import WSIPatchKeys, optional_import, set_determinism +from tests.utils import testing_data_config + +set_determinism(0) + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (2, 2)}], "mask_level": 8}, + { + "num_patches": 4256, + "wsi_size": [32914, 46000], + "mask_level": 8, + "patch_level": 8, + "mask_size": (128, 179), + "patch_size": (2, 2), + }, +] + + +class MaskedPatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_gen_patches(self, input_parameters, expected): + dataset = MaskedPatchWSIDataset(reader=self.backend, **input_parameters) + self.assertEqual(len(dataset), expected["num_patches"]) + for i, sample in enumerate(dataset): + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected["patch_size"]) + assert_array_equal(sample["image"].shape[1:], expected["patch_size"]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] < expected["wsi_size"][0]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] < expected["wsi_size"][1]) + if i > 10: + break + + +@skipUnless(has_cucim, "Requires cucim") +class TestSlidingPatchWSIDatasetCuCIM(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestSlidingPatchWSIDatasetOpenSlide(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py index d128d45262..65e65035c4 100644 --- a/tests/test_patch_wsi_dataset_new.py +++ b/tests/test_patch_wsi_dataset_new.py @@ -35,41 +35,41 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1], "level": 0}], "size": (1, 1)}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "size": 1, "label": [1]}]}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": 1, "level": 0}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "size": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, ] TEST_CASE_4 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, ], - "size": 1, + "patch_size": 1, }, [ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, @@ -80,8 +80,20 @@ TEST_CASE_5 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]], "size": 1, "level": 1}, - {"image": FILE_PATH, "location": [100, 100], "label": [[[1, 0], [0, 0]]], "size": 1, "level": 1}, + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, ] }, [ @@ -129,9 +141,9 @@ def test_read_patches_class(self, input_parameters, expected): @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_read_patches_object(self, input_parameters, expected): if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("level", 0)) + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("level", 0)) + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) else: raise ValueError("Unsupported backend: {self.backend}") dataset = PatchWSIDataset(reader=reader, **input_parameters) diff --git a/tests/test_rand_grid_patch.py b/tests/test_rand_grid_patch.py index 36da899982..3957dc1ce8 100644 --- a/tests/test_rand_grid_patch.py +++ b/tests/test_rand_grid_patch.py @@ -55,6 +55,7 @@ A, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatch(unittest.TestCase): diff --git a/tests/test_rand_grid_patchd.py b/tests/test_rand_grid_patchd.py index 6f89a3d155..656fbd9e36 100644 --- a/tests/test_rand_grid_patchd.py +++ b/tests/test_rand_grid_patchd.py @@ -55,6 +55,7 @@ {"image": A}, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatchd(unittest.TestCase): diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index ded0a9213c..5f2a2c0d55 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -14,10 +14,11 @@ from unittest import skipUnless import numpy as np +from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import SlidingPatchWSIDataset -from monai.utils import optional_import, set_determinism +from monai.utils import WSIPatchKeys, optional_import, set_determinism from tests.utils import download_url_or_skip_test, testing_data_config set_determinism(0) @@ -41,7 +42,7 @@ ARRAY_SMALL_1 = np.random.randint(low=0, high=255, size=(3, 5, 5), dtype=np.uint8) TEST_CASE_SMALL_0 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2)}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -51,7 +52,7 @@ ] TEST_CASE_SMALL_1 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}]}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}]}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -61,7 +62,7 @@ ] TEST_CASE_SMALL_2 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2), "overlap": 0.5}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2), "overlap": 0.5}, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 1:3]}, @@ -76,7 +77,7 @@ ] TEST_CASE_SMALL_3 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (3, 3), "overlap": 2.0 / 3.0}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (3, 3), "overlap": 2.0 / 3.0}, [ {"image": ARRAY_SMALL_0[:, :3, :3]}, {"image": ARRAY_SMALL_0[:, :3, 1:]}, @@ -86,7 +87,13 @@ ] TEST_CASE_SMALL_4 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}, {"image": FILE_PATH_SMALL_1, "level": 0}], "size": (2, 2)}, + { + "data": [ + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0}, + ], + "patch_size": (2, 2), + }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, @@ -102,8 +109,8 @@ TEST_CASE_SMALL_5 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}, - {"image": FILE_PATH_SMALL_1, "level": 0, "size": (3, 3)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (3, 3)}, ] }, [ @@ -118,11 +125,11 @@ TEST_CASE_SMALL_6 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 1, "size": (1, 1)}, - {"image": FILE_PATH_SMALL_1, "level": 2, "size": (4, 4)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 1, WSIPatchKeys.SIZE: (1, 1)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 2, WSIPatchKeys.SIZE: (4, 4)}, ], - "size": (2, 2), - "level": 0, + "patch_size": (2, 2), + "patch_level": 0, }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, @@ -138,18 +145,22 @@ TEST_CASE_SMALL_7 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": (1, 0)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": (1, 0)}, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_8 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": "random", "offset_limits": (0, 2)}, + { + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], + "offset": "random", + "offset_limits": (0, 2), + }, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_9 = [ { - "data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": "random", "offset_limits": ((0, 3), (0, 2)), }, @@ -157,37 +168,37 @@ ] TEST_CASE_LARGE_0 = [ - {"data": [{"image": FILE_PATH, "level": 8, "size": (64, 50)}]}, + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}]}, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, ], ] TEST_CASE_LARGE_1 = [ { "data": [ - {"image": FILE_PATH, "level": 8, "size": (64, 50)}, - {"image": FILE_PATH, "level": 7, "size": (125, 110)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 7, WSIPatchKeys.SIZE: (125, 110)}, ] }, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, ], ] @@ -233,11 +244,11 @@ def test_read_patches_large(self, input_parameters, expected): dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) self.assertEqual(len(dataset), len(expected)) for i, sample in enumerate(dataset): - self.assertEqual(sample["metadata"]["patch_level"], expected[i]["level"]) - self.assertTupleEqual(tuple(sample["metadata"]["patch_size"]), expected[i]["size"]) - steps = [round(expected[i]["ratio"] * s) for s in expected[i]["size"]] + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected[i]["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected[i]["patch_size"]) + steps = [round(expected[i]["ratio"] * s) for s in expected[i]["patch_size"]] expected_location = tuple(expected[i]["step_loc"][j] * steps[j] for j in range(len(steps))) - self.assertTupleEqual(tuple(sample["metadata"]["patch_location"]), expected_location) + assert_array_equal(sample["metadata"][WSIPatchKeys.LOCATION], expected_location) @skipUnless(has_cucim, "Requires cucim") diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 35aaccad26..8330603db0 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -14,7 +14,6 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized @@ -23,7 +22,7 @@ from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -46,17 +45,33 @@ TEST_CASE_1 = [ FILE_PATH, + {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, + {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ + FILE_PATH, + {"channel_dim": -1}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), +] + +TEST_CASE_4 = [ + FILE_PATH, + {"channel_dim": 2}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), +] + +TEST_CASE_MULTI_WSI = [ [FILE_PATH, FILE_PATH], {"location": (0, 0), "size": (2, 1), "level": 2}, np.concatenate( @@ -68,6 +83,7 @@ ), ] + TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW @@ -133,11 +149,10 @@ def test_read_whole_image(self, file_path, level, expected_shape): assert_array_equal(meta["patch_size"], expected_shape[1:]) assert_array_equal(meta["patch_location"], (0, 0)) - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_read_region(self, file_path, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path, **kwargs) as img_obj: + with reader.read(file_path) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] @@ -152,10 +167,10 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertEqual(meta["backend"], self.backend) self.assertEqual(meta["path"], str(os.path.abspath(file_path))) self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], expected_img.shape[1:]) + assert_array_equal(meta["patch_size"], patch_info["size"]) assert_array_equal(meta["patch_location"], patch_info["location"]) - @parameterized.expand([TEST_CASE_3]) + @parameterized.expand([TEST_CASE_MULTI_WSI]) def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) @@ -222,7 +237,7 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, expected_shape) @parameterized.expand([TEST_CASE_TRANSFORM_0]) @@ -238,7 +253,7 @@ def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, e data_loader = DataLoader(dataset, batch_size=batch_size) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:]))