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
57 changes: 54 additions & 3 deletions monai/transforms/post/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,22 +361,70 @@ 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
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.
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:
"""
Expand All @@ -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):
Expand Down
13 changes: 11 additions & 2 deletions monai/transforms/post/dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,14 +270,21 @@ 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
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.
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
Expand All @@ -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)
Expand Down
29 changes: 28 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,
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.
Expand All @@ -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:
Expand All @@ -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)

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, "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):
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 = 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 {}
Expand Down