Repository: Project-MONAI/MONAI

Issue:
Bugs in `create_test_image_2d` and `create_test_image_3d`
**Describe the bug**

1) There is a limitation in the current implementation:
The following line:

```
rad = rs.randint(5, rad_max)
```
requires `rad_max` be larger than 5.

I think we should not use the magic number `5` here, and there is no docstring about this limitation.

2) The functions should also ensure that `rad_max` is less than the input size.

**To Reproduce**
```
from monai.data import create_test_image_3d
im, seg = create_test_image_3d(20, 20, 20)
```
or
```
im, seg = create_test_image_3d(128, 128, 128, rad_max=5)
```

3) There is no unittest for these two functions.


Discussion:
I will enhance these functions later to resolve the bugs.

Patch:
diff --git a/monai/data/synthetic.py b/monai/data/synthetic.py
--- a/monai/data/synthetic.py
+++ b/monai/data/synthetic.py
@@ -23,23 +23,25 @@ def create_test_image_2d(
     height: int,
     num_objs: int = 12,
     rad_max: int = 30,
+    rad_min: int = 5,
     noise_max: float = 0.0,
     num_seg_classes: int = 5,
     channel_dim: Optional[int] = None,
     random_state: Optional[np.random.RandomState] = None,
 ) -> Tuple[np.ndarray, np.ndarray]:
     """
-    Return a noisy 2D image with `num_objs` circles and a 2D mask image. The maximum radius of the circles is given as
-    `rad_max`. The mask will have `num_seg_classes` number of classes for segmentations labeled sequentially from 1, plus a
-    background class represented as 0. If `noise_max` is greater than 0 then noise will be added to the image taken from
-    the uniform distribution on range `[0,noise_max)`. If `channel_dim` is None, will create an image without channel
-    dimension, otherwise create an image with channel dimension as first dim or last dim.
+    Return a noisy 2D image with `num_objs` circles and a 2D mask image. The maximum and minimum radii of the circles
+    are given as `rad_max` and `rad_min`. The mask will have `num_seg_classes` number of classes for segmentations labeled
+    sequentially from 1, plus a background class represented as 0. If `noise_max` is greater than 0 then noise will be
+    added to the image taken from the uniform distribution on range `[0,noise_max)`. If `channel_dim` is None, will create
+    an image without channel dimension, otherwise create an image with channel dimension as first dim or last dim.
 
     Args:
-        width: width of the image.
-        height: height of the image.
+        width: width of the image. The value should be larger than `2 * rad_max`.
+        height: height of the image. The value should be larger than `2 * rad_max`.
         num_objs: number of circles to generate. Defaults to `12`.
         rad_max: maximum circle radius. Defaults to `30`.
+        rad_min: minimum circle radius. Defaults to `5`.
         noise_max: if greater than 0 then noise will be added to the image taken from
             the uniform distribution on range `[0,noise_max)`. Defaults to `0`.
         num_seg_classes: number of classes for segmentations. Defaults to `5`.
@@ -47,13 +49,22 @@ def create_test_image_2d(
             an image with channel dimension as first dim or last dim. Defaults to `None`.
         random_state: the random generator to use. Defaults to `np.random`.
     """
+
+    if rad_max <= rad_min:
+        raise ValueError("`rad_min` should be less than `rad_max`.")
+    if rad_min < 1:
+        raise ValueError("`rad_min` should be no less than 1.")
+    min_size = min(width, height)
+    if min_size <= 2 * rad_max:
+        raise ValueError("the minimal size of the image should be larger than `2 * rad_max`.")
+
     image = np.zeros((width, height))
     rs: np.random.RandomState = np.random.random.__self__ if random_state is None else random_state  # type: ignore
 
     for _ in range(num_objs):
         x = rs.randint(rad_max, width - rad_max)
         y = rs.randint(rad_max, height - rad_max)
-        rad = rs.randint(5, rad_max)
+        rad = rs.randint(rad_min, rad_max)
         spy, spx = np.ogrid[-x : width - x, -y : height - y]
         circle = (spx * spx + spy * spy) <= rad * rad
 
