diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 239ae9eb17..f4599ce20c 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -122,3 +122,13 @@ Applications :members: .. autoclass:: TileOnGridd :members: + +`Detection` +----------- + +`Transforms` +~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.transforms.array + :members: +.. automodule:: monai.apps.detection.transforms.dictionary + :members: diff --git a/monai/apps/detection/__init__.py b/monai/apps/detection/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/monai/apps/detection/transforms/__init__.py b/monai/apps/detection/transforms/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/transforms/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py new file mode 100644 index 0000000000..c3ea959eb8 --- /dev/null +++ b/monai/apps/detection/transforms/array.py @@ -0,0 +1,128 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A collection of "vanilla" transforms for box operations +https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design +""" + +from typing import Type, Union + +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode +from monai.transforms.transform import Transform +from monai.utils.enums import TransformBackends + +__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode"] + + +class ConvertBoxMode(Transform): + """ + This transform converts the boxes in src_mode to the dst_mode. + + Example: + .. code-block:: python + + boxes = torch.ones(10,4) + # convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. + box_converter = ConvertBoxMode(src_mode="xyxy", dst_mode="ccwh") + box_converter(boxes) + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + ) -> None: + """ + + Args: + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" for 2D and "xyzxyz" for 3D. + + src_mode and dst_mode can be: + #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example, + - "xyxy": boxes has format [xmin, ymin, xmax, ymax] + - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax] + - "xxyy": boxes has format [xmin, xmax, ymin, ymax] + - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax] + - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax] + - "xywh": boxes has format [xmin, ymin, xsize, ysize] + - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize] + - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize] + - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize] + #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode: equivalent to "xywh" or "xyzwhd" + - CenterSizeMode: equivalent to "ccwh" or "cccwhd" + #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode(): equivalent to "xywh" or "xyzwhd" + - CenterSizeMode(): equivalent to "ccwh" or "cccwhd" + #. None: will assume mode is ``StandardMode()`` + """ + self.src_mode = src_mode + self.dst_mode = dst_mode + + def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Converts the boxes in src_mode to the dst_mode. + + Returns: + bounding boxes with target mode, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + return convert_box_mode(boxes, src_mode=self.src_mode, dst_mode=self.dst_mode) + + +class ConvertBoxToStandardMode(Transform): + """ + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + # convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] + box_converter = ConvertBoxToStandardMode(mode="xxyyzz") + box_converter(boxes) + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, mode: Union[str, BoxMode, Type[BoxMode], None] = None) -> None: + """ + Args: + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + """ + self.mode = mode + + def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Returns: + bounding boxes with standard mode, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + return convert_box_to_standard_mode(boxes, mode=self.mode) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py new file mode 100644 index 0000000000..a0b4c76224 --- /dev/null +++ b/monai/apps/detection/transforms/dictionary.py @@ -0,0 +1,148 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A collection of dictionary-based wrappers around the "vanilla" transforms for box operations +defined in :py:class:`monai.apps.detection.transforms.array`. + +Class names are ended with 'd' to denote dictionary-based transforms. +""" + +from copy import deepcopy +from typing import Dict, Hashable, Mapping, Type, Union + +from monai.apps.detection.transforms.array import ConvertBoxMode, ConvertBoxToStandardMode +from monai.config import KeysCollection +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.box_utils import BoxMode +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.transform import MapTransform + +__all__ = [ + "ConvertBoxModed", + "ConvertBoxModeD", + "ConvertBoxModeDict", + "ConvertBoxToStandardModed", + "ConvertBoxToStandardModeD", + "ConvertBoxToStandardModeDict", +] + + +class ConvertBoxModed(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.ConvertBoxMode`. + + This transform converts the boxes in src_mode to the dst_mode. + + Example: + .. code-block:: python + + data = {"boxes": torch.ones(10,4)} + # convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. + box_converter = ConvertBoxModed(box_keys=["boxes"], src_mode="xyxy", dst_mode="ccwh") + box_converter(data) + """ + + def __init__( + self, + box_keys: KeysCollection, + src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + box_keys: Keys to pick data for transformation. + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + allow_missing_keys: don't raise exception if key is missing. + + See also :py:class:`monai.apps.detection,transforms.array.ConvertBoxMode` + """ + super().__init__(box_keys, allow_missing_keys) + self.converter = ConvertBoxMode(src_mode=src_mode, dst_mode=dst_mode) + self.inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + d[key] = self.converter(d[key]) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Inverse is same as forward + d[key] = self.inverse_converter(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class ConvertBoxToStandardModed(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.ConvertBoxToStandardMode`. + + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Example: + .. code-block:: python + + data = {"boxes": torch.ones(10,6)} + # convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] + box_converter = ConvertBoxToStandardModed(box_keys=["boxes"], mode="xxyyzz") + box_converter(data) + """ + + def __init__( + self, + box_keys: KeysCollection, + mode: Union[str, BoxMode, Type[BoxMode], None] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + box_keys: Keys to pick data for transformation. + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + allow_missing_keys: don't raise exception if key is missing. + + See also :py:class:`monai.apps.detection,transforms.array.ConvertBoxToStandardMode` + """ + super().__init__(box_keys, allow_missing_keys) + self.converter = ConvertBoxToStandardMode(mode=mode) + self.inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=mode) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + d[key] = self.converter(d[key]) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Inverse is same as forward + d[key] = self.inverse_converter(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed +ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py new file mode 100644 index 0000000000..a679dd3c95 --- /dev/null +++ b/tests/test_box_transform.py @@ -0,0 +1,52 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.detection.transforms.dictionary import ConvertBoxToStandardModed +from monai.transforms import Invertd +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] + +boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]] +image_size = [1, 5, 6, 4] +image = np.zeros(image_size) + +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"box_keys": "boxes", "mode": "xyzwhd"}, + {"boxes": p(boxes), "image": p(image)}, + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[0, 1, 1, 2, 3, 4], [0, 1, 0, 2, 3, 3]]), + ] + ) + + +class TestBoxTransform(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, keys, data, expected_standard_result, expected_clip_result, expected_flip_result): + transform_convert_mode = ConvertBoxToStandardModed(**keys) + result = transform_convert_mode(data) + assert_allclose(result["boxes"], expected_standard_result, type_test=True, device_test=True, atol=0.0) + + invert_transform_convert_mode = Invertd(keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]) + data_back = invert_transform_convert_mode(result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.0) + + +if __name__ == "__main__": + unittest.main()