Skip to content

Commit

Permalink
Add in_mm3 in remove_small_objects (#7137)
Browse files Browse the repository at this point in the history
Fixes #7034.

### Description
Add `in_mm3` in `remove_small_objects`, if `in_mm3=True`, `min_size`
will be divided by pixdim when the image is MetaTensor.

### Types of changes
<!--- Put an `x` in all the boxes that apply, and remove the not
applicable items -->
- [x] Non-breaking change (fix or new feature that would not break
existing functionality).
- [ ] Breaking change (fix or new feature that would cause existing
functionality to change).
- [ ] New tests added to cover the changes.
- [ ] Integration tests passed locally by running `./runtests.sh -f -u
--net --coverage`.
- [ ] Quick tests passed locally by running `./runtests.sh --quick
--unittests --disttests`.
- [ ] In-line docstrings updated.
- [ ] Documentation updated, tested `make html` command in the `docs/`
folder.

---------

Signed-off-by: KumoLiu <[email protected]>
  • Loading branch information
KumoLiu authored Oct 18, 2023
1 parent 0a3cdd9 commit 2c9f44c
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 10 deletions.
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

0 comments on commit 2c9f44c

Please sign in to comment.