From fbca17488ddc1299c84231276a45bc5d07fed4c0 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Tue, 17 Oct 2023 17:59:39 +0800 Subject: [PATCH 01/15] fix #7034 Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 6 +++++- monai/transforms/utils.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index f10dd21642..98cd1f129c 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -369,11 +369,15 @@ 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. + physical_scale: Whether or not to consider min_size at physical scale, default is false. If true, + min_size will be multiplied by pixdim. """ 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, physical_scale: bool = False + ) -> None: self.min_size = min_size self.connectivity = connectivity self.independent_channels = independent_channels diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 44e5b25a34..6bfebb7d28 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1068,7 +1068,11 @@ 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, + physical_scale: bool = False, ) -> NdarrayTensor: """ Use `skimage.morphology.remove_small_objects` to remove small objects from images. @@ -1085,6 +1089,8 @@ 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. + physical_scale: Whether or not to consider min_size at physical scale, default is false. If true, + min_size will be multiplied by pixdim. """ # if all equal to one value, no need to call skimage if len(unique(img)) == 1: @@ -1093,6 +1099,12 @@ def remove_small_objects( if not has_morphology: raise RuntimeError("Skimage required.") + if physical_scale: + if isinstance(img, monai.data.MetaTensor): + min_size = int(np.prod(np.array(img.pixdim)) * min_size) + else: + warnings.warn("`img` is not of type MetaTensor, assuming affine to be identity.") + img_np: np.ndarray img_np, *_ = convert_data_type(img, np.ndarray) From 344357fd234203cbbc3b2244db8b3be05fa2313f Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Tue, 17 Oct 2023 18:01:08 +0800 Subject: [PATCH 02/15] minor fix Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 98cd1f129c..84f6607a1f 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -381,6 +381,7 @@ def __init__( self.min_size = min_size self.connectivity = connectivity self.independent_channels = independent_channels + self.physical_scale = physical_scale def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -391,7 +392,7 @@ 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.physical_scale) class LabelFilter(Transform): From d0826661fdbb2e53327d81285f58752a07b2e806 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 13:14:55 +0800 Subject: [PATCH 03/15] add resample option Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 84f6607a1f..73793c9e5a 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -27,6 +27,7 @@ from monai.networks import one_hot from monai.networks.layers import GaussianFilter, apply_filter, separable_filtering from monai.transforms.inverse import InvertibleTransform +from monai.transforms.spatial.array import Spacing from monai.transforms.transform import Transform from monai.transforms.utility.array import ToTensor from monai.transforms.utils import ( @@ -376,12 +377,20 @@ class RemoveSmallObjects(Transform): backend = [TransformBackends.NUMPY] def __init__( - self, min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, physical_scale: bool = False + self, + min_size: int = 64, + connectivity: int = 1, + independent_channels: bool = True, + physical_scale: bool = False, + resample: bool = False, + mode: str | int | None = None, ) -> None: self.min_size = min_size self.connectivity = connectivity self.independent_channels = independent_channels self.physical_scale = physical_scale + self.resample = resample + self.mode = mode def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -392,7 +401,22 @@ 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, self.physical_scale) + + if self.resample and self.physical_scale: + raise ValueError("Incompatible values: resample=True and physical_scale=True.") + + if self.resample and isinstance(img, MetaTensor): + original_pixdim = img.pixdim + img = Spacing(pixdim=(1, 1, 1))(img, self.mode) + + out = remove_small_objects( + img, self.min_size, self.connectivity, self.independent_channels, self.physical_scale + ) + + if self.resample and isinstance(img, MetaTensor): + out = Spacing(pixdim=original_pixdim)(out, self.mode) + + return out class LabelFilter(Transform): From 1d13e069419aecdc40da9ee660f58ecdfde804ba Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 13:55:23 +0800 Subject: [PATCH 04/15] minor fix Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 73793c9e5a..85b13917a9 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -407,14 +407,14 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: if self.resample and isinstance(img, MetaTensor): original_pixdim = img.pixdim - img = Spacing(pixdim=(1, 1, 1))(img, self.mode) + img = Spacing(pixdim=(1.0, 1.0, 1.0))(img, self.mode) out = remove_small_objects( img, self.min_size, self.connectivity, self.independent_channels, self.physical_scale ) if self.resample and isinstance(img, MetaTensor): - out = Spacing(pixdim=original_pixdim)(out, self.mode) + out = Spacing(pixdim=original_pixdim)(out, self.mode) # type: ignore return out From 5effcad98dba2ba243ae56ac7bf1405e07b0ff9d Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 16:19:49 +0800 Subject: [PATCH 05/15] add pixdim Signed-off-by: KumoLiu --- monai/metrics/utils.py | 6 ++-- monai/transforms/post/array.py | 63 ++++++++++++++++++++++------------ monai/transforms/utils.py | 12 +++++-- 3 files changed, 54 insertions(+), 27 deletions(-) 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 85b13917a9..038a9d0caa 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -362,7 +362,7 @@ 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 pixel or in physical scale if physical_scale 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 @@ -370,8 +370,43 @@ 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. - physical_scale: Whether or not to consider min_size at physical scale, default is false. If true, - min_size will be multiplied by pixdim. + physical_scale: Whether or not to consider min_size at physical scale, default is false. + If true, pixdim will be used to multiply min_size. e.g. if min_size is 3 and physical_scale + is True, 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, physical_scale=True) + out = trans(data2) + # remove objects smaller than 3mm^3, input is not MetaTensor + trans = RemoveSmallObjects(min_size=3, physical_scale=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] @@ -382,15 +417,13 @@ def __init__( connectivity: int = 1, independent_channels: bool = True, physical_scale: bool = False, - resample: bool = False, - mode: str | int | None = None, + pixdim: Sequence[float] | float | np.ndarray | None = None, ) -> None: self.min_size = min_size self.connectivity = connectivity self.independent_channels = independent_channels self.physical_scale = physical_scale - self.resample = resample - self.mode = mode + self.pixdim = pixdim def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ @@ -402,22 +435,10 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: An array with shape (C, spatial_dim1[, spatial_dim2, ...]). """ - if self.resample and self.physical_scale: - raise ValueError("Incompatible values: resample=True and physical_scale=True.") - - if self.resample and isinstance(img, MetaTensor): - original_pixdim = img.pixdim - img = Spacing(pixdim=(1.0, 1.0, 1.0))(img, self.mode) - - out = remove_small_objects( - img, self.min_size, self.connectivity, self.independent_channels, self.physical_scale + return remove_small_objects( + img, self.min_size, self.connectivity, self.independent_channels, self.physical_scale, self.pixdim ) - if self.resample and isinstance(img, MetaTensor): - out = Spacing(pixdim=original_pixdim)(out, self.mode) # type: ignore - - return out - class LabelFilter(Transform): """ diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 6bfebb7d28..2f3cd3a0cf 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1073,6 +1073,7 @@ def remove_small_objects( connectivity: int = 1, independent_channels: bool = True, physical_scale: bool = False, + pixdim: Sequence[float] | float | np.ndarray | None = None, ) -> NdarrayTensor: """ Use `skimage.morphology.remove_small_objects` to remove small objects from images. @@ -1091,6 +1092,8 @@ def remove_small_objects( independent_channels: Whether to consider each channel independently. physical_scale: Whether or not to consider min_size at physical scale, default is false. If true, min_size will be multiplied by pixdim. + 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: @@ -1100,10 +1103,15 @@ def remove_small_objects( raise RuntimeError("Skimage required.") if physical_scale: + sr = len(img.shape[1:]) if isinstance(img, monai.data.MetaTensor): - min_size = int(np.prod(np.array(img.pixdim)) * min_size) + _pixdim = img.pixdim + elif pixdim is not None: + _pixdim = ensure_tuple_rep(pixdim, sr) else: - warnings.warn("`img` is not of type MetaTensor, assuming affine to be identity.") + warnings.warn("`img` is not of type MetaTensor and `pixdim` is None, assuming affine to be identity.") + _pixdim = (1.0, ) * sr + min_size = int(np.prod(np.array(_pixdim)) * min_size) img_np: np.ndarray img_np, *_ = convert_data_type(img, np.ndarray) From 37d6c46bfda3047187b66a5e253f77d14d6661f6 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 16:20:02 +0800 Subject: [PATCH 06/15] fix flake8 Signed-off-by: KumoLiu --- monai/transforms/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 2f3cd3a0cf..b727f23093 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1110,7 +1110,7 @@ def remove_small_objects( _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 + _pixdim = (1.0,) * sr min_size = int(np.prod(np.array(_pixdim)) * min_size) img_np: np.ndarray From 50184716f301cc66858ddf77b08b218b3d806b00 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 16:22:06 +0800 Subject: [PATCH 07/15] update dictionary version Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 2 +- monai/transforms/post/dictionary.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 038a9d0caa..40ced5269c 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -376,7 +376,7 @@ class RemoveSmallObjects(Transform): 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: + Example:: .. code-block:: python diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 393f161917..0cf903d882 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -278,6 +278,11 @@ 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. + physical_scale: Whether or not to consider min_size at physical scale, default is false. + If true, pixdim will be used to multiply min_size. e.g. if min_size is 3 and physical_scale + is True, 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 +293,12 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, + physical_scale: 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, physical_scale, pixdim) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: d = dict(data) From 7c5ed9afcaff7a6dd7f4b3488c40ceef485b29d4 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 17:04:07 +0800 Subject: [PATCH 08/15] add unittest Signed-off-by: KumoLiu --- tests/test_remove_small_objects.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_remove_small_objects.py b/tests/test_remove_small_objects.py index 27dd648e24..887189abe8 100644 --- a/tests/test_remove_small_objects.py +++ b/tests/test_remove_small_objects.py @@ -31,6 +31,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 = np.eye(4) +affine[0, 0] = 2 +TEST_INPUT3 = MetaTensor(TEST_INPUT2, affine=affine) + + TESTS: list[tuple] = [] for dtype in (int, float): for p in TEST_NDARRAYS: @@ -41,6 +47,13 @@ # 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, "physical_scale": True, "pixdim": (2, 1)}) + ) + TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "physical_scale": True})) + @SkipIfNoModule("skimage.morphology") class TestRemoveSmallObjects(unittest.TestCase): @@ -57,6 +70,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 = 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 {} From 4ed8e2101a6a4e1bfe8585a23e6c3e4c5c8150e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 09:04:49 +0000 Subject: [PATCH 09/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/transforms/post/array.py | 1 - 1 file changed, 1 deletion(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 40ced5269c..91ff603d06 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -27,7 +27,6 @@ from monai.networks import one_hot from monai.networks.layers import GaussianFilter, apply_filter, separable_filtering from monai.transforms.inverse import InvertibleTransform -from monai.transforms.spatial.array import Spacing from monai.transforms.transform import Transform from monai.transforms.utility.array import ToTensor from monai.transforms.utils import ( From 730546ba477956e6acf9f4f58618aeda1d2e705c Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 17:34:03 +0800 Subject: [PATCH 10/15] fix flake8 Signed-off-by: KumoLiu --- tests/test_remove_small_objects.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_remove_small_objects.py b/tests/test_remove_small_objects.py index 887189abe8..c4d4c62d80 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 @@ -32,8 +33,8 @@ 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 = np.eye(4) -affine[0, 0] = 2 +affine = torch.eye(4, dtype=torch.float64) +affine[0, 0] = 2.0 TEST_INPUT3 = MetaTensor(TEST_INPUT2, affine=affine) From 285548b619138bd967a08b8cd1e9cb1d781aaa3c Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 18 Oct 2023 20:27:19 +0800 Subject: [PATCH 11/15] Update monai/transforms/post/array.py Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/transforms/post/array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 91ff603d06..166c789fb3 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -361,7 +361,7 @@ class RemoveSmallObjects(Transform): Data should be one-hotted. Args: - min_size: objects smaller than this size (in pixel or in physical scale if physical_scale is True) are removed. + min_size: objects smaller than this size (in number of voxels; or volume in mm^3 if in_mm3 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 From 0793134ded27efef86187d71c96239665c451270 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 18 Oct 2023 20:31:41 +0800 Subject: [PATCH 12/15] Update monai/transforms/utils.py Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Signed-off-by: YunLiu <55491388+KumoLiu@users.noreply.github.com> --- monai/transforms/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index b727f23093..eaaeb26b43 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1112,6 +1112,8 @@ def remove_small_objects( warnings.warn("`img` is not of type MetaTensor and `pixdim` is None, assuming affine to be identity.") _pixdim = (1.0,) * sr min_size = int(np.prod(np.array(_pixdim)) * min_size) + elif pixdim is not None: + warnings.warn(`pixdim is specified by not in use when computing the volume.`) img_np: np.ndarray img_np, *_ = convert_data_type(img, np.ndarray) From 9aaf3770da6d85d2d0cc01ee03731870b744ecd4 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 20:43:13 +0800 Subject: [PATCH 13/15] rename to in_mm3 Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 16 ++++++++-------- monai/transforms/post/dictionary.py | 12 ++++++------ monai/transforms/utils.py | 12 ++++++------ tests/test_remove_small_objects.py | 8 +++----- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 166c789fb3..bd5a0f83a3 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -369,9 +369,9 @@ 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. - physical_scale: Whether or not to consider min_size at physical scale, default is false. - If true, pixdim will be used to multiply min_size. e.g. if min_size is 3 and physical_scale - is True, objects smaller than 3mm^3 are removed. + in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. + If true, min-size will be divided by pixdim. e.g. if min_size is 3 and in_mm3 + is true, 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. @@ -390,10 +390,10 @@ class RemoveSmallObjects(Transform): data2 = MetaTensor(data1, affine=affine) # remove objects smaller than 3mm^3, input is MetaTensor - trans = RemoveSmallObjects(min_size=3, physical_scale=True) + trans = RemoveSmallObjects(min_size=3, in_mm3=True) out = trans(data2) # remove objects smaller than 3mm^3, input is not MetaTensor - trans = RemoveSmallObjects(min_size=3, physical_scale=True, pixdim=(2, 1, 1)) + trans = RemoveSmallObjects(min_size=3, in_mm3=True, pixdim=(2, 1, 1)) out = trans(data1) # remove objects smaller than 3 (in pixel) @@ -415,13 +415,13 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - physical_scale: bool = False, + in_mm3: 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.physical_scale = physical_scale + self.in_mm3 = in_mm3 self.pixdim = pixdim def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: @@ -435,7 +435,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ return remove_small_objects( - img, self.min_size, self.connectivity, self.independent_channels, self.physical_scale, self.pixdim + img, self.min_size, self.connectivity, self.independent_channels, self.in_mm3, self.pixdim ) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 0cf903d882..a6d4cb01f6 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -270,7 +270,7 @@ 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 volume in mm^3 if in_mm3 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,9 +278,9 @@ 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. - physical_scale: Whether or not to consider min_size at physical scale, default is false. - If true, pixdim will be used to multiply min_size. e.g. if min_size is 3 and physical_scale - is True, objects smaller than 3mm^3 are removed. + in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. + If true, min-size will be divided by pixdim. e.g. if min_size is 3 and in_mm3 + is true, 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. """ @@ -293,12 +293,12 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - physical_scale: bool = False, + in_mm3: 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, physical_scale, pixdim) + self.converter = RemoveSmallObjects(min_size, connectivity, independent_channels, in_mm3, 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 eaaeb26b43..d342df1cf8 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1072,7 +1072,7 @@ def remove_small_objects( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - physical_scale: bool = False, + in_mm3: bool = False, pixdim: Sequence[float] | float | np.ndarray | None = None, ) -> NdarrayTensor: """ @@ -1090,8 +1090,8 @@ 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. - physical_scale: Whether or not to consider min_size at physical scale, default is false. If true, - min_size will be multiplied by pixdim. + in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. + If true, min-size will be divided by pixdim. 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. """ @@ -1102,7 +1102,7 @@ def remove_small_objects( if not has_morphology: raise RuntimeError("Skimage required.") - if physical_scale: + if in_mm3: sr = len(img.shape[1:]) if isinstance(img, monai.data.MetaTensor): _pixdim = img.pixdim @@ -1111,9 +1111,9 @@ def remove_small_objects( else: warnings.warn("`img` is not of type MetaTensor and `pixdim` is None, assuming affine to be identity.") _pixdim = (1.0,) * sr - min_size = int(np.prod(np.array(_pixdim)) * min_size) + min_size = int(min_size / np.prod(np.array(_pixdim))) elif pixdim is not None: - warnings.warn(`pixdim is specified by not in use when computing the volume.`) + 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 c4d4c62d80..683c2d91a4 100644 --- a/tests/test_remove_small_objects.py +++ b/tests/test_remove_small_objects.py @@ -50,10 +50,8 @@ TESTS_PHYSICAL: list[tuple] = [] for dtype in (int, float): - TESTS_PHYSICAL.append( - (dtype, np.array, TEST_INPUT2, None, {"min_size": 3, "physical_scale": True, "pixdim": (2, 1)}) - ) - TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "physical_scale": True})) + TESTS_PHYSICAL.append((dtype, np.array, TEST_INPUT2, None, {"min_size": 3, "in_mm3": True, "pixdim": (2, 1)})) + TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "in_mm3": True})) @SkipIfNoModule("skimage.morphology") @@ -74,7 +72,7 @@ def test_remove_small_objects(self, dtype, im_type, lbl, expected, params=None): @parameterized.expand(TESTS_PHYSICAL) def test_remove_small_objects_physical(self, dtype, im_type, lbl, expected, params): params = params or {} - min_size = params["min_size"] * 2 + min_size = params["min_size"] / 2 if expected is None: dtype = bool if lbl.max() <= 1 else int From fae65b96c739962cdff7fce2e4ef1ac831470e78 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 21:26:02 +0800 Subject: [PATCH 14/15] divide zero Signed-off-by: KumoLiu --- monai/transforms/utils.py | 6 +++++- tests/test_remove_small_objects.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index d342df1cf8..a601a726e0 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1111,7 +1111,11 @@ def remove_small_objects( else: warnings.warn("`img` is not of type MetaTensor and `pixdim` is None, assuming affine to be identity.") _pixdim = (1.0,) * sr - min_size = int(min_size / np.prod(np.array(_pixdim))) + 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.") diff --git a/tests/test_remove_small_objects.py b/tests/test_remove_small_objects.py index 683c2d91a4..115d3d59f7 100644 --- a/tests/test_remove_small_objects.py +++ b/tests/test_remove_small_objects.py @@ -72,7 +72,7 @@ def test_remove_small_objects(self, dtype, im_type, lbl, expected, params=None): @parameterized.expand(TESTS_PHYSICAL) def test_remove_small_objects_physical(self, dtype, im_type, lbl, expected, params): params = params or {} - min_size = params["min_size"] / 2 + min_size = np.ceil(params["min_size"] / 2) if expected is None: dtype = bool if lbl.max() <= 1 else int From 3df40d1ccc28d32f046028eff20de24b7890fc46 Mon Sep 17 00:00:00 2001 From: KumoLiu Date: Wed, 18 Oct 2023 22:37:17 +0800 Subject: [PATCH 15/15] rename to `by_measure` Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 20 +++++++++++--------- monai/transforms/post/dictionary.py | 14 ++++++++------ monai/transforms/utils.py | 9 +++++---- tests/test_remove_small_objects.py | 4 ++-- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index bd5a0f83a3..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 number of voxels; or volume in mm^3 if in_mm3 is True) 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,9 +370,10 @@ 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. - in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. - If true, min-size will be divided by pixdim. e.g. if min_size is 3 and in_mm3 - is true, objects smaller than 3mm^3 are removed. + 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. @@ -390,10 +392,10 @@ class RemoveSmallObjects(Transform): data2 = MetaTensor(data1, affine=affine) # remove objects smaller than 3mm^3, input is MetaTensor - trans = RemoveSmallObjects(min_size=3, in_mm3=True) + 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, in_mm3=True, pixdim=(2, 1, 1)) + trans = RemoveSmallObjects(min_size=3, by_measure=True, pixdim=(2, 1, 1)) out = trans(data1) # remove objects smaller than 3 (in pixel) @@ -415,13 +417,13 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - in_mm3: bool = False, + 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.in_mm3 = in_mm3 + self.by_measure = by_measure self.pixdim = pixdim def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: @@ -435,7 +437,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ return remove_small_objects( - img, self.min_size, self.connectivity, self.independent_channels, self.in_mm3, self.pixdim + img, self.min_size, self.connectivity, self.independent_channels, self.by_measure, self.pixdim ) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index a6d4cb01f6..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 (in number of voxels; or volume in mm^3 if in_mm3 is True) 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,9 +279,10 @@ 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. - in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. - If true, min-size will be divided by pixdim. e.g. if min_size is 3 and in_mm3 - is true, objects smaller than 3mm^3 are removed. + 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. """ @@ -293,12 +295,12 @@ def __init__( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - in_mm3: bool = False, + 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, in_mm3, pixdim) + 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 a601a726e0..678219991f 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -1072,7 +1072,7 @@ def remove_small_objects( min_size: int = 64, connectivity: int = 1, independent_channels: bool = True, - in_mm3: bool = False, + by_measure: bool = False, pixdim: Sequence[float] | float | np.ndarray | None = None, ) -> NdarrayTensor: """ @@ -1090,8 +1090,9 @@ 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. - in_mm3: Whether the specified min_size is in number of voxels or volume in mm^3, default is false. - If true, min-size will be divided by pixdim. + 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. """ @@ -1102,7 +1103,7 @@ def remove_small_objects( if not has_morphology: raise RuntimeError("Skimage required.") - if in_mm3: + if by_measure: sr = len(img.shape[1:]) if isinstance(img, monai.data.MetaTensor): _pixdim = img.pixdim diff --git a/tests/test_remove_small_objects.py b/tests/test_remove_small_objects.py index 115d3d59f7..4f2c9e9a7d 100644 --- a/tests/test_remove_small_objects.py +++ b/tests/test_remove_small_objects.py @@ -50,8 +50,8 @@ TESTS_PHYSICAL: list[tuple] = [] for dtype in (int, float): - TESTS_PHYSICAL.append((dtype, np.array, TEST_INPUT2, None, {"min_size": 3, "in_mm3": True, "pixdim": (2, 1)})) - TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "in_mm3": True})) + 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")