From 372916fc5a6225f677ed9f9220b31f3e1f17b717 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 9 Dec 2020 17:05:31 +0800 Subject: [PATCH 1/3] [DLMED] refine generate_spatial_bounding_box Signed-off-by: Nic Ma --- monai/transforms/croppad/array.py | 19 +++++++-- monai/transforms/croppad/dictionary.py | 9 +++-- monai/transforms/utils.py | 54 ++++++++++---------------- tests/test_bounding_rect.py | 20 +++++++--- tests/test_bounding_rectd.py | 14 ++++--- 5 files changed, 63 insertions(+), 53 deletions(-) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 2cd8ee861e..aae033b609 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -21,7 +21,6 @@ from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.compose import Randomizable, Transform from monai.transforms.utils import ( - compute_bounding_rect, generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, map_binary_to_indices, @@ -638,11 +637,23 @@ def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = N class BoundingRect(Transform): """ Compute coordinates of axis-aligned bounding rectangles from input image `img`. + + Args: + select_fn: function to select expected foreground, default is to select values > 0. """ + def __init__(self, select_fn: Callable = lambda x: x > 0) -> None: + self.select_fn = select_fn + def __call__(self, img: np.ndarray) -> np.ndarray: """ - See also: :py:class:`monai.transforms.utils.compute_bounding_rect`. + See also: :py:class:`monai.transforms.utils.generate_spatial_bounding_box`. """ - bbox = [compute_bounding_rect(channel) for channel in img] - return np.stack(bbox, axis=0) + bbox_start = list() + bbox_end = list() + + for channel in range(img.shape[0]): + start_, end_ = generate_spatial_bounding_box(img, select_fn=self.select_fn, channel_indices=channel) + bbox_start.append(start_) + bbox_end.append(end_) + return np.stack(bbox_start, axis=0), np.stack(bbox_end, axis=0) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 557e80474b..6091aa68bd 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -589,17 +589,18 @@ class BoundingRectd(MapTransform): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform bbox_key_postfix: the output bounding box coordinates will be - written to the value of `{key}_{bbox_key_postfix}`. + written to the value of `{key}_{bbox_key_postfix}`, it's a tuple of (bbox_start, bbox_end). + select_fn: function to select expected foreground, default is to select values > 0. """ - def __init__(self, keys: KeysCollection, bbox_key_postfix: str = "bbox"): + def __init__(self, keys: KeysCollection, bbox_key_postfix: str = "bbox", select_fn: Callable = lambda x: x > 0): super().__init__(keys=keys) - self.bbox = BoundingRect() + self.bbox = BoundingRect(select_fn=select_fn) self.bbox_key_postfix = bbox_key_postfix def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: """ - See also: :py:class:`monai.transforms.utils.compute_bounding_rect`. + See also: :py:class:`monai.transforms.utils.generate_spatial_bounding_box`. """ d = dict(data) for key in self.keys: diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 54d0632d9c..49cde008ed 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -516,6 +516,15 @@ def generate_spatial_bounding_box( generate the spatial bounding box of foreground in the image with start-end positions. Users can define arbitrary function to select expected foreground from the whole image or specified channels. And it can also add margin to every dim of the bounding box. + The output format of the coordinates is: + + [1st_spatial_dim_start, 1st_spatial_dim_end, + 2nd_spatial_dim_start, 2nd_spatial_dim_end, + ..., + Nth_spatial_dim_start, Nth_spatial_dim_end,] + + The bounding boxes edges are aligned with the input image edges. + This function returns [-1, -1, ...] if there's no positive intensity. Args: img: source image to generate bounding box from. @@ -526,42 +535,21 @@ def generate_spatial_bounding_box( """ data = img[[*(ensure_tuple(channel_indices))]] if channel_indices is not None else img data = np.any(select_fn(data), axis=0) - nonzero_idx = np.nonzero(data) - margin = ensure_tuple_rep(margin, data.ndim) - - box_start = list() - box_end = list() - for i in range(data.ndim): - assert len(nonzero_idx[i]) > 0, f"did not find nonzero index at spatial dim {i}" - box_start.append(max(0, np.min(nonzero_idx[i]) - margin[i])) - box_end.append(min(data.shape[i], np.max(nonzero_idx[i]) + margin[i] + 1)) - return box_start, box_end + ndim = len(data.shape) + margin = ensure_tuple_rep(margin, ndim) + box_start = [0] * ndim + box_end = [0] * ndim - -def compute_bounding_rect(image: np.array): - """ - Compute ND coordinates of a bounding rectangle from the positive intensities. - The output format of the coordinates is: - - [1st_spatial_dim_start, 1st_spatial_dim_end, - 2nd_spatial_dim_start, 2nd_spatial_dim_end, - ..., - Nth_spatial_dim_start, Nth_spatial_dim_end,] - - The bounding boxes edges are aligned with the input image edges. - This function returns [-1, -1, ...] if there's no positive intensity. - """ - _binary_image = image > 0 - ndim = len(_binary_image.shape) - bbox = [0] * (2 * ndim) for di, ax in enumerate(itertools.combinations(reversed(range(ndim)), ndim - 1)): - dt = _binary_image.any(axis=ax) + dt = data.any(axis=ax) if not np.any(dt): - return np.asarray([-1] * len(bbox)) - min_d = np.argmax(dt) - max_d = max(_binary_image.shape[di] - np.argmax(dt[::-1]), min_d + 1) - bbox[di * 2], bbox[di * 2 + 1] = min_d, max_d - return np.asarray(bbox) + return [-1] * ndim, [-1] * ndim + + min_d = max(np.argmax(dt) - margin[di], 0) + max_d = max(data.shape[di] - max(np.argmax(dt[::-1]) - margin[di], 0), min_d + 1) + box_start[di], box_end[di] = min_d, max_d + + return box_start, box_end def get_largest_connected_component_mask(img: torch.Tensor, connectivity: Optional[int] = None) -> torch.Tensor: diff --git a/tests/test_bounding_rect.py b/tests/test_bounding_rect.py index 04fea8a22c..d94ded68e2 100644 --- a/tests/test_bounding_rect.py +++ b/tests/test_bounding_rect.py @@ -17,11 +17,11 @@ import monai from monai.transforms import BoundingRect -TEST_CASE_1 = [(2, 3), [[-1, -1], [1, 2]]] +TEST_CASE_1 = [(2, 3), [[-1], [1]], [[-1], [2]]] -TEST_CASE_2 = [(1, 8, 10), [[0, 7, 1, 9]]] +TEST_CASE_2 = [(1, 8, 10), [[0, 1]], [[7, 9]]] -TEST_CASE_3 = [(2, 16, 20, 18), [[0, 16, 0, 20, 0, 18], [0, 16, 0, 20, 0, 18]]] +TEST_CASE_3 = [(2, 16, 20, 18), [[0, 0, 0], [0, 0, 0]], [[16, 20, 18], [16, 20, 18]]] class TestBoundingRect(unittest.TestCase): @@ -32,11 +32,19 @@ def tearDown(self): monai.utils.set_determinism(None) @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_shape, expected): + def test_shape(self, input_shape, expected_start, expected_end): test_data = np.random.randint(0, 8, size=input_shape) test_data = test_data == 7 - result = BoundingRect()(test_data) - np.testing.assert_allclose(result, expected) + bbox_start, bbox_end = BoundingRect()(test_data) + np.testing.assert_allclose(bbox_start, expected_start) + np.testing.assert_allclose(bbox_end, expected_end) + + def test_select_fn(self): + test_data = np.random.randint(0, 8, size=(2, 3)) + test_data = test_data == 7 + bbox_start, bbox_end = BoundingRect(select_fn=lambda x: x < 1)(test_data) + np.testing.assert_allclose(bbox_start, [[0], [0]]) + np.testing.assert_allclose(bbox_end, [[3], [3]]) if __name__ == "__main__": diff --git a/tests/test_bounding_rectd.py b/tests/test_bounding_rectd.py index c33a3c371d..b431b82230 100644 --- a/tests/test_bounding_rectd.py +++ b/tests/test_bounding_rectd.py @@ -17,11 +17,11 @@ import monai from monai.transforms import BoundingRectD -TEST_CASE_1 = [(2, 3), [[-1, -1], [1, 2]]] +TEST_CASE_1 = [(2, 3), [[-1], [1]], [[-1], [2]]] -TEST_CASE_2 = [(1, 8, 10), [[0, 7, 1, 9]]] +TEST_CASE_2 = [(1, 8, 10), [[0, 1]], [[7, 9]]] -TEST_CASE_3 = [(2, 16, 20, 18), [[0, 16, 0, 20, 0, 18], [0, 16, 0, 20, 0, 18]]] +TEST_CASE_3 = [(2, 16, 20, 18), [[0, 0, 0], [0, 0, 0]], [[16, 20, 18], [16, 20, 18]]] class TestBoundingRectD(unittest.TestCase): @@ -32,14 +32,16 @@ def tearDown(self): monai.utils.set_determinism(None) @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_shape, expected): + def test_shape(self, input_shape, expected_start, expected_end): test_data = np.random.randint(0, 8, size=input_shape) test_data = test_data == 7 result = BoundingRectD("image")({"image": test_data}) - np.testing.assert_allclose(result["image_bbox"], expected) + np.testing.assert_allclose(result["image_bbox"][0], expected_start) + np.testing.assert_allclose(result["image_bbox"][1], expected_end) result = BoundingRectD("image", "cc")({"image": test_data}) - np.testing.assert_allclose(result["image_cc"], expected) + np.testing.assert_allclose(result["image_cc"][0], expected_start) + np.testing.assert_allclose(result["image_cc"][1], expected_end) with self.assertRaises(KeyError): BoundingRectD("image", "cc")({"image": test_data, "image_cc": None}) From c984e75164bc4a5a7a780327b1b5cb50860f8bc6 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 9 Dec 2020 22:47:36 +0800 Subject: [PATCH 2/3] [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/transforms/croppad/array.py | 25 ++++++++++++++++++++----- monai/transforms/croppad/dictionary.py | 2 +- monai/transforms/utils.py | 10 ++++------ tests/test_bounding_rect.py | 18 ++++++++---------- tests/test_bounding_rectd.py | 14 ++++++-------- 5 files changed, 39 insertions(+), 30 deletions(-) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index aae033b609..4c69a61b15 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -637,6 +637,22 @@ def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = N class BoundingRect(Transform): """ Compute coordinates of axis-aligned bounding rectangles from input image `img`. + The output format of the coordinates is (shape is [channel, 2 * spatial dims]): + + [[1st_spatial_dim_start, 1st_spatial_dim_end, + 2nd_spatial_dim_start, 2nd_spatial_dim_end, + ..., + Nth_spatial_dim_start, Nth_spatial_dim_end], + + ... + + [1st_spatial_dim_start, 1st_spatial_dim_end, + 2nd_spatial_dim_start, 2nd_spatial_dim_end, + ..., + Nth_spatial_dim_start, Nth_spatial_dim_end]] + + The bounding boxes edges are aligned with the input image edges. + This function returns [-1, -1, ...] if there's no positive intensity. Args: select_fn: function to select expected foreground, default is to select values > 0. @@ -649,11 +665,10 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ See also: :py:class:`monai.transforms.utils.generate_spatial_bounding_box`. """ - bbox_start = list() - bbox_end = list() + bbox = list() for channel in range(img.shape[0]): start_, end_ = generate_spatial_bounding_box(img, select_fn=self.select_fn, channel_indices=channel) - bbox_start.append(start_) - bbox_end.append(end_) - return np.stack(bbox_start, axis=0), np.stack(bbox_end, axis=0) + bbox.append([i for k in zip(start_, end_) for i in k]) + + return np.stack(bbox, axis=0) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 6091aa68bd..8e927eb605 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -589,7 +589,7 @@ class BoundingRectd(MapTransform): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform bbox_key_postfix: the output bounding box coordinates will be - written to the value of `{key}_{bbox_key_postfix}`, it's a tuple of (bbox_start, bbox_end). + written to the value of `{key}_{bbox_key_postfix}`. select_fn: function to select expected foreground, default is to select values > 0. """ diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 49cde008ed..c4110f0901 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -518,13 +518,11 @@ def generate_spatial_bounding_box( And it can also add margin to every dim of the bounding box. The output format of the coordinates is: - [1st_spatial_dim_start, 1st_spatial_dim_end, - 2nd_spatial_dim_start, 2nd_spatial_dim_end, - ..., - Nth_spatial_dim_start, Nth_spatial_dim_end,] + [1st_spatial_dim_start, 2nd_spatial_dim_start, ..., Nth_spatial_dim_start], + [1st_spatial_dim_end, 2nd_spatial_dim_end, ..., Nth_spatial_dim_end] The bounding boxes edges are aligned with the input image edges. - This function returns [-1, -1, ...] if there's no positive intensity. + This function returns [-1, -1, ...], [-1, -1, ...] if there's no positive intensity. Args: img: source image to generate bounding box from. @@ -533,7 +531,7 @@ def generate_spatial_bounding_box( of image. if None, select foreground on the whole image. margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. """ - data = img[[*(ensure_tuple(channel_indices))]] if channel_indices is not None else img + data = img[list(ensure_tuple(channel_indices))] if channel_indices is not None else img data = np.any(select_fn(data), axis=0) ndim = len(data.shape) margin = ensure_tuple_rep(margin, ndim) diff --git a/tests/test_bounding_rect.py b/tests/test_bounding_rect.py index d94ded68e2..69476479a3 100644 --- a/tests/test_bounding_rect.py +++ b/tests/test_bounding_rect.py @@ -17,11 +17,11 @@ import monai from monai.transforms import BoundingRect -TEST_CASE_1 = [(2, 3), [[-1], [1]], [[-1], [2]]] +TEST_CASE_1 = [(2, 3), [[-1, -1], [1, 2]]] -TEST_CASE_2 = [(1, 8, 10), [[0, 1]], [[7, 9]]] +TEST_CASE_2 = [(1, 8, 10), [[0, 7, 1, 9]]] -TEST_CASE_3 = [(2, 16, 20, 18), [[0, 0, 0], [0, 0, 0]], [[16, 20, 18], [16, 20, 18]]] +TEST_CASE_3 = [(2, 16, 20, 18), [[0, 16, 0, 20, 0, 18], [0, 16, 0, 20, 0, 18]]] class TestBoundingRect(unittest.TestCase): @@ -32,19 +32,17 @@ def tearDown(self): monai.utils.set_determinism(None) @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_shape, expected_start, expected_end): + def test_shape(self, input_shape, expected): test_data = np.random.randint(0, 8, size=input_shape) test_data = test_data == 7 - bbox_start, bbox_end = BoundingRect()(test_data) - np.testing.assert_allclose(bbox_start, expected_start) - np.testing.assert_allclose(bbox_end, expected_end) + result = BoundingRect()(test_data) + np.testing.assert_allclose(result, expected) def test_select_fn(self): test_data = np.random.randint(0, 8, size=(2, 3)) test_data = test_data == 7 - bbox_start, bbox_end = BoundingRect(select_fn=lambda x: x < 1)(test_data) - np.testing.assert_allclose(bbox_start, [[0], [0]]) - np.testing.assert_allclose(bbox_end, [[3], [3]]) + bbox = BoundingRect(select_fn=lambda x: x < 1)(test_data) + np.testing.assert_allclose(bbox, [[0, 3], [0, 3]]) if __name__ == "__main__": diff --git a/tests/test_bounding_rectd.py b/tests/test_bounding_rectd.py index b431b82230..c33a3c371d 100644 --- a/tests/test_bounding_rectd.py +++ b/tests/test_bounding_rectd.py @@ -17,11 +17,11 @@ import monai from monai.transforms import BoundingRectD -TEST_CASE_1 = [(2, 3), [[-1], [1]], [[-1], [2]]] +TEST_CASE_1 = [(2, 3), [[-1, -1], [1, 2]]] -TEST_CASE_2 = [(1, 8, 10), [[0, 1]], [[7, 9]]] +TEST_CASE_2 = [(1, 8, 10), [[0, 7, 1, 9]]] -TEST_CASE_3 = [(2, 16, 20, 18), [[0, 0, 0], [0, 0, 0]], [[16, 20, 18], [16, 20, 18]]] +TEST_CASE_3 = [(2, 16, 20, 18), [[0, 16, 0, 20, 0, 18], [0, 16, 0, 20, 0, 18]]] class TestBoundingRectD(unittest.TestCase): @@ -32,16 +32,14 @@ def tearDown(self): monai.utils.set_determinism(None) @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_shape, expected_start, expected_end): + def test_shape(self, input_shape, expected): test_data = np.random.randint(0, 8, size=input_shape) test_data = test_data == 7 result = BoundingRectD("image")({"image": test_data}) - np.testing.assert_allclose(result["image_bbox"][0], expected_start) - np.testing.assert_allclose(result["image_bbox"][1], expected_end) + np.testing.assert_allclose(result["image_bbox"], expected) result = BoundingRectD("image", "cc")({"image": test_data}) - np.testing.assert_allclose(result["image_cc"][0], expected_start) - np.testing.assert_allclose(result["image_cc"][1], expected_end) + np.testing.assert_allclose(result["image_cc"], expected) with self.assertRaises(KeyError): BoundingRectD("image", "cc")({"image": test_data, "image_cc": None}) From cc215b53d4f778f5dc545db55beb53e107432830 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 9 Dec 2020 23:25:40 +0800 Subject: [PATCH 3/3] [DLMED] add check for margin value Signed-off-by: Nic Ma --- monai/transforms/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index c4110f0901..44205e4e09 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -535,6 +535,10 @@ def generate_spatial_bounding_box( data = np.any(select_fn(data), axis=0) ndim = len(data.shape) margin = ensure_tuple_rep(margin, ndim) + for m in margin: + if m < 0: + raise ValueError("margin value should not be negative number.") + box_start = [0] * ndim box_end = [0] * ndim