Repository: Project-MONAI/MONAI

Issue:
Add support to create train / val datalists for Decathlon dataset
**Is your feature request related to a problem? Please describe.**
Currently, we already have the APIs to load Decathlon datalist or check missing files, would be nice to provide the API to split datalist into train / val or Kfolds cross validation, and save new datalist.json files.
We already have this feature in NVIDIA Clara, will reimplement in MONAI style.




Discussion:

Resolving patch:
diff --git a/monai/data/__init__.py b/monai/data/__init__.py
--- a/monai/data/__init__.py
+++ b/monai/data/__init__.py
@@ -24,7 +24,12 @@
     ZipDataset,
 )
 from .dataset_summary import DatasetSummary
-from .decathlon_datalist import check_missing_files, load_decathlon_datalist, load_decathlon_properties
+from .decathlon_datalist import (
+    check_missing_files,
+    create_cross_validation_datalist,
+    load_decathlon_datalist,
+    load_decathlon_properties,
+)
 from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter
 from .image_dataset import ImageDataset
 from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader
diff --git a/monai/data/decathlon_datalist.py b/monai/data/decathlon_datalist.py
--- a/monai/data/decathlon_datalist.py
+++ b/monai/data/decathlon_datalist.py
@@ -11,9 +11,12 @@
 
 import json
 import os
+import warnings
+from pathlib import Path
 from typing import Dict, List, Optional, Sequence, Union, overload
 
 from monai.config import KeysCollection
+from monai.data.utils import partition_dataset, select_cross_validation_folds
 from monai.utils import ensure_tuple
 
 
@@ -152,7 +155,10 @@ def load_decathlon_properties(data_property_file_path: str, property_keys: Union
 
 
 def check_missing_files(
-    datalist: List[Dict], keys: KeysCollection, root_dir: Optional[str] = None, allow_missing_keys: bool = False
+    datalist: List[Dict],
+    keys: KeysCollection,
+    root_dir: Optional[Union[Path, str]] = None,
+    allow_missing_keys: bool = False,
 ):
     """Checks whether some files in the Decathlon datalist are missing.
     It would be helpful to check missing files before a heavy training run.
@@ -178,11 +184,66 @@ def check_missing_files(
                 continue
 
             for f in ensure_tuple(item[k]):
-                if not isinstance(f, str):
+                if not isinstance(f, (str, Path)):
                     raise ValueError(f"filepath of key `{k}` must be a string or a list of strings, but got: {f}.")
-                if isinstance(root_dir, str):
+                if isinstance(root_dir, (str, Path)):
                     f = os.path.join(root_dir, f)
                 if not os.path.exists(f):
                     missing_files.append(f)
 
     return missing_files
+
+
+def create_cross_validation_datalist(
+    datalist: List[Dict],
+    nfolds: int,
+    train_folds: Union[Sequence[int], int],
+    val_folds: Union[Sequence[int], int],
+    train_key: str = "training",
+    val_key: str = "validation",
+    filename: Optional[Union[Path, str]] = None,
+    shuffle: bool = True,
+    seed: int = 0,
+    check_missing: bool = False,
+    keys: Optional[KeysCollection] = None,
+    root_dir: Optional[str] = None,
+    allow_missing_keys: bool = False,
+    raise_error: bool = True,
+):
+    """
+    Utility to create new Decathlon style datalist based on cross validation partition.
+
+    Args:
+        datalist: loaded list of dictionaries for all the items to partition.
+        nfolds: number of the kfold split.
+        train_folds: indices of folds for training part.
+        val_folds: indices of folds for validation part.
+        train_key: the key of train part in the new datalist, defaults to "training".
+        val_key: the key of validation part in the new datalist, defaults to "validation".
+        filename: if not None and ends with ".json", save the new datalist into JSON file.
+        shuffle: whether to shuffle the datalist before partition, defaults to `True`.
+        seed: if `shuffle` is True, set the random seed, defaults to `0`.
+        check_missing: whether to check all the files specified by `keys` are existing.
+        keys: if not None and check_missing_files is True, the expected keys to check in the datalist.
+        root_dir: if not None, provides the root dir for the relative file paths in `datalist`.
+        allow_missing_keys: if check_missing_files is `True`, whether allow missing keys in the datalist items.
+            if False, raise exception if missing. default to False.
+        raise_error: when found missing files, if `True`, raise exception and stop, if `False`, print warining.
+
+    """
+    if check_missing and keys is not None:
+        files = check_missing_files(datalist, keys, root_dir, allow_missing_keys)
+        if files:
+            msg = f"some files of the datalist are missing: {files}"
+            if raise_error:
+                raise ValueError(msg)
+            warnings.warn(msg)
+
+    data = partition_dataset(data=datalist, num_partitions=nfolds, shuffle=shuffle, seed=seed)
+    train_list = select_cross_validation_folds(partitions=data, folds=train_folds)
+    val_list = select_cross_validation_folds(partitions=data, folds=val_folds)
+    ret = {train_key: train_list, val_key: val_list}
+    if isinstance(filename, (str, Path)):
+        json.dump(ret, open(filename, "w"), indent=4)
+
+    return ret


Test patch:
diff --git a/tests/test_check_missing_files.py b/tests/test_check_missing_files.py
--- a/tests/test_check_missing_files.py
+++ b/tests/test_check_missing_files.py
@@ -12,6 +12,7 @@
 import os
 import tempfile
 import unittest
+from pathlib import Path
 
 import nibabel as nib
 import numpy as np
@@ -34,14 +35,14 @@ def test_content(self):
                     "label": [os.path.join(tempdir, "test_label1.nii.gz"), os.path.join(tempdir, "test_extra1.nii.gz")],
                 },
                 {
-                    "image": os.path.join(tempdir, "test_image2.nii.gz"),
-                    "label": os.path.join(tempdir, "test_label_missing.nii.gz"),
+                    "image": Path(os.path.join(tempdir, "test_image2.nii.gz")),
+                    "label": Path(os.path.join(tempdir, "test_label_missing.nii.gz")),
                 },
             ]
 
             missings = check_missing_files(datalist=datalist, keys=["image", "label"])
             self.assertEqual(len(missings), 1)
-            self.assertEqual(missings[0], os.path.join(tempdir, "test_label_missing.nii.gz"))
+            self.assertEqual(str(missings[0]), os.path.join(tempdir, "test_label_missing.nii.gz"))
 
             # test with missing key and relative path
             datalist = [{"image": "test_image1.nii.gz", "label": "test_label_missing.nii.gz"}]
diff --git a/tests/test_create_cross_validation_datalist.py b/tests/test_create_cross_validation_datalist.py
new file mode 100644
--- /dev/null
+++ b/tests/test_create_cross_validation_datalist.py
@@ -0,0 +1,56 @@
+# 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 os
+import tempfile
+import unittest
+from pathlib import Path
+
+from monai.data import create_cross_validation_datalist, load_decathlon_datalist
+
+
+class TestCreateCrossValidationDatalist(unittest.TestCase):
+    def test_content(self):
+        with tempfile.TemporaryDirectory() as tempdir:
+            datalist = []
+            for i in range(5):
+                image = os.path.join(tempdir, f"test_image{i}.nii.gz")
+                label = os.path.join(tempdir, f"test_label{i}.nii.gz")
+                Path(image).touch()
+                Path(label).touch()
+                datalist.append({"image": image, "label": label})
+
+            filename = os.path.join(tempdir, "test_datalist.json")
+            result = create_cross_validation_datalist(
+                datalist=datalist,
+                nfolds=5,
+                train_folds=[0, 1, 2, 3],
+                val_folds=4,
+                train_key="test_train",
+                val_key="test_val",
+                filename=Path(filename),
+                shuffle=True,
+                seed=123,
+                check_missing=True,
+                keys=["image", "label"],
+                root_dir=None,
+                allow_missing_keys=False,
+                raise_error=True,
+            )
+
+            loaded = load_decathlon_datalist(filename, data_list_key="test_train")
+            for r, l in zip(result["test_train"], loaded):
+                self.assertEqual(r["image"], l["image"])
+                self.assertEqual(r["label"], l["label"])
+
+
+if __name__ == "__main__":
+    unittest.main()