Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add in_mm3 in remove_small_objects #7137

Merged
merged 15 commits into from
Oct 18, 2023
6 changes: 2 additions & 4 deletions monai/metrics/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down
55 changes: 52 additions & 3 deletions monai/transforms/post/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,22 +361,68 @@ 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 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
documentation.
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.
wyli marked this conversation as resolved.
Show resolved Hide resolved
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.

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, in_mm3=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))
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,
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.in_mm3 = in_mm3
self.pixdim = pixdim

def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
"""
Expand All @@ -387,7 +433,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.in_mm3, self.pixdim
)


class LabelFilter(Transform):
Expand Down
11 changes: 9 additions & 2 deletions monai/transforms/post/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,14 +270,19 @@ 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
documentation.
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.
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
Expand All @@ -288,10 +293,12 @@ def __init__(
min_size: int = 64,
connectivity: int = 1,
independent_channels: bool = True,
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)
self.converter = RemoveSmallObjects(min_size, connectivity, independent_channels, in_mm3, pixdim)

def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]:
d = dict(data)
Expand Down
24 changes: 23 additions & 1 deletion monai/transforms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
in_mm3: bool = False,
pixdim: Sequence[float] | float | np.ndarray | None = None,
) -> NdarrayTensor:
"""
Use `skimage.morphology.remove_small_objects` to remove small objects from images.
Expand All @@ -1085,6 +1090,10 @@ 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.
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:
Expand All @@ -1093,6 +1102,19 @@ def remove_small_objects(
if not has_morphology:
raise RuntimeError("Skimage required.")

if in_mm3:
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
min_size = int(min_size / np.prod(np.array(_pixdim)))
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)

Expand Down
28 changes: 28 additions & 0 deletions tests/test_remove_small_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import unittest

import numpy as np
import torch
from parameterized import parameterized

from monai.data.meta_tensor import MetaTensor
Expand All @@ -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:
Expand All @@ -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, "in_mm3": True, "pixdim": (2, 1)}))
TESTS_PHYSICAL.append((dtype, MetaTensor, TEST_INPUT3, None, {"min_size": 3, "in_mm3": True}))


@SkipIfNoModule("skimage.morphology")
class TestRemoveSmallObjects(unittest.TestCase):
Expand All @@ -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 = 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 {}
Expand Down
Loading