diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index 05dc8a196e..fe145b0f50 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -261,11 +261,9 @@ def get_surface_distance( - ``"euclidean"``, uses Exact Euclidean distance transform. - ``"chessboard"``, uses `chessboard` metric in chamfer type of transform. - ``"taxicab"``, uses `taxicab` metric in chamfer type of transform. - spacing: spacing of pixel (or voxel) along each axis. If a sequence, must be of - length equal to the image dimensions; if a single number, this is used for all axes. - If ``None``, spacing of unity is used. Defaults to ``None``. spacing: spacing of pixel (or voxel). This parameter is relevant only if ``distance_metric`` is set to ``"euclidean"``. - Several input options are allowed: (1) If a single number, isotropic spacing with that value is used. + Several input options are allowed: + (1) If a single number, isotropic spacing with that value is used. (2) If a sequence of numbers, the length of the sequence must be equal to the image dimensions. (3) If ``None``, spacing of unity is used. Defaults to ``None``. diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index f10dd21642..3d5d30be92 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -361,7 +361,8 @@ class RemoveSmallObjects(Transform): Data should be one-hotted. Args: - min_size: objects smaller than this size (in pixel) are removed. + min_size: objects smaller than this size (in number of voxels; or surface area/volume value + in whatever units your image is if by_measure is True) are removed. connectivity: Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. Accepted values are ranging from 1 to input.ndim. If ``None``, a full connectivity of ``input.ndim`` is used. For more details refer to linked scikit-image @@ -369,14 +370,61 @@ class RemoveSmallObjects(Transform): independent_channels: Whether or not to consider channels as independent. If true, then conjoining islands from different labels will be removed if they are below the threshold. If false, the overall size islands made from all non-background voxels will be used. + by_measure: Whether the specified min_size is in number of voxels. if this is True then min_size + represents a surface area or volume value of whatever units your image is in (mm^3, cm^2, etc.) + default is False. e.g. if min_size is 3, by_measure is True and the units of your data is mm, + objects smaller than 3mm^3 are removed. + pixdim: the pixdim of the input image. if a single number, this is used for all axes. + If a sequence of numbers, the length of the sequence must be equal to the image dimensions. + + Example:: + + .. code-block:: python + + from monai.transforms import RemoveSmallObjects, Spacing, Compose + from monai.data import MetaTensor + + data1 = torch.tensor([[[0, 0, 0, 0, 0], [0, 1, 1, 0, 1], [0, 0, 0, 1, 1]]]) + affine = torch.as_tensor([[2,0,0,0], + [0,1,0,0], + [0,0,1,0], + [0,0,0,1]], dtype=torch.float64) + data2 = MetaTensor(data1, affine=affine) + + # remove objects smaller than 3mm^3, input is MetaTensor + trans = RemoveSmallObjects(min_size=3, by_measure=True) + out = trans(data2) + # remove objects smaller than 3mm^3, input is not MetaTensor + trans = RemoveSmallObjects(min_size=3, by_measure=True, pixdim=(2, 1, 1)) + out = trans(data1) + + # remove objects smaller than 3 (in pixel) + trans = RemoveSmallObjects(min_size=3) + out = trans(data2) + + # If the affine of the data is not identity, you can also add Spacing before. + trans = Compose([ + Spacing(pixdim=(1, 1, 1)), + RemoveSmallObjects(min_size=3) + ]) + """ backend = [TransformBackends.NUMPY] - def __init__(self, min_size: int = 64, connectivity: int = 1, independent_channels: bool = True) -> None: + def __init__( + self, + min_size: int = 64, + connectivity: int = 1, + independent_channels: bool = True, + by_measure: bool = False, + pixdim: Sequence[float] | float | np.ndarray | None = None, + ) -> None: self.min_size = min_size self.connectivity = connectivity self.independent_channels = independent_channels + self.by_measure = by_measure + self.pixdim = pixdim def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -387,7 +435,10 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: Returns: An array with shape (C, spatial_dim1[, spatial_dim2, ...]). """ - return remove_small_objects(img, self.min_size, self.connectivity, self.independent_channels) + + return remove_small_objects( + img, self.min_size, self.connectivity, self.independent_channels, self.by_measure, self.pixdim + ) class LabelFilter(Transform): diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 393f161917..7e1e074f71 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -270,7 +270,8 @@ class RemoveSmallObjectsd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.RemoveSmallObjectsd`. Args: - min_size: objects smaller than this size are removed. + min_size: objects smaller than this size (in number of voxels; or surface area/volume value + in whatever units your image is if by_measure is True) are removed. connectivity: Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. Accepted values are ranging from 1 to input.ndim. If ``None``, a full connectivity of ``input.ndim`` is used. For more details refer to linked scikit-image @@ -278,6 +279,12 @@ class RemoveSmallObjectsd(MapTransform): independent_channels: Whether or not to consider channels as independent. If true, then conjoining islands from different labels will be removed if they are below the threshold. If false, the overall size islands made from all non-background voxels will be used. + by_measure: Whether the specified min_size is in number of voxels. if this is True then min_size + represents a surface area or volume value of whatever units your image is in (mm^3, cm^2, etc.) + default is False. e.g. if min_size is 3, by_measure is True and the units of your data is mm, + objects smaller than 3mm^3 are removed. + pixdim: the pixdim of the input image. if a single number, this is used for all axes. + If a sequence of numbers, the length of the sequence must be equal to the image dimensions. """ backend = RemoveSmallObjects.backend @@ -288,10 +295,12 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, + by_measure: bool = False, + pixdim: Sequence[float] | float | np.ndarray | None = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) - self.converter = RemoveSmallObjects(min_size, connectivity, independent_channels) + self.converter = RemoveSmallObjects(min_size, connectivity, independent_channels, by_measure, pixdim) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: d = dict(data) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 44e5b25a34..678219991f 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1068,7 +1068,12 @@ def get_largest_connected_component_mask( def remove_small_objects( - img: NdarrayTensor, min_size: int = 64, connectivity: int = 1, independent_channels: bool = True + img: NdarrayTensor, + min_size: int = 64, + connectivity: int = 1, + independent_channels: bool = True, + by_measure: bool = False, + pixdim: Sequence[float] | float | np.ndarray | None = None, ) -> NdarrayTensor: """ Use `skimage.morphology.remove_small_objects` to remove small objects from images. @@ -1085,6 +1090,11 @@ def remove_small_objects( connectivity of ``input.ndim`` is used. For more details refer to linked scikit-image documentation. independent_channels: Whether to consider each channel independently. + by_measure: Whether the specified min_size is in number of voxels. if this is True then min_size + represents a surface area or volume value of whatever units your image is in (mm^3, cm^2, etc.) + default is False. + pixdim: the pixdim of the input image. if a single number, this is used for all axes. + If a sequence of numbers, the length of the sequence must be equal to the image dimensions. """ # if all equal to one value, no need to call skimage if len(unique(img)) == 1: @@ -1093,6 +1103,23 @@ def remove_small_objects( if not has_morphology: raise RuntimeError("Skimage required.") + if by_measure: + sr = len(img.shape[1:]) + if isinstance(img, monai.data.MetaTensor): + _pixdim = img.pixdim + elif pixdim is not None: + _pixdim = ensure_tuple_rep(pixdim, sr) + else: + warnings.warn("`img` is not of type MetaTensor and `pixdim` is None, assuming affine to be identity.") + _pixdim = (1.0,) * sr + voxel_volume = np.prod(np.array(_pixdim)) + if voxel_volume == 0: + warnings.warn("Invalid `pixdim` value detected, set it to 1. Please verify the pixdim settings.") + voxel_volume = 1 + min_size = np.ceil(min_size / voxel_volume) + elif pixdim is not None: + warnings.warn("`pixdim` is specified but not in use when computing the volume.") + img_np: np.ndarray img_np, *_ = convert_data_type(img, np.ndarray) diff --git a/tests/test_remove_small_objects.py b/tests/test_remove_small_objects.py index 27dd648e24..4f2c9e9a7d 100644 --- a/tests/test_remove_small_objects.py +++ b/tests/test_remove_small_objects.py @@ -14,6 +14,7 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.data.meta_tensor import MetaTensor @@ -31,6 +32,12 @@ TEST_OUTPUT1 = np.array([[[0, 0, 2, 1, 0], [1, 1, 1, 2, 0], [1, 1, 1, 0, 0]]]) +TEST_INPUT2 = np.array([[[1, 1, 1, 0, 0], [1, 1, 1, 0, 0], [0, 0, 0, 0, 0], [0, 1, 1, 0, 1], [0, 0, 0, 1, 1]]]) +affine = torch.eye(4, dtype=torch.float64) +affine[0, 0] = 2.0 +TEST_INPUT3 = MetaTensor(TEST_INPUT2, affine=affine) + + TESTS: list[tuple] = [] for dtype in (int, float): for p in TEST_NDARRAYS: @@ -41,6 +48,11 @@ # for non-independent channels, the twos should stay TESTS.append((dtype, p, TEST_INPUT1, TEST_OUTPUT1, {"min_size": 2, "independent_channels": False})) +TESTS_PHYSICAL: list[tuple] = [] +for dtype in (int, float): + TESTS_PHYSICAL.append((dtype, np.array, TEST_INPUT2, None, {"min_size": 3, "by_measure": True, "pixdim": (2, 1)})) + TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "by_measure": True})) + @SkipIfNoModule("skimage.morphology") class TestRemoveSmallObjects(unittest.TestCase): @@ -57,6 +69,22 @@ def test_remove_small_objects(self, dtype, im_type, lbl, expected, params=None): if isinstance(lbl, MetaTensor): assert_allclose(lbl.affine, lbl_clean.affine) + @parameterized.expand(TESTS_PHYSICAL) + def test_remove_small_objects_physical(self, dtype, im_type, lbl, expected, params): + params = params or {} + min_size = np.ceil(params["min_size"] / 2) + + if expected is None: + dtype = bool if lbl.max() <= 1 else int + expected = morphology.remove_small_objects(lbl.astype(dtype), min_size=min_size) + expected = im_type(expected, dtype=dtype) + lbl = im_type(lbl, dtype=dtype) + lbl_clean = RemoveSmallObjects(**params)(lbl) + assert_allclose(lbl_clean, expected, device_test=True) + + lbl_clean = RemoveSmallObjectsd("lbl", **params)({"lbl": lbl})["lbl"] + assert_allclose(lbl_clean, expected, device_test=True) + @parameterized.expand(TESTS) def test_remove_small_objects_dict(self, dtype, im_type, lbl, expected, params=None): params = params or {}