@@ -86,6 +97,7 @@ def create_test_image_3d(
     depth: int,
     num_objs: int = 12,
     rad_max: int = 30,
+    rad_min: int = 5,
     noise_max: float = 0.0,
     num_seg_classes: int = 5,
     channel_dim: Optional[int] = None,
@@ -95,11 +107,12 @@ def create_test_image_3d(
     Return a noisy 3D image and segmentation.
 
     Args:
-        height: height of the image.
-        width: width of the image.
-        depth: depth of the image.
+        height: height of the image. The value should be larger than `2 * rad_max`.
+        width: width of the image. The value should be larger than `2 * rad_max`.
+        depth: depth of the image. The value should be larger than `2 * rad_max`.
         num_objs: number of circles to generate. Defaults to `12`.
         rad_max: maximum circle radius. Defaults to `30`.
+        rad_min: minimum circle radius. Defaults to `5`.
         noise_max: if greater than 0 then noise will be added to the image taken from
             the uniform distribution on range `[0,noise_max)`. Defaults to `0`.
         num_seg_classes: number of classes for segmentations. Defaults to `5`.
@@ -110,6 +123,15 @@ def create_test_image_3d(
     See also:
         :py:meth:`~create_test_image_2d`
     """
+
+    if rad_max <= rad_min:
+        raise ValueError("`rad_min` should be less than `rad_max`.")
+    if rad_min < 1:
+        raise ValueError("`rad_min` should be no less than 1.")
+    min_size = min(width, height, depth)
+    if min_size <= 2 * rad_max:
+        raise ValueError("the minimal size of the image should be larger than `2 * rad_max`.")
+
     image = np.zeros((width, height, depth))
     rs: np.random.RandomState = np.random.random.__self__ if random_state is None else random_state  # type: ignore
 
@@ -117,7 +139,7 @@ def create_test_image_3d(
         x = rs.randint(rad_max, width - rad_max)
         y = rs.randint(rad_max, height - rad_max)
         z = rs.randint(rad_max, depth - rad_max)
-        rad = rs.randint(5, rad_max)
+        rad = rs.randint(rad_min, rad_max)
         spy, spx, spz = np.ogrid[-x : width - x, -y : height - y, -z : depth - z]
         circle = (spx * spx + spy * spy + spz * spz) <= rad * rad
 


Test patch:
diff --git a/tests/test_gibbs_noise.py b/tests/test_gibbs_noise.py
--- a/tests/test_gibbs_noise.py
+++ b/tests/test_gibbs_noise.py
@@ -38,7 +38,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        im = create_test_image(*im_shape, 4, 20, 0, 5)[0][None]
+        im = create_test_image(*im_shape, num_objs=4, rad_max=20, noise_max=0.0, num_seg_classes=5)[0][None]
         return torch.Tensor(im) if as_tensor_input else im
 
     @parameterized.expand(TEST_CASES)
diff --git a/tests/test_gibbs_noised.py b/tests/test_gibbs_noised.py
--- a/tests/test_gibbs_noised.py
+++ b/tests/test_gibbs_noised.py
@@ -40,7 +40,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        ims = create_test_image(*im_shape, 4, 20, 0, 5)
+        ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)
         ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims
         return {k: v for k, v in zip(KEYS, ims)}
 
diff --git a/tests/test_k_space_spike_noise.py b/tests/test_k_space_spike_noise.py
--- a/tests/test_k_space_spike_noise.py
+++ b/tests/test_k_space_spike_noise.py
@@ -39,7 +39,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        im = create_test_image(*im_shape, 4, 20, 0, 5)[0][None]
+        im = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)[0][None]
         return torch.Tensor(im) if as_tensor_input else im
 
     @parameterized.expand(TEST_CASES)
diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py
--- a/tests/test_k_space_spike_noised.py
+++ b/tests/test_k_space_spike_noised.py
@@ -41,7 +41,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        ims = create_test_image(*im_shape, 4, 20, 0, 5)
+        ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)
         ims = [im[None] for im in ims]
         ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims
         return {k: v for k, v in zip(KEYS, ims)}
diff --git a/tests/test_rand_gibbs_noise.py b/tests/test_rand_gibbs_noise.py
--- a/tests/test_rand_gibbs_noise.py
+++ b/tests/test_rand_gibbs_noise.py
@@ -38,7 +38,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        im = create_test_image(*im_shape, 4, 20, 0, 5)[0][None]
+        im = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)[0][None]
         return torch.Tensor(im) if as_tensor_input else im
 
     @parameterized.expand(TEST_CASES)
diff --git a/tests/test_rand_gibbs_noised.py b/tests/test_rand_gibbs_noised.py
--- a/tests/test_rand_gibbs_noised.py
+++ b/tests/test_rand_gibbs_noised.py
@@ -40,7 +40,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        ims = create_test_image(*im_shape, 4, 20, 0, 5)
+        ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)
         ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims
         return {k: v for k, v in zip(KEYS, ims)}
 
diff --git a/tests/test_rand_k_space_spike_noise.py b/tests/test_rand_k_space_spike_noise.py
--- a/tests/test_rand_k_space_spike_noise.py
+++ b/tests/test_rand_k_space_spike_noise.py
@@ -39,7 +39,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        im = create_test_image(*im_shape, 4, 20, 0, 5)[0][None]
+        im = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)[0][None]
         return torch.Tensor(im) if as_tensor_input else im
 
     @parameterized.expand(TEST_CASES)
diff --git a/tests/test_rand_k_space_spike_noised.py b/tests/test_rand_k_space_spike_noised.py
--- a/tests/test_rand_k_space_spike_noised.py
+++ b/tests/test_rand_k_space_spike_noised.py
@@ -40,7 +40,7 @@ def tearDown(self):
     @staticmethod
     def get_data(im_shape, as_tensor_input):
         create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d
-        ims = create_test_image(*im_shape, 4, 20, 0, 5)
+        ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5)
         ims = [im[None] for im in ims]
         ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims
         return {k: v for k, v in zip(KEYS, ims)}
diff --git a/tests/test_synthetic.py b/tests/test_synthetic.py
new file mode 100644
--- /dev/null
+++ b/tests/test_synthetic.py
@@ -0,0 +1,91 @@
+# Copyright 2020 - 2021 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.data import create_test_image_2d, create_test_image_3d
+from monai.utils import set_determinism
+
+TEST_CASES = [
+    [
+        2,
+        {
+            "width": 64,
+            "height": 64,
+            "rad_max": 10,
+            "rad_min": 4,
+        },
+        0.1479004,
+        0.739502,
+        (64, 64),
+        5,
+    ],
+    [
+        2,
+        {
+            "width": 32,
+            "height": 28,
+            "num_objs": 3,
+            "rad_max": 5,
+            "rad_min": 1,
+            "noise_max": 0.2,
+        },
+        0.1709315,
+        0.4040179,
+        (32, 28),
+        5,
+    ],
+    [
+        3,
+        {
+            "width": 64,
+            "height": 64,
+            "depth": 45,
+            "num_seg_classes": 3,
+            "channel_dim": -1,
+            "rad_max": 10,
+            "rad_min": 4,
+        },
+        0.025132,
+        0.0753961,
+        (64, 64, 45, 1),
+        3,
+    ],
+]
+
+
+class TestDiceCELoss(unittest.TestCase):
+    @parameterized.expand(TEST_CASES)
+    def test_create_test_image(self, dim, input_param, expected_img, expected_seg, expected_shape, expected_max_cls):
+        set_determinism(seed=0)
+        if dim == 2:
+            img, seg = create_test_image_2d(**input_param)
+        elif dim == 3:
+            img, seg = create_test_image_3d(**input_param)
+        self.assertEqual(img.shape, expected_shape)
+        self.assertEqual(seg.max(), expected_max_cls)
+        np.testing.assert_allclose(img.mean(), expected_img, atol=1e-7, rtol=1e-7)
+        np.testing.assert_allclose(seg.mean(), expected_seg, atol=1e-7, rtol=1e-7)
+
+    def test_ill_radius(self):
+        with self.assertRaisesRegex(ValueError, ""):
+            img, seg = create_test_image_2d(32, 32, rad_max=20)
+        with self.assertRaisesRegex(ValueError, ""):
+            img, seg = create_test_image_3d(32, 32, 32, rad_max=10, rad_min=11)
+        with self.assertRaisesRegex(ValueError, ""):
+            img, seg = create_test_image_2d(32, 32, rad_max=10, rad_min=0)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/utils.py b/tests/utils.py
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -473,7 +473,9 @@ class NumpyImageTestCase2D(unittest.TestCase):
     num_classes = 3
 
     def setUp(self):
-        im, msk = create_test_image_2d(self.im_shape[0], self.im_shape[1], 4, 20, 0, self.num_classes)
+        im, msk = create_test_image_2d(
+            self.im_shape[0], self.im_shape[1], num_objs=4, rad_max=20, noise_max=0.0, num_seg_classes=self.num_classes
+        )
 
         self.imt = im[None, None]
         self.seg1 = (msk[None, None] > 0).astype(np.float32)
@@ -495,7 +497,15 @@ class NumpyImageTestCase3D(unittest.TestCase):
     num_classes = 3
 
     def setUp(self):
-        im, msk = create_test_image_3d(self.im_shape[0], self.im_shape[1], self.im_shape[2], 4, 20, 0, self.num_classes)
+        im, msk = create_test_image_3d(
+            self.im_shape[0],
+            self.im_shape[1],
+            self.im_shape[2],
+            num_objs=4,
+            rad_max=20,
+            noise_max=0.0,
+            num_seg_classes=self.num_classes,
+        )
 
         self.imt = im[None, None]
         self.seg1 = (msk[None, None] > 0).astype(np.float32)