Repository: Project-MONAI/MONAI

Issue:
Save segmentation results in the same folder
**Is your feature request related to a problem? Please describe.**
Feature requested in discussion: https://github.com/Project-MONAI/MONAI/discussions/2069#discussioncomment-978827.
@jnc13:
Since it is a related question to this discussion, is there a way to avoid having a new directory being created for each segmentation being saved? Ideally I would have the segmentation files just saved to the output directory rather than in separate folders:

/output/S01_seg.nii.gz
/output/S05_seg.nii.gz
etc

Thanks



Discussion:

Patch:
diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py
--- a/monai/data/nifti_saver.py
+++ b/monai/data/nifti_saver.py
@@ -47,6 +47,7 @@ def __init__(
         output_dtype: DtypeLike = np.float32,
         squeeze_end_dims: bool = True,
         data_root_dir: str = "",
+        separate_folder: bool = True,
         print_log: bool = True,
     ) -> None:
         """
@@ -82,6 +83,9 @@ def __init__(
                 output_dir: /output,
                 data_root_dir: /foo/bar,
                 output will be: /output/test1/image/image_seg.nii.gz
+            separate_folder: whether to save every file in a separate folder, for example: if input filename is
+                `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+                `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`.
             print_log: whether to print log about the saved NIfTI file path, etc. default to `True`.
 
         """
@@ -97,6 +101,7 @@ def __init__(
         self._data_index = 0
         self.squeeze_end_dims = squeeze_end_dims
         self.data_root_dir = data_root_dir
+        self.separate_folder = separate_folder
         self.print_log = print_log
 
     def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None:
@@ -133,7 +138,14 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict]
         if isinstance(data, torch.Tensor):
             data = data.detach().cpu().numpy()
 
-        path = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir, patch_index)
+        path = create_file_basename(
+            postfix=self.output_postfix,
+            input_file_name=filename,
+            folder_path=self.output_dir,
+            data_root_dir=self.data_root_dir,
+            separate_folder=self.separate_folder,
+            patch_index=patch_index,
+        )
         path = f"{path}{self.output_ext}"
         # change data shape to be (channel, h, w, d)
         while len(data.shape) < 4:
diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py
--- a/monai/data/png_saver.py
+++ b/monai/data/png_saver.py
@@ -40,6 +40,7 @@ def __init__(
         mode: Union[InterpolateMode, str] = InterpolateMode.NEAREST,
         scale: Optional[int] = None,
         data_root_dir: str = "",
+        separate_folder: bool = True,
         print_log: bool = True,
     ) -> None:
         """
@@ -63,6 +64,9 @@ def __init__(
                 output_dir: /output,
                 data_root_dir: /foo/bar,
                 output will be: /output/test1/image/image_seg.png
+            separate_folder: whether to save every file in a separate folder, for example: if input filename is
+                `image.png`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+                `output/image/image_seg.png`, if `False`, save as `output/image_seg.nii`. default to `True`.
             print_log: whether to print log about the saved PNG file path, etc. default to `True`.
 
         """
@@ -73,6 +77,7 @@ def __init__(
         self.mode: InterpolateMode = InterpolateMode(mode)
         self.scale = scale
         self.data_root_dir = data_root_dir
+        self.separate_folder = separate_folder
         self.print_log = print_log
 
         self._data_index = 0
@@ -110,7 +115,14 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict]
         if isinstance(data, torch.Tensor):
             data = data.detach().cpu().numpy()
 
-        path = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir, patch_index)
+        path = create_file_basename(
+            postfix=self.output_postfix,
+            input_file_name=filename,
+            folder_path=self.output_dir,
+            data_root_dir=self.data_root_dir,
+            separate_folder=self.separate_folder,
+            patch_index=patch_index,
+        )
         path = f"{path}{self.output_ext}"
 
         if data.shape[0] == 1:
diff --git a/monai/data/utils.py b/monai/data/utils.py
--- a/monai/data/utils.py
+++ b/monai/data/utils.py
@@ -674,6 +674,7 @@ def create_file_basename(
     input_file_name: str,
     folder_path: str,
     data_root_dir: str = "",
+    separate_folder: bool = True,
     patch_index: Optional[int] = None,
 ) -> str:
     """
@@ -698,6 +699,9 @@ def create_file_basename(
             absolute path. This is used to compute `input_file_rel_path`, the relative path to the file from
             `data_root_dir` to preserve folder structure when saving in case there are files in different
             folders with the same file names.
+        separate_folder: whether to save every file in a separate folder, for example: if input filename is
+            `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+            `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`.
         patch_index: if not None, append the patch index to filename.
     """
 
@@ -712,16 +716,16 @@ def create_file_basename(
     if data_root_dir and filedir:
         filedir_rel_path = os.path.relpath(filedir, data_root_dir)
 
-    # sub-folder path will be original name without the extension
-    subfolder_path = os.path.join(folder_path, filedir_rel_path, filename)
-    if not os.path.exists(subfolder_path):
-        os.makedirs(subfolder_path)
+    # output folder path will be original name without the extension
+    output = os.path.join(folder_path, filedir_rel_path)
 
-    if len(postfix) > 0:
-        # add the sub-folder plus the postfix name to become the file basename in the output path
-        output = os.path.join(subfolder_path, filename + "_" + postfix)
-    else:
-        output = os.path.join(subfolder_path, filename)
+    if separate_folder:
+        output = os.path.join(output, filename)
+    # create target folder if no existing
+    os.makedirs(output, exist_ok=True)
+
+    # add the sub-folder plus the postfix name to become the file basename in the output path
+    output = os.path.join(output, (filename + "_" + postfix) if len(postfix) > 0 else filename)
 
     if patch_index is not None:
         output += f"_{patch_index}"
diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py
--- a/monai/handlers/segmentation_saver.py
+++ b/monai/handlers/segmentation_saver.py
@@ -52,6 +52,7 @@ def __init__(
         output_dtype: DtypeLike = np.float32,
         squeeze_end_dims: bool = True,
         data_root_dir: str = "",
+        separate_folder: bool = True,
         batch_transform: Callable = lambda x: x,
         output_transform: Callable = lambda x: x,
         name: Optional[str] = None,
@@ -103,6 +104,9 @@ def __init__(
                 output_dir: /output,
                 data_root_dir: /foo/bar,
                 output will be: /output/test1/image/image_seg.nii.gz
+            separate_folder: whether to save every file in a separate folder, for example: if input filename is
+                `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+                `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`.
             batch_transform: a callable that is used to extract the `meta_data` dictionary of the input images
                 from `ignite.engine.state.batch`. the purpose is to extract necessary information from the meta data:
                 filename, affine, original_shape, etc.
@@ -124,6 +128,7 @@ def __init__(
             output_dtype=output_dtype,
             squeeze_end_dims=squeeze_end_dims,
             data_root_dir=data_root_dir,
+            separate_folder=separate_folder,
         )
         self.batch_transform = batch_transform
         self.output_transform = output_transform
diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py
--- a/monai/transforms/io/array.py
+++ b/monai/transforms/io/array.py
@@ -233,6 +233,9 @@ class SaveImage(Transform):
             output_dir: /output,
             data_root_dir: /foo/bar,
             output will be: /output/test1/image/image_seg.nii.gz
+        separate_folder: whether to save every file in a separate folder, for example: if input filename is
+            `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+            `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`.
         print_log: whether to print log about the saved file path, etc. default to `True`.
 
     """
@@ -250,6 +253,7 @@ def __init__(
         output_dtype: DtypeLike = np.float32,
         squeeze_end_dims: bool = True,
         data_root_dir: str = "",
+        separate_folder: bool = True,
         print_log: bool = True,
     ) -> None:
         self.saver: Union[NiftiSaver, PNGSaver]
@@ -265,6 +269,7 @@ def __init__(
                 output_dtype=output_dtype,
                 squeeze_end_dims=squeeze_end_dims,
                 data_root_dir=data_root_dir,
+                separate_folder=separate_folder,
                 print_log=print_log,
             )
         elif output_ext == ".png":
@@ -276,6 +281,7 @@ def __init__(
                 mode=InterpolateMode(mode),
                 scale=scale,
                 data_root_dir=data_root_dir,
+                separate_folder=separate_folder,
                 print_log=print_log,
             )
         else:
diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py
--- a/monai/transforms/io/dictionary.py
+++ b/monai/transforms/io/dictionary.py
@@ -197,6 +197,9 @@ class SaveImaged(MapTransform):
             output_dir: /output,
             data_root_dir: /foo/bar,
             output will be: /output/test1/image/image_seg.nii.gz
+        separate_folder: whether to save every file in a separate folder, for example: if input filename is
+            `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as:
+            `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`.
         print_log: whether to print log about the saved file path, etc. default to `True`.
 
     """
@@ -218,6 +221,7 @@ def __init__(
         allow_missing_keys: bool = False,
         squeeze_end_dims: bool = True,
         data_root_dir: str = "",
+        separate_folder: bool = True,
         print_log: bool = True,
     ) -> None:
         super().__init__(keys, allow_missing_keys)
@@ -235,6 +239,7 @@ def __init__(
             output_dtype=output_dtype,
             squeeze_end_dims=squeeze_end_dims,
             data_root_dir=data_root_dir,
+            separate_folder=separate_folder,
             print_log=print_log,
         )
 


Test patch:
diff --git a/tests/test_file_basename.py b/tests/test_file_basename.py
--- a/tests/test_file_basename.py
+++ b/tests/test_file_basename.py
@@ -57,15 +57,15 @@ def test_value(self):
             expected = os.path.join(output_tmp, "test", "test")
             self.assertEqual(result, expected)
 
-            result = create_file_basename("", "test.txt", output_tmp, "foo", 5)
-            expected = os.path.join(output_tmp, "test", "test_5")
+            result = create_file_basename("", "test.txt", output_tmp, "foo", False, 5)
+            expected = os.path.join(output_tmp, "test_5")
             self.assertEqual(result, expected)
 
             result = create_file_basename("post", "test.tar.gz", output_tmp, "foo")
             expected = os.path.join(output_tmp, "test", "test_post")
             self.assertEqual(result, expected)
 
-            result = create_file_basename("post", "test.tar.gz", output_tmp, "foo", 8)
+            result = create_file_basename("post", "test.tar.gz", output_tmp, "foo", True, 8)
             expected = os.path.join(output_tmp, "test", "test_post_8")
             self.assertEqual(result, expected)
 
diff --git a/tests/test_save_image.py b/tests/test_save_image.py
--- a/tests/test_save_image.py
+++ b/tests/test_save_image.py
@@ -41,14 +41,13 @@ def test_saved_content(self, test_data, meta_data, output_ext, resample):
                 output_dir=tempdir,
                 output_ext=output_ext,
                 resample=resample,
+                # test saving into the same folder
+                separate_folder=False,
             )
             trans(test_data, meta_data)
 
-            if meta_data is not None:
-                filepath = os.path.join("testfile0", "testfile0" + "_trans" + output_ext)
-            else:
-                filepath = os.path.join("0", "0" + "_trans" + output_ext)
-            self.assertTrue(os.path.exists(os.path.join(tempdir, filepath)))
+            filepath = "testfile0" if meta_data is not None else "0"
+            self.assertTrue(os.path.exists(os.path.join(tempdir, filepath + "_trans" + output_ext)))
 
 
 if __name__ == "__main__":