From 29db225dda1c969bc3df2b7cc5d78ee41ef06d8f Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Wed, 29 Jun 2022 16:57:12 +0800 Subject: [PATCH 01/22] Add TCIA dataset Signed-off-by: Yiheng Wang --- monai/apps/utils.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index cbfdcd7423..7c94acf55b 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -19,7 +19,7 @@ import warnings import zipfile from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import List, TYPE_CHECKING, Optional from urllib.error import ContentTooShortError, HTTPError, URLError from urllib.parse import urlparse from urllib.request import urlretrieve @@ -28,6 +28,7 @@ from monai.utils import look_up_option, min_version, optional_import gdown, has_gdown = optional_import("gdown", "4.4") +requests_get, has_requests = optional_import("requests", name="get") if TYPE_CHECKING: from tqdm import tqdm @@ -146,6 +147,47 @@ def check_hash(filepath: PathLike, val: Optional[str] = None, hash_type: str = " return True +def get_tcia_metadata(query: str, attribute: Optional[str] = None): + """ + Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset. + + This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata + of objects in the TCIA database. + Please refer to the following link for more details: + https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide + + This function relys on `requests` package. + + Args: + query: queries used to achieve the corresponding metadata. A query is consisted with query name and + query parameters. The format is like: ?&. + For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG" + Please refer to the section of Image Metadata APIs in the link mentioned + above for more details. + attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes + will be ignored. + + """ + + if has_requests: + baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" + full_url = baseurl + query + resp = requests_get(full_url) + resp.raise_for_status() + else: + raise ValueError("requests package is necessary, please install it.") + metadata_list: List = [] + if len(resp.text) == 0: + return metadata_list + for d in resp.json(): + if attribute is not None and attribute in d: + metadata_list.append(d[attribute]) + else: + metadata_list.append(d) + + return metadata_list + + def download_url( url: str, filepath: PathLike = "", From a525675031857f2569db610427896257c431c303 Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Wed, 29 Jun 2022 17:03:57 +0800 Subject: [PATCH 02/22] fix isort issue Signed-off-by: Yiheng Wang --- monai/apps/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 7c94acf55b..dc06c1cf4e 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -19,7 +19,7 @@ import warnings import zipfile from pathlib import Path -from typing import List, TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional from urllib.error import ContentTooShortError, HTTPError, URLError from urllib.parse import urlparse from urllib.request import urlretrieve From da5735982e3d3ba76c42a74a89fad83d72c30233 Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Thu, 30 Jun 2022 14:42:24 +0800 Subject: [PATCH 03/22] add series instance download function Signed-off-by: Yiheng Wang --- monai/apps/utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index dc06c1cf4e..ec74ca5f47 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -29,6 +29,7 @@ gdown, has_gdown = optional_import("gdown", "4.4") requests_get, has_requests = optional_import("requests", name="get") +pd, has_pandas = optional_import("pandas") if TYPE_CHECKING: from tqdm import tqdm @@ -353,3 +354,42 @@ def download_and_extract( filename = filepath or Path(tmp_dir, _basename(url)).resolve() download_url(url=url, filepath=filename, hash_val=hash_val, hash_type=hash_type, progress=progress) extractall(filepath=filename, output_dir=output_dir, file_type=file_type, has_base=has_base) + + +def download_tcia_series_instance( + series_uid: str, + download_dir: PathLike, + output_dir: PathLike, + check_md5: bool = False, + hashes_filename: str = "md5hashes.csv", +): + """ + Download a dicom series from a public The Cancer Imaging Archive (TCIA) dataset. + The downloaded compressed file will be stored in `download_dir`, and the uncompressed folder will be saved + in `output_dir`. + + Args: + series_uid: SeriesInstanceUID of a dicom series. + download_dir: the path to store the downloaded compressed file. The full path of the file is: + `os.path.join(download_dir, f"{series_uid}.zip")`. + output_dir: target directory to save extracted dicom series. + check_md5: whether to download the MD5 hash values as well. If True, will check hash values for all images in + the downloaded dicom series. + hashes_filename: file that contains hashes. + + """ + query_name = "getImageWithMD5Hash" if check_md5 else "getImage" + baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" + download_url = f"{baseurl}{query_name}?SeriesInstanceUID={series_uid}" + + download_and_extract( + url=download_url, + filepath=os.path.join(download_dir, f"{series_uid}.zip"), + output_dir=output_dir, + ) + if check_md5: + if not has_pandas: + raise ValueError("pandas package is necessary, please install it.") + hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename)) + for dcm, md5hash in hashes_df.values: + check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") From 31e50bf4ffb723b3f4aff5c70c91cd6c03755b4b Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Fri, 8 Jul 2022 16:03:26 +0800 Subject: [PATCH 04/22] add tciadataset Signed-off-by: Yiheng Wang --- docs/source/apps.rst | 3 + monai/apps/__init__.py | 2 +- monai/apps/datasets.py | 244 ++++++++++++++++++++++++++++++++++++++++- monai/apps/utils.py | 135 ++++++++++++++++------- 4 files changed, 339 insertions(+), 45 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 3bb85c9296..e4c9cae45a 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -15,6 +15,9 @@ Applications .. autoclass:: DecathlonDataset :members: +.. autoclass:: TciaDataset + :members: + .. autoclass:: CrossValidation :members: diff --git a/monai/apps/__init__.py b/monai/apps/__init__.py index 893f7877d2..3df0e95a98 100644 --- a/monai/apps/__init__.py +++ b/monai/apps/__init__.py @@ -9,6 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset +from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset, TciaDataset from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 1bfb97abd9..418c94e33c 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -9,16 +9,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import shutil import sys +import warnings from pathlib import Path -from typing import Callable, Dict, List, Optional, Sequence, Union +from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np -from monai.apps.utils import download_and_extract +from monai.apps.utils import ( + download_and_extract, + download_tcia_series_instance, + get_ref_uuid, + get_tcia_metadata, + match_ref_uid_in_study, +) from monai.config.type_definitions import PathLike from monai.data import ( CacheDataset, + PydicomReader, load_decathlon_datalist, load_decathlon_properties, partition_dataset, @@ -194,8 +204,8 @@ class DecathlonDataset(Randomizable, CacheDataset): for further usage, use `AddChanneld` or `AsChannelFirstd` to convert the shape to [C, H, W, D]. download: whether to download and extract the Decathlon from resource link, default is False. if expected file already exists, skip downloading even set it to True. - val_frac: percentage of of validation fraction in the whole dataset, default is 0.2. user can manually copy tar file or dataset folder to the root directory. + val_frac: percentage of of validation fraction in the whole dataset, default is 0.2. seed: random seed to randomly shuffle the datalist before splitting into training and validation, default is 0. note to set same seed for `training` and `validation` sections. cache_num: number of items to be cached. Default is `sys.maxsize`. @@ -379,6 +389,234 @@ def _split_datalist(self, datalist: List[Dict]) -> List[Dict]: return [datalist[i] for i in self.indices] +class TciaDataset(Randomizable, CacheDataset): + """ + The Dataset to automatically download the data from a public The Cancer Imaging Archive (TCIA) dataset + and generate items for training, validation or test. + The downloading is divided into two steps: + 1. download all series with `picked_modality`. + 2. for each series, get the referenced Series Instance UID from its referenced sequence, and + and use it to download the series. + + This class is based on :py:class:`monai.data.CacheDataset` to accelerate the training process. + + Args: + root_dir: user's local directory for caching and loading the TCIA dataset. + collection: a TCIA dataset is defined as a collection. Please check the following list to browse + the collection list (only public collections can be downloaded): + https://www.cancerimagingarchive.net/collections/ + section: expected data section, can be: `training`, `validation` or `test`. + transform: transforms to execute operations on input data. + for further usage, use `AddChanneld` or `AsChannelFirstd` to convert the shape to [C, H, W, D]. + download: whether to download and extract the dataset, default is False. + if expected file already exists, skip downloading even set it to True. + user can manually copy tar file or dataset folder to the root directory. + download_len: number of series that will be downloaded, the value should be larger than 0 or -1, where -1 means + all series will be downloaded. Default is -1. + picked_modality: modality that is used to do the first step download. Default is "SEG". + modality_tag: tag of modality. Default is (0x0008, 0x0060). + ref_series_uid_tag: tag of referenced Series Instance UID. Default is (0x0020, 0x000e). + ref_sop_uid_tag: tag of referenced SOP Instance UID. Default is (0x0008, 0x1155). + specific_tags: tags that will be loaded for "SEG" series. This argument will be used in + `monai.data.PydicomReader`. Default is [(0x0008, 0x1115), (0x0008,0x1140), (0x3006, 0x0010), + (0x0020,0x000D), (0x0010,0x0010), (0x0010,0x0020), (0x0020,0x0011), (0x0020,0x0012)]. + val_frac: percentage of of validation fraction in the whole dataset, default is 0.2. + seed: random seed to randomly shuffle the datalist before splitting into training and validation, default is 0. + note to set same seed for `training` and `validation` sections. + cache_num: number of items to be cached. Default is `sys.maxsize`. + will take the minimum of (cache_num, data_length x cache_rate, data_length). + cache_rate: percentage of cached data in total, default is 1.0 (cache all). + will take the minimum of (cache_num, data_length x cache_rate, data_length). + num_workers: the number of worker threads to use. + If num_workers is None then the number returned by os.cpu_count() is used. + If a value less than 1 is speficied, 1 will be used instead. + progress: whether to display a progress bar when downloading dataset and computing the transform cache content. + copy_cache: whether to `deepcopy` the cache content before applying the random transforms, + default to `True`. if the random transforms don't modify the cached content + (for example, randomly crop from the cached image and deepcopy the crop region) + or if every cache item is only used once in a `multi-processing` environment, + may set `copy=False` for better performance. + as_contiguous: whether to convert the cached NumPy array or PyTorch tensor to be contiguous. + it may help improve the performance of following logic. + + Example:: + + # collection is "C4KC-KiTS", picked_modality is "SEG" + data = TciaDataset( + root_dir="./", collection="C4KC-KiTS", section="validation", seed=12345, download=True + ) + # collection is "Pancreatic-CT-CBCT-SEG", picked_modality is "RTSTRUCT" + data = TciaDataset( + root_dir="./", collection="Pancreatic-CT-CBCT-SEG", picked_modality="RTSTRUCT", download=True + ) + + print(data[0]["image"]) + + """ + + def __init__( + self, + root_dir: PathLike, + collection: str, + section: str, + transform: Union[Sequence[Callable], Callable] = (), + download: bool = False, + download_len: int = -1, + picked_modality: str = "SEG", + modality_tag: Tuple = (0x0008, 0x0060), + ref_series_uid_tag: Tuple = (0x0020, 0x000E), + ref_sop_uid_tag: Tuple = (0x0008, 0x1155), + specific_tags: List[Tuple] = [ + (0x0008, 0x1115), + (0x0008, 0x1140), + (0x3006, 0x0010), + (0x0020, 0x000D), + (0x0010, 0x0010), + (0x0010, 0x0020), + (0x0020, 0x0011), + (0x0020, 0x0012), + ], + seed: int = 0, + val_frac: float = 0.2, + cache_num: int = sys.maxsize, + cache_rate: float = 0.0, + num_workers: int = 1, + progress: bool = True, + copy_cache: bool = True, + as_contiguous: bool = True, + ) -> None: + root_dir = Path(root_dir) + if not root_dir.is_dir(): + raise ValueError("Root directory root_dir must be a directory.") + self.section = section + self.val_frac = val_frac + self.set_random_state(seed=seed) + download_dir = os.path.join(root_dir, collection) + specific_tags.append(modality_tag) + + if download: + seg_series_list = get_tcia_metadata( + query=f"getSeries?Collection={collection}&Modality={picked_modality}", + attribute="SeriesInstanceUID", + ) + if download_len > 0: + seg_series_list = seg_series_list[:download_len] + if len(seg_series_list) == 0: + raise ValueError(f"Cannot find data with collection: {collection} picked_modality: {picked_modality}") + for series_uid in seg_series_list: + seg_first_dir = os.path.join(download_dir, "raw", series_uid) + download_tcia_series_instance( + series_uid=series_uid, + download_dir=download_dir, + output_dir=seg_first_dir, + check_md5=False, + ) + dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")] + # achieve series number and patient id from the first dicom file + dcm_path = os.path.join(seg_first_dir, dicom_files[0]) + ds = PydicomReader(stop_before_pixels=True, specific_tags=specific_tags).read(dcm_path) + # (0x0010,0x0020) and (0x0010,0x0010), better to be contained in `specific_tags` + patient_id = ds.PatientID if ds.PatientID else ds.PatientName + if not patient_id: + warnings.warn(f"unable to find patient name of dicom file: {dcm_path}, use 'patient' instead.") + patient_id = "patient" + # (0x0020,0x0011) and (0x0020,0x0012), better to be contained in `specific_tags` + series_num = ds.SeriesNumber if ds.SeriesNumber else ds.AcquisitionNumber + if not series_num: + print(f"unable to find series number of dicom file: {dcm_path}, use '0' instead.") + series_num = 0 + + series_num = str(series_num) + seg_dir = os.path.join(download_dir, patient_id, series_num, "masks") + dcm_dir = os.path.join(download_dir, patient_id, series_num, "images") + + # get ref uuid + ref_uuid_list = [] + for dcm_file in dicom_files: + dcm_path = os.path.join(seg_first_dir, dcm_file) + ds = PydicomReader(stop_before_pixels=True, specific_tags=specific_tags).read(dcm_path) + if ds[modality_tag].value == picked_modality: + ref_uuid = get_ref_uuid( + ds, find_sop=False, ref_series_uid=ref_series_uid_tag, ref_sop_uid=ref_sop_uid_tag + ) + if ref_uuid == "": + ref_sop_uid = get_ref_uuid( + ds, find_sop=True, ref_series_uid=ref_series_uid_tag, ref_sop_uid=ref_sop_uid_tag + ) + ref_uuid = match_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) + if ref_uuid != "": + ref_uuid_list.append(ref_uuid) + if len(ref_uuid_list) == 0: + raise ValueError(f"Cannot find the referenced Series Instance UID from SEG series: {series_uid}.") + download_tcia_series_instance( + series_uid=ref_uuid_list[0], + download_dir=download_dir, + output_dir=dcm_dir, + check_md5=False, + ) + if not os.path.exists(seg_dir): + shutil.copytree(seg_first_dir, seg_dir) + + if not os.path.exists(download_dir): + raise RuntimeError(f"Cannot find dataset directory: {download_dir}.") + + self.indices: np.ndarray = np.array([]) + self.datalist = self._generate_data_list(download_dir) + + if transform == (): + transform = LoadImaged(reader="PydicomReader", keys=["image"]) + CacheDataset.__init__( + self, + data=self.datalist, + transform=transform, + cache_num=cache_num, + cache_rate=cache_rate, + num_workers=num_workers, + progress=progress, + copy_cache=copy_cache, + as_contiguous=as_contiguous, + ) + + def get_indices(self) -> np.ndarray: + """ + Get the indices of datalist used in this dataset. + + """ + return self.indices + + def randomize(self, data: np.ndarray) -> None: + self.R.shuffle(data) + + def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]: + # the types of the item in data list should be compatible with the dataloader + dataset_dir = Path(dataset_dir) + datalist = [] + patient_list = [f.name for f in os.scandir(dataset_dir) if f.is_dir() and f.name != "raw"] + for patient_id in patient_list: + series_list = [f.name for f in os.scandir(os.path.join(dataset_dir, patient_id)) if f.is_dir()] + for series_num in series_list: + image_path = os.path.join(dataset_dir, patient_id, series_num, "images") + mask_path = os.path.join(dataset_dir, patient_id, series_num, "masks") + datalist.append({"image": image_path, "mask": mask_path}) + + return self._split_datalist(datalist) + + def _split_datalist(self, datalist: List[Dict]) -> List[Dict]: + if self.section == "test": + return datalist + length = len(datalist) + indices = np.arange(length) + self.randomize(indices) + + val_length = int(length * self.val_frac) + if self.section == "training": + self.indices = indices[val_length:] + else: + self.indices = indices[:val_length] + + return [datalist[i] for i in self.indices] + + class CrossValidation: """ Cross validation dataset based on the general dataset which must have `_split_datalist` API. diff --git a/monai/apps/utils.py b/monai/apps/utils.py index ec74ca5f47..05ce694e57 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -148,47 +148,6 @@ def check_hash(filepath: PathLike, val: Optional[str] = None, hash_type: str = " return True -def get_tcia_metadata(query: str, attribute: Optional[str] = None): - """ - Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset. - - This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata - of objects in the TCIA database. - Please refer to the following link for more details: - https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide - - This function relys on `requests` package. - - Args: - query: queries used to achieve the corresponding metadata. A query is consisted with query name and - query parameters. The format is like: ?&. - For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG" - Please refer to the section of Image Metadata APIs in the link mentioned - above for more details. - attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes - will be ignored. - - """ - - if has_requests: - baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" - full_url = baseurl + query - resp = requests_get(full_url) - resp.raise_for_status() - else: - raise ValueError("requests package is necessary, please install it.") - metadata_list: List = [] - if len(resp.text) == 0: - return metadata_list - for d in resp.json(): - if attribute is not None and attribute in d: - metadata_list.append(d[attribute]) - else: - metadata_list.append(d) - - return metadata_list - - def download_url( url: str, filepath: PathLike = "", @@ -356,12 +315,54 @@ def download_and_extract( extractall(filepath=filename, output_dir=output_dir, file_type=file_type, has_base=has_base) +def get_tcia_metadata(query: str, attribute: Optional[str] = None): + """ + Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset. + + This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata + of objects in the TCIA database. + Please refer to the following link for more details: + https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide + + This function relys on `requests` package. + + Args: + query: queries used to achieve the corresponding metadata. A query is consisted with query name and + query parameters. The format is like: ?&. + For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG" + Please refer to the section of Image Metadata APIs in the link mentioned + above for more details. + attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes + will be ignored. + + """ + + if has_requests: + baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" + full_url = baseurl + query + resp = requests_get(full_url) + resp.raise_for_status() + else: + raise ValueError("requests package is necessary, please install it.") + metadata_list: List = [] + if len(resp.text) == 0: + return metadata_list + for d in resp.json(): + if attribute is not None and attribute in d: + metadata_list.append(d[attribute]) + else: + metadata_list.append(d) + + return metadata_list + + def download_tcia_series_instance( series_uid: str, download_dir: PathLike, output_dir: PathLike, check_md5: bool = False, hashes_filename: str = "md5hashes.csv", + progress: bool = True, ): """ Download a dicom series from a public The Cancer Imaging Archive (TCIA) dataset. @@ -376,6 +377,7 @@ def download_tcia_series_instance( check_md5: whether to download the MD5 hash values as well. If True, will check hash values for all images in the downloaded dicom series. hashes_filename: file that contains hashes. + progress: whether to display progress bar. """ query_name = "getImageWithMD5Hash" if check_md5 else "getImage" @@ -386,6 +388,7 @@ def download_tcia_series_instance( url=download_url, filepath=os.path.join(download_dir, f"{series_uid}.zip"), output_dir=output_dir, + progress=progress, ) if check_md5: if not has_pandas: @@ -393,3 +396,53 @@ def download_tcia_series_instance( hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename)) for dcm, md5hash in hashes_df.values: check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") + + +def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid=(0x0020, 0x000E), ref_sop_uid=(0x0008, 0x1155)): + """ + Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object. + The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from + the data element of the input object. If the data element is a sequence, each dataset within the sequence + will be detected iteratively. The first detected UID will be returned. + + Args: + ds: a pydicom dataset object. + find_sop: whether to achieve the referenced SOP Instance UID. + ref_series_uid: tag of the referenced Series Instance UID. + ref_sop_uid: tag of the referenced SOP Instance UID. + + """ + ref_uid = ref_series_uid if find_sop is False else ref_sop_uid + output = "" + + for elem in ds: + if elem.VR == "SQ": + for item in elem: + output = get_ref_uuid(item, find_sop) + if elem.tag == ref_uid: + return elem.value + + return output + + +def match_ref_uid_in_study(study_uid, ref_sop_uid): + """ + Match the SeriesInstanceUID from all series in a study according to the input SOPInstanceUID. + + Args: + study_uid: StudyInstanceUID. + ref_sop_uid: SOPInstanceUID. + + """ + series_list = get_tcia_metadata( + query=f"getSeries?StudyInstanceUID={study_uid}", + attribute="SeriesInstanceUID", + ) + for series_id in series_list: + sop_id_list = get_tcia_metadata( + query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", + attribute="SOPInstanceUID", + ) + if ref_sop_uid in sop_id_list: + return series_id + return "" From 65553d99c273f138db2c79fcfd59f6519a2cf38c Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Fri, 8 Jul 2022 16:15:34 +0800 Subject: [PATCH 05/22] fix black error Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 13 +++---------- monai/apps/utils.py | 8 ++------ 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 418c94e33c..40b7d13097 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -496,8 +496,7 @@ def __init__( if download: seg_series_list = get_tcia_metadata( - query=f"getSeries?Collection={collection}&Modality={picked_modality}", - attribute="SeriesInstanceUID", + query=f"getSeries?Collection={collection}&Modality={picked_modality}", attribute="SeriesInstanceUID" ) if download_len > 0: seg_series_list = seg_series_list[:download_len] @@ -506,10 +505,7 @@ def __init__( for series_uid in seg_series_list: seg_first_dir = os.path.join(download_dir, "raw", series_uid) download_tcia_series_instance( - series_uid=series_uid, - download_dir=download_dir, - output_dir=seg_first_dir, - check_md5=False, + series_uid=series_uid, download_dir=download_dir, output_dir=seg_first_dir, check_md5=False ) dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")] # achieve series number and patient id from the first dicom file @@ -549,10 +545,7 @@ def __init__( if len(ref_uuid_list) == 0: raise ValueError(f"Cannot find the referenced Series Instance UID from SEG series: {series_uid}.") download_tcia_series_instance( - series_uid=ref_uuid_list[0], - download_dir=download_dir, - output_dir=dcm_dir, - check_md5=False, + series_uid=ref_uuid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False ) if not os.path.exists(seg_dir): shutil.copytree(seg_first_dir, seg_dir) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 05ce694e57..faad67ccb6 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -434,14 +434,10 @@ def match_ref_uid_in_study(study_uid, ref_sop_uid): ref_sop_uid: SOPInstanceUID. """ - series_list = get_tcia_metadata( - query=f"getSeries?StudyInstanceUID={study_uid}", - attribute="SeriesInstanceUID", - ) + series_list = get_tcia_metadata(query=f"getSeries?StudyInstanceUID={study_uid}", attribute="SeriesInstanceUID") for series_id in series_list: sop_id_list = get_tcia_metadata( - query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", - attribute="SOPInstanceUID", + query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", attribute="SOPInstanceUID" ) if ref_sop_uid in sop_id_list: return series_id From a766cdbd45ddbc7e2b0e2e87d1afb8a4424aaf7f Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Fri, 8 Jul 2022 16:55:37 +0800 Subject: [PATCH 06/22] fix flake8 error Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 40b7d13097..4b07a31c06 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -466,7 +466,7 @@ def __init__( modality_tag: Tuple = (0x0008, 0x0060), ref_series_uid_tag: Tuple = (0x0020, 0x000E), ref_sop_uid_tag: Tuple = (0x0008, 0x1155), - specific_tags: List[Tuple] = [ + specific_tags: Tuple = ( (0x0008, 0x1115), (0x0008, 0x1140), (0x3006, 0x0010), @@ -475,7 +475,7 @@ def __init__( (0x0010, 0x0020), (0x0020, 0x0011), (0x0020, 0x0012), - ], + ), seed: int = 0, val_frac: float = 0.2, cache_num: int = sys.maxsize, @@ -492,8 +492,8 @@ def __init__( self.val_frac = val_frac self.set_random_state(seed=seed) download_dir = os.path.join(root_dir, collection) - specific_tags.append(modality_tag) - + load_tags = list(specific_tags) + load_tags += [modality_tag] if download: seg_series_list = get_tcia_metadata( query=f"getSeries?Collection={collection}&Modality={picked_modality}", attribute="SeriesInstanceUID" @@ -510,7 +510,7 @@ def __init__( dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")] # achieve series number and patient id from the first dicom file dcm_path = os.path.join(seg_first_dir, dicom_files[0]) - ds = PydicomReader(stop_before_pixels=True, specific_tags=specific_tags).read(dcm_path) + ds = PydicomReader(stop_before_pixels=True, specific_tags=load_tags).read(dcm_path) # (0x0010,0x0020) and (0x0010,0x0010), better to be contained in `specific_tags` patient_id = ds.PatientID if ds.PatientID else ds.PatientName if not patient_id: @@ -530,7 +530,7 @@ def __init__( ref_uuid_list = [] for dcm_file in dicom_files: dcm_path = os.path.join(seg_first_dir, dcm_file) - ds = PydicomReader(stop_before_pixels=True, specific_tags=specific_tags).read(dcm_path) + ds = PydicomReader(stop_before_pixels=True, specific_tags=load_tags).read(dcm_path) if ds[modality_tag].value == picked_modality: ref_uuid = get_ref_uuid( ds, find_sop=False, ref_series_uid=ref_series_uid_tag, ref_sop_uid=ref_sop_uid_tag From a3235efc48ed4ae08beaf1b3e8c35ff0c3228a4a Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Mon, 11 Jul 2022 22:04:37 +0800 Subject: [PATCH 07/22] refine tcia dataset cls Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 122 +++++++++++++++++++++++------------------ monai/apps/utils.py | 10 ++-- 2 files changed, 74 insertions(+), 58 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 4b07a31c06..55ee24ae8c 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -393,10 +393,6 @@ class TciaDataset(Randomizable, CacheDataset): """ The Dataset to automatically download the data from a public The Cancer Imaging Archive (TCIA) dataset and generate items for training, validation or test. - The downloading is divided into two steps: - 1. download all series with `picked_modality`. - 2. for each series, get the referenced Series Instance UID from its referenced sequence, and - and use it to download the series. This class is based on :py:class:`monai.data.CacheDataset` to accelerate the training process. @@ -490,10 +486,16 @@ def __init__( raise ValueError("Root directory root_dir must be a directory.") self.section = section self.val_frac = val_frac + self.picked_modality = picked_modality + self.modality_tag = modality_tag + self.ref_series_uid_tag = ref_series_uid_tag + self.ref_sop_uid_tag = ref_sop_uid_tag + self.set_random_state(seed=seed) download_dir = os.path.join(root_dir, collection) load_tags = list(specific_tags) load_tags += [modality_tag] + self.load_tags = load_tags if download: seg_series_list = get_tcia_metadata( query=f"getSeries?Collection={collection}&Modality={picked_modality}", attribute="SeriesInstanceUID" @@ -503,52 +505,7 @@ def __init__( if len(seg_series_list) == 0: raise ValueError(f"Cannot find data with collection: {collection} picked_modality: {picked_modality}") for series_uid in seg_series_list: - seg_first_dir = os.path.join(download_dir, "raw", series_uid) - download_tcia_series_instance( - series_uid=series_uid, download_dir=download_dir, output_dir=seg_first_dir, check_md5=False - ) - dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")] - # achieve series number and patient id from the first dicom file - dcm_path = os.path.join(seg_first_dir, dicom_files[0]) - ds = PydicomReader(stop_before_pixels=True, specific_tags=load_tags).read(dcm_path) - # (0x0010,0x0020) and (0x0010,0x0010), better to be contained in `specific_tags` - patient_id = ds.PatientID if ds.PatientID else ds.PatientName - if not patient_id: - warnings.warn(f"unable to find patient name of dicom file: {dcm_path}, use 'patient' instead.") - patient_id = "patient" - # (0x0020,0x0011) and (0x0020,0x0012), better to be contained in `specific_tags` - series_num = ds.SeriesNumber if ds.SeriesNumber else ds.AcquisitionNumber - if not series_num: - print(f"unable to find series number of dicom file: {dcm_path}, use '0' instead.") - series_num = 0 - - series_num = str(series_num) - seg_dir = os.path.join(download_dir, patient_id, series_num, "masks") - dcm_dir = os.path.join(download_dir, patient_id, series_num, "images") - - # get ref uuid - ref_uuid_list = [] - for dcm_file in dicom_files: - dcm_path = os.path.join(seg_first_dir, dcm_file) - ds = PydicomReader(stop_before_pixels=True, specific_tags=load_tags).read(dcm_path) - if ds[modality_tag].value == picked_modality: - ref_uuid = get_ref_uuid( - ds, find_sop=False, ref_series_uid=ref_series_uid_tag, ref_sop_uid=ref_sop_uid_tag - ) - if ref_uuid == "": - ref_sop_uid = get_ref_uuid( - ds, find_sop=True, ref_series_uid=ref_series_uid_tag, ref_sop_uid=ref_sop_uid_tag - ) - ref_uuid = match_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) - if ref_uuid != "": - ref_uuid_list.append(ref_uuid) - if len(ref_uuid_list) == 0: - raise ValueError(f"Cannot find the referenced Series Instance UID from SEG series: {series_uid}.") - download_tcia_series_instance( - series_uid=ref_uuid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False - ) - if not os.path.exists(seg_dir): - shutil.copytree(seg_first_dir, seg_dir) + self._download_series_reference_data(series_uid, download_dir) if not os.path.exists(download_dir): raise RuntimeError(f"Cannot find dataset directory: {download_dir}.") @@ -580,6 +537,62 @@ def get_indices(self) -> np.ndarray: def randomize(self, data: np.ndarray) -> None: self.R.shuffle(data) + def _download_series_reference_data(self, series_uid: str, download_dir: str): + """ + First of all, download a series from TCIA according to `series_uid`. + Then find all referenced series and download. + """ + seg_first_dir = os.path.join(download_dir, "raw", series_uid) + download_tcia_series_instance( + series_uid=series_uid, download_dir=download_dir, output_dir=seg_first_dir, check_md5=False + ) + dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")] + # achieve series number and patient id from the first dicom file + dcm_path = os.path.join(seg_first_dir, dicom_files[0]) + ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path) + # (0x0010,0x0020) and (0x0010,0x0010), better to be contained in `specific_tags` + patient_id = ds.PatientID if ds.PatientID else ds.PatientName + if not patient_id: + warnings.warn(f"unable to find patient name of dicom file: {dcm_path}, use 'patient' instead.") + patient_id = "patient" + # (0x0020,0x0011) and (0x0020,0x0012), better to be contained in `specific_tags` + series_num = ds.SeriesNumber if ds.SeriesNumber else ds.AcquisitionNumber + if not series_num: + warnings.warn(f"unable to find series number of dicom file: {dcm_path}, use '0' instead.") + series_num = 0 + + series_num = str(series_num) + seg_dir = os.path.join(download_dir, patient_id, series_num, self.picked_modality) + dcm_dir = os.path.join(download_dir, patient_id, series_num, "IMAGE") + + # get ref uuid + ref_uuid_list = [] + for dcm_file in dicom_files: + dcm_path = os.path.join(seg_first_dir, dcm_file) + ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path) + if ds[self.modality_tag].value == self.picked_modality: + ref_uuid = get_ref_uuid( + ds, find_sop=False, ref_series_uid_tag=self.ref_series_uid_tag, ref_sop_uid_tag=self.ref_sop_uid_tag + ) + if ref_uuid == "": + ref_sop_uid = get_ref_uuid( + ds, + find_sop=True, + ref_series_uid_tag=self.ref_series_uid_tag, + ref_sop_uid_tag=self.ref_sop_uid_tag, + ) + ref_uuid = match_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) + if ref_uuid != "": + ref_uuid_list.append(ref_uuid) + if len(ref_uuid_list) == 0: + warnings.warn(f"Cannot find the referenced Series Instance UID from series: {series_uid}.") + else: + download_tcia_series_instance( + series_uid=ref_uuid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False + ) + if not os.path.exists(seg_dir): + shutil.copytree(seg_first_dir, seg_dir) + def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]: # the types of the item in data list should be compatible with the dataloader dataset_dir = Path(dataset_dir) @@ -588,9 +601,12 @@ def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]: for patient_id in patient_list: series_list = [f.name for f in os.scandir(os.path.join(dataset_dir, patient_id)) if f.is_dir()] for series_num in series_list: - image_path = os.path.join(dataset_dir, patient_id, series_num, "images") - mask_path = os.path.join(dataset_dir, patient_id, series_num, "masks") - datalist.append({"image": image_path, "mask": mask_path}) + image_path = os.path.join(dataset_dir, patient_id, series_num, "IMAGE") + mask_path = os.path.join(dataset_dir, patient_id, series_num, self.picked_modality) + if os.path.exists(image_path): + datalist.append({"image": image_path, self.picked_modality: mask_path}) + else: + datalist.append({self.picked_modality: mask_path}) return self._split_datalist(datalist) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index faad67ccb6..356088ac31 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -398,7 +398,7 @@ def download_tcia_series_instance( check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") -def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid=(0x0020, 0x000E), ref_sop_uid=(0x0008, 0x1155)): +def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): """ Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object. The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from @@ -408,18 +408,18 @@ def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid=(0x0020, 0x000E), re Args: ds: a pydicom dataset object. find_sop: whether to achieve the referenced SOP Instance UID. - ref_series_uid: tag of the referenced Series Instance UID. - ref_sop_uid: tag of the referenced SOP Instance UID. + ref_series_uid_tag: tag of the referenced Series Instance UID. + ref_sop_uid_tag: tag of the referenced SOP Instance UID. """ - ref_uid = ref_series_uid if find_sop is False else ref_sop_uid + ref_uid_tag = ref_series_uid_tag if find_sop is False else ref_sop_uid_tag output = "" for elem in ds: if elem.VR == "SQ": for item in elem: output = get_ref_uuid(item, find_sop) - if elem.tag == ref_uid: + if elem.tag == ref_uid_tag: return elem.value return output From 0da4b682adfda675850df8701da92eac6dc4c5bd Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Wed, 13 Jul 2022 00:25:36 +0800 Subject: [PATCH 08/22] add highdicom support Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 14 ++-- monai/data/image_reader.py | 133 +++++++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 28 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 55ee24ae8c..48ff2f3c31 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -562,8 +562,8 @@ def _download_series_reference_data(self, series_uid: str, download_dir: str): series_num = 0 series_num = str(series_num) - seg_dir = os.path.join(download_dir, patient_id, series_num, self.picked_modality) - dcm_dir = os.path.join(download_dir, patient_id, series_num, "IMAGE") + seg_dir = os.path.join(download_dir, patient_id, series_num, self.picked_modality.lower()) + dcm_dir = os.path.join(download_dir, patient_id, series_num, "image") # get ref uuid ref_uuid_list = [] @@ -601,12 +601,14 @@ def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]: for patient_id in patient_list: series_list = [f.name for f in os.scandir(os.path.join(dataset_dir, patient_id)) if f.is_dir()] for series_num in series_list: - image_path = os.path.join(dataset_dir, patient_id, series_num, "IMAGE") - mask_path = os.path.join(dataset_dir, patient_id, series_num, self.picked_modality) + seg_key = self.picked_modality.lower() + image_path = os.path.join(dataset_dir, patient_id, series_num, "image") + mask_path = os.path.join(dataset_dir, patient_id, series_num, seg_key) + if os.path.exists(image_path): - datalist.append({"image": image_path, self.picked_modality: mask_path}) + datalist.append({"image": image_path, seg_key: mask_path}) else: - datalist.append({self.picked_modality: mask_path}) + datalist.append({seg_key: mask_path}) return self._split_datalist(datalist) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 743896c99a..1f7718cc7f 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -38,13 +38,14 @@ from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_nrrd = has_itk = has_nib = has_pil = has_pydicom = True + has_nrrd = has_itk = has_nib = has_pil = has_pydicom = has_highdicom = True else: itk, has_itk = optional_import("itk", allow_namespace_pkg=True) nib, has_nib = optional_import("nibabel") Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image") PILImage, has_pil = optional_import("PIL.Image") pydicom, has_pydicom = optional_import("pydicom") + highdicom, has_highdicom = optional_import("highdicom") nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True) OpenSlide, _ = optional_import("openslide", name="OpenSlide") @@ -373,15 +374,19 @@ def _get_array_data(self, img): @require_pkg(pkg_name="pydicom") +@require_pkg(pkg_name="highdicom") class PydicomReader(ImageReader): """ Load medical images based on Pydicom library. All the supported image formats can be found at: https://dicom.nema.org/medical/dicom/current/output/chtml/part10/chapter_7.html + For dicom data with modality "SEG", Highdicom will be used. + This class refers to: https://nipy.org/nibabel/dicom/dicom_orientation.html#dicom-affine-formula https://github.com/pydicom/contrib-pydicom/blob/master/input-output/pydicom_series.py + https://highdicom.readthedocs.io/en/latest/usage.html#parsing-segmentation-seg-images Args: channel_dim: the channel dimension of the input image, default is None. @@ -392,6 +397,9 @@ class PydicomReader(ImageReader): otherwise the affine matrix remains in the Dicom convention. swap_ij: whether to swap the first two spatial axes. Default to ``True``, so that the outputs are consistent with the other readers. + prune_metadata: whether to prune the saved information in metadata. This argument is used for + `get_data` function. If True, only items that are related to the affine matrix will be saved. + Default to ``True``. kwargs: additional args for `pydicom.dcmread` API. more details about available args: https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread If the `get_data` function will be called @@ -401,13 +409,19 @@ class PydicomReader(ImageReader): """ def __init__( - self, channel_dim: Optional[int] = None, affine_lps_to_ras: bool = True, swap_ij: bool = True, **kwargs + self, + channel_dim: Optional[int] = None, + affine_lps_to_ras: bool = True, + swap_ij: bool = True, + prune_metadata: bool = True, + **kwargs, ): super().__init__() self.kwargs = kwargs self.channel_dim = channel_dim self.affine_lps_to_ras = affine_lps_to_ras self.swap_ij = swap_ij + self.prune_metadata = prune_metadata def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: """ @@ -418,7 +432,7 @@ def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: if a list of files, verify all the suffixes. """ - return has_pydicom + return has_pydicom and has_highdicom def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): """ @@ -448,9 +462,10 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): name = f"{name}" if Path(name).is_dir(): # read DICOM series - slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "**"))] + slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "*.dcm"))] img_.append(slices if len(slices) > 1 else slices[0]) - self.has_series = True + if len(slices) > 1: + self.has_series = True else: ds = pydicom.dcmread(fp=name, **kwargs_) img_.append(ds) @@ -481,7 +496,7 @@ def _combine_dicom_series(self, data): slices.append(slc_ds) else: warnings.warn(f"slice: {slc_ds.filename} does not have InstanceNumber tag, skip it.") - slices = sorted(slices, key=lambda s: s.InstanceNumber) + slices = sorted(slices, key=lambda s: s.InstanceNumber, reverse=True) if len(slices) == 0: raise ValueError("the input does not have valid slices.") @@ -532,8 +547,13 @@ def get_data(self, data): When loading a list of files (dicom file, or stacked dicom series), they are stacked together at a new dimension as the first dimension, and the metadata of the first image is used to represent the output metadata. - To use this function, all pydicom dataset objects should contain: `pixel_array`, `PixelSpacing`, - `ImagePositionPatient` and `ImageOrientationPatient`. + To use this function, all pydicom dataset objects (if not segmentation data) should contain: + `pixel_array`, `PixelSpacing`, `ImagePositionPatient` and `ImageOrientationPatient`. + + For segmentation data, we assume that the input is not a dicom series, and the object should contain + `SegmentSequence` in order to identify it. + In addition, tags (5200, 9229) and (5200, 9230) are required to achieve + `PixelSpacing`, `ImageOrientationPatient` and `ImagePositionPatient`. Args: data: a pydicom dataset object, or a list of pydicom dataset objects, or a list of list of @@ -556,10 +576,12 @@ def get_data(self, data): if not isinstance(data, List): data = [data] for d in data: - data_array = self._get_array_data(d) - metadata = self._get_meta_dict(d) - metadata["spatial_shape"] = data_array.shape - metadata["spacing"] = np.asarray(metadata.get("00280030", {}).get("Value", (1.0, 1.0, 1.0))) + if hasattr(d, "SegmentSequence"): + data_array, metadata = self._get_seg_data(d) + else: + data_array = self._get_array_data(d) + metadata = self._get_meta_dict(d) + metadata["spatial_shape"] = data_array.shape dicom_data.append((data_array, metadata)) img_array: List[np.ndarray] = [] @@ -582,6 +604,7 @@ def get_data(self, data): else: metadata["original_channel_dim"] = self.channel_dim metadata["spacing"] = affine_to_spacing(metadata["original_affine"], r=len(metadata["spatial_shape"])) + _copy_compatible_dict(metadata, compatible_meta) return _stack_images(img_array, compatible_meta), compatible_meta @@ -594,19 +617,23 @@ def _get_meta_dict(self, img) -> Dict: img: a Pydicom dataset object. """ - if not hasattr(img, "ImagePositionPatient"): - raise ValueError(f"dicom data: {img.filename} does not have ImagePositionPatient.") - if not hasattr(img, "ImageOrientationPatient"): - raise ValueError(f"dicom data: {img.filename} does not have ImageOrientationPatient.") - meta_dict = img.to_json_dict() - # remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010" - # remove Data Set Trailing Padding "FFFCFFFC" + metadata = img.to_json_dict() + + if self.prune_metadata: + prune_metadata = {} + for key in ["00200037", "00200032", "52009229", "52009230"]: + if key in metadata.keys(): + prune_metadata[key] = metadata[key] + return prune_metadata + + # always remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010" + # always remove Data Set Trailing Padding "FFFCFFFC" for key in ["7FE00008", "7FE00009", "7FE00010", "FFFCFFFC"]: - if key in meta_dict.keys(): - meta_dict.pop(key) + if key in metadata.keys(): + metadata.pop(key) - return meta_dict # type: ignore + return metadata # type: ignore def _get_affine(self, metadata: Dict, lps_to_ras: bool = True): """ @@ -650,6 +677,68 @@ def _get_affine(self, metadata: Dict, lps_to_ras: bool = True): affine = orientation_ras_lps(affine) return affine + def _get_seg_data(self, img): + """ + Get the array data and metadata of the segmentation image. + + Aegs: + img: a Pydicom dataset object that has attribute "SegmentSequence". + + """ + + metadata = self._get_meta_dict(img) + metadata["labels"] = {} + + try: + all_segs = [] + for frames, _, description in highdicom.seg.utils.iter_segments(img): + all_segs.append(np.transpose(frames, [1, 2, 0])) + metadata["labels"][str(description.SegmentNumber)] = description.SegmentDescription + if len(all_segs) == 1: + all_segs = np.expand_dims(all_segs, axis=-1).astype(np.uint8) + else: + all_segs = np.stack(all_segs, axis=-1).astype(np.uint8) + metadata["spatial_shape"] = all_segs.shape[:-1] + except Exception as e: + raise NotImplementedError(f"Highdicom cannot read dicom seg data: {img.filename}.") from e + + if "52009229" in metadata.keys(): + shared_func_group_seq = metadata["52009229"]["Value"][0] + + # get `ImageOrientationPatient` + if "00209116" in shared_func_group_seq.keys(): + plane_orient_seq = shared_func_group_seq["00209116"]["Value"][0] + if "00200037" in plane_orient_seq.keys(): + metadata["00200037"] = plane_orient_seq["00200037"] + + # get `PixelSpacing` + if "00289110" in shared_func_group_seq.keys(): + pixel_measure_seq = shared_func_group_seq["00289110"]["Value"][0] + + if "00280030" in pixel_measure_seq.keys(): + pixel_spacing = pixel_measure_seq["00280030"]["Value"] + metadata["spacing"] = pixel_spacing + if "00180050" in pixel_measure_seq.keys(): + metadata["spacing"] += pixel_measure_seq["00180050"]["Value"] + + if self.prune_metadata: + metadata.pop("52009229") + + # get `ImagePositionPatient` + if "52009230" in metadata.keys(): + first_frame_func_group_seq = metadata["52009230"]["Value"][0] + if "00209113" in first_frame_func_group_seq.keys(): + plane_position_seq = first_frame_func_group_seq["00209113"]["Value"][0] + if "00200032" in plane_position_seq.keys(): + metadata["00200032"] = plane_position_seq["00200032"] + metadata["lastImagePositionPatient"] = metadata["52009230"]["Value"][-1]["00209113"]["Value"][0][ + "00200032" + ]["Value"] + if self.prune_metadata: + metadata.pop("52009230") + + return all_segs, metadata + def _get_array_data(self, img): """ Get the array data of the image. If `RescaleSlope` and `RescaleIntercept` are available, the raw array data From d3779839ee5dc36d2b93bd80dbabfe35ea4fd7ce Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Wed, 13 Jul 2022 00:26:14 +0800 Subject: [PATCH 09/22] modify requirements-dev Signed-off-by: Yiheng Wang --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8318a1795c..efbd6ffa1f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -50,3 +50,4 @@ jsonschema pynrrd pre-commit pydicom +highdicom From 725e61ebbec4b9e0b08ef7183b350d3f5da65eef Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Wed, 13 Jul 2022 16:35:00 +0800 Subject: [PATCH 10/22] add unittest Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 7 ++- monai/data/image_reader.py | 12 +++-- tests/test_tciadataset.py | 97 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 tests/test_tciadataset.py diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 48ff2f3c31..cd96e31dea 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -394,6 +394,11 @@ class TciaDataset(Randomizable, CacheDataset): The Dataset to automatically download the data from a public The Cancer Imaging Archive (TCIA) dataset and generate items for training, validation or test. + The Highdicom library is used to load dicom data with modality "SEG", but only a part of collections are + supoorted, such as: "C4KC-KiTS", "NSCLC-Radiomics", "NSCLC-Radiomics-Interobserver1", " QIN-PROSTATE-Repeatability" + and "PROSTATEx". Therefore, if "seg" is included in `keys` of the `LoadImaged` transform and loading some + other collections, errors may be raised. + This class is based on :py:class:`monai.data.CacheDataset` to accelerate the training process. Args: @@ -421,7 +426,7 @@ class TciaDataset(Randomizable, CacheDataset): note to set same seed for `training` and `validation` sections. cache_num: number of items to be cached. Default is `sys.maxsize`. will take the minimum of (cache_num, data_length x cache_rate, data_length). - cache_rate: percentage of cached data in total, default is 1.0 (cache all). + cache_rate: percentage of cached data in total, default is 0.0 (no cache). will take the minimum of (cache_num, data_length x cache_rate, data_length). num_workers: the number of worker threads to use. If num_workers is None then the number returned by os.cpu_count() is used. diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 1f7718cc7f..c5375188cd 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -462,7 +462,9 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): name = f"{name}" if Path(name).is_dir(): # read DICOM series - slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "*.dcm"))] + series_slcs = glob.glob(os.path.join(name, "*")) + series_slcs = [slc for slc in series_slcs if "LICENSE" not in slc] + slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in series_slcs] img_.append(slices if len(slices) > 1 else slices[0]) if len(slices) > 1: self.has_series = True @@ -496,7 +498,7 @@ def _combine_dicom_series(self, data): slices.append(slc_ds) else: warnings.warn(f"slice: {slc_ds.filename} does not have InstanceNumber tag, skip it.") - slices = sorted(slices, key=lambda s: s.InstanceNumber, reverse=True) + slices = sorted(slices, key=lambda s: s.InstanceNumber) if len(slices) == 0: raise ValueError("the input does not have valid slices.") @@ -691,11 +693,11 @@ def _get_seg_data(self, img): try: all_segs = [] - for frames, _, description in highdicom.seg.utils.iter_segments(img): + for i, (frames, _, description) in enumerate(highdicom.seg.utils.iter_segments(img)): all_segs.append(np.transpose(frames, [1, 2, 0])) - metadata["labels"][str(description.SegmentNumber)] = description.SegmentDescription + metadata["labels"][str(i)] = description.SegmentDescription if len(all_segs) == 1: - all_segs = np.expand_dims(all_segs, axis=-1).astype(np.uint8) + all_segs = np.expand_dims(all_segs[0], axis=-1).astype(np.uint8) else: all_segs = np.stack(all_segs, axis=-1).astype(np.uint8) metadata["spatial_shape"] = all_segs.shape[:-1] diff --git a/tests/test_tciadataset.py b/tests/test_tciadataset.py new file mode 100644 index 0000000000..26024ac114 --- /dev/null +++ b/tests/test_tciadataset.py @@ -0,0 +1,97 @@ +# 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 os +import shutil +import unittest + +from monai.apps import TciaDataset +from monai.data import MetaTensor +from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd +from tests.utils import skip_if_downloading_fails, skip_if_quick + + +class TestTciaDataset(unittest.TestCase): + @skip_if_quick + def test_values(self): + testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") + transform = Compose( + [ + LoadImaged(keys=["image", "seg"], reader="PydicomReader"), + AddChanneld(keys="image"), + ScaleIntensityd(keys="image"), + ] + ) + download_len = 2 + val_frac = 0.5 + collection = "C4KC-KiTS" + + def _test_dataset(dataset): + self.assertEqual(len(dataset), int(download_len * val_frac)) + self.assertTrue("image" in dataset[0]) + self.assertTrue("seg" in dataset[0]) + self.assertTrue(isinstance(dataset[0]["image"], MetaTensor)) + self.assertTupleEqual(dataset[0]["image"].shape, (1, 512, 512, 61)) + self.assertTupleEqual(dataset[0]["seg"].shape, (512, 512, 61, 2)) + + with skip_if_downloading_fails(): + data = TciaDataset( + root_dir=testing_dir, + collection=collection, + transform=transform, + section="validation", + download=True, + download_len=download_len, + copy_cache=False, + val_frac=val_frac, + ) + + _test_dataset(data) + data = TciaDataset( + root_dir=testing_dir, + collection=collection, + transform=transform, + section="validation", + download=False, + val_frac=val_frac, + ) + _test_dataset(data) + self.assertTrue(data[0]["image"].meta["filename_or_obj"].endswith("C4KC-KiTS/KiTS-00007/300/image")) + self.assertTrue(data[0]["seg"].meta["filename_or_obj"].endswith("C4KC-KiTS/KiTS-00007/300/seg")) + # test validation without transforms + data = TciaDataset( + root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac + ) + self.assertTupleEqual(data[0]["image"].shape, (512, 512, 61)) + self.assertEqual(len(data), int(download_len * val_frac)) + data = TciaDataset( + root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac + ) + self.assertTupleEqual(data[0]["image"].shape, (512, 512, 61)) + self.assertEqual(len(data), int(download_len - download_len * val_frac)) + + shutil.rmtree(os.path.join(testing_dir, collection)) + try: + TciaDataset( + root_dir=testing_dir, + collection=collection, + transform=transform, + section="validation", + download=False, + val_frac=val_frac, + ) + except RuntimeError as e: + print(str(e)) + self.assertTrue(str(e).startswith("Cannot find dataset directory")) + + +if __name__ == "__main__": + unittest.main() From 7638b229eecd3e95b174eaab230adba387a9cfe1 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Wed, 13 Jul 2022 16:51:14 +0800 Subject: [PATCH 11/22] Update monai/apps/datasets.py Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index cd96e31dea..18abb7dbd8 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -468,14 +468,14 @@ def __init__( ref_series_uid_tag: Tuple = (0x0020, 0x000E), ref_sop_uid_tag: Tuple = (0x0008, 0x1155), specific_tags: Tuple = ( - (0x0008, 0x1115), - (0x0008, 0x1140), - (0x3006, 0x0010), - (0x0020, 0x000D), - (0x0010, 0x0010), - (0x0010, 0x0020), - (0x0020, 0x0011), - (0x0020, 0x0012), + (0x0008, 0x1115), # Referenced Series Sequence + (0x0008, 0x1140), # Referenced Image Sequence + (0x3006, 0x0010), # Referenced Frame of Reference Sequence + (0x0020, 0x000D), # Study Instance UID + (0x0010, 0x0010), # Patient's Name + (0x0010, 0x0020), # Patient ID + (0x0020, 0x0011), # Series Number + (0x0020, 0x0012), # Acquisition Number ), seed: int = 0, val_frac: float = 0.2, From 9c6f34375f64dd8b834309a16df5a9f47d1a7ead Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Fri, 15 Jul 2022 20:54:29 +0800 Subject: [PATCH 12/22] add labels option Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 50 ++++++++++++++++++++++++-------------- monai/apps/utils.py | 19 ++++++++++++--- monai/data/image_reader.py | 30 ++++++++++++++++------- tests/test_tciadataset.py | 1 - 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 18abb7dbd8..3d6ae87076 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -21,9 +21,9 @@ from monai.apps.utils import ( download_and_extract, download_tcia_series_instance, - get_ref_uuid, get_tcia_metadata, - match_ref_uid_in_study, + get_tcia_ref_uid, + match_tcia_ref_uid_in_study, ) from monai.config.type_definitions import PathLike from monai.data import ( @@ -37,7 +37,7 @@ from monai.transforms import LoadImaged, Randomizable from monai.utils import ensure_tuple -__all__ = ["MedNISTDataset", "DecathlonDataset", "CrossValidation"] +__all__ = ["MedNISTDataset", "DecathlonDataset", "CrossValidation", "TciaDataset"] class MedNISTDataset(Randomizable, CacheDataset): @@ -403,12 +403,17 @@ class TciaDataset(Randomizable, CacheDataset): Args: root_dir: user's local directory for caching and loading the TCIA dataset. - collection: a TCIA dataset is defined as a collection. Please check the following list to browse + collection: name of a TCIA collection. + a TCIA dataset is defined as a collection. Please check the following list to browse the collection list (only public collections can be downloaded): https://www.cancerimagingarchive.net/collections/ section: expected data section, can be: `training`, `validation` or `test`. transform: transforms to execute operations on input data. for further usage, use `AddChanneld` or `AsChannelFirstd` to convert the shape to [C, H, W, D]. + If not specified, `LoadImaged(reader="PydicomReader", keys=["image"])` will be used as the default + transform. In addition, we suggest to set the argument `labels` of `PydicomReader` if segmentations + are needed to be loaded. The original labels for each dicom series may be different, using this argument + is able to unify the format of labels. download: whether to download and extract the dataset, default is False. if expected file already exists, skip downloading even set it to True. user can manually copy tar file or dataset folder to the root directory. @@ -442,16 +447,24 @@ class TciaDataset(Randomizable, CacheDataset): Example:: - # collection is "C4KC-KiTS", picked_modality is "SEG" - data = TciaDataset( - root_dir="./", collection="C4KC-KiTS", section="validation", seed=12345, download=True - ) # collection is "Pancreatic-CT-CBCT-SEG", picked_modality is "RTSTRUCT" data = TciaDataset( root_dir="./", collection="Pancreatic-CT-CBCT-SEG", picked_modality="RTSTRUCT", download=True ) - print(data[0]["image"]) + # collection is "C4KC-KiTS", picked_modality is "SEG", and load both images and segmentations + transform = Compose( + [ + LoadImaged(reader="PydicomReader", keys=["image", "seg"]), + EnsureChannelFirstd(keys=["image", "seg"]), + ResampleToMatchd(keys="image", key_dst="seg"), + ] + ) + data = TciaDataset( + root_dir="./", collection="C4KC-KiTS", section="validation", seed=12345, download=True + ) + + print(data[0]["seg"].shape) """ @@ -489,6 +502,7 @@ def __init__( root_dir = Path(root_dir) if not root_dir.is_dir(): raise ValueError("Root directory root_dir must be a directory.") + self.section = section self.val_frac = val_frac self.picked_modality = picked_modality @@ -571,29 +585,29 @@ def _download_series_reference_data(self, series_uid: str, download_dir: str): dcm_dir = os.path.join(download_dir, patient_id, series_num, "image") # get ref uuid - ref_uuid_list = [] + ref_uid_list = [] for dcm_file in dicom_files: dcm_path = os.path.join(seg_first_dir, dcm_file) ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path) if ds[self.modality_tag].value == self.picked_modality: - ref_uuid = get_ref_uuid( + ref_uid = get_tcia_ref_uid( ds, find_sop=False, ref_series_uid_tag=self.ref_series_uid_tag, ref_sop_uid_tag=self.ref_sop_uid_tag ) - if ref_uuid == "": - ref_sop_uid = get_ref_uuid( + if ref_uid == "": + ref_sop_uid = get_tcia_ref_uid( ds, find_sop=True, ref_series_uid_tag=self.ref_series_uid_tag, ref_sop_uid_tag=self.ref_sop_uid_tag, ) - ref_uuid = match_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) - if ref_uuid != "": - ref_uuid_list.append(ref_uuid) - if len(ref_uuid_list) == 0: + ref_uid = match_tcia_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) + if ref_uid != "": + ref_uid_list.append(ref_uid) + if len(ref_uid_list) == 0: warnings.warn(f"Cannot find the referenced Series Instance UID from series: {series_uid}.") else: download_tcia_series_instance( - series_uid=ref_uuid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False + series_uid=ref_uid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False ) if not os.path.exists(seg_dir): shutil.copytree(seg_first_dir, seg_dir) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 356088ac31..1dd39011ef 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -38,7 +38,18 @@ else: tqdm, has_tqdm = optional_import("tqdm", "4.47.0", min_version, "tqdm") -__all__ = ["check_hash", "download_url", "extractall", "download_and_extract", "get_logger", "SUPPORTED_HASH_TYPES"] +__all__ = [ + "check_hash", + "download_url", + "extractall", + "download_and_extract", + "get_logger", + "SUPPORTED_HASH_TYPES", + "get_tcia_metadata", + "download_tcia_series_instance", + "get_tcia_ref_uid", + "match_tcia_ref_uid_in_study", +] DEFAULT_FMT = "%(asctime)s - %(levelname)s - %(message)s" SUPPORTED_HASH_TYPES = {"md5": hashlib.md5, "sha1": hashlib.sha1, "sha256": hashlib.sha256, "sha512": hashlib.sha512} @@ -398,7 +409,7 @@ def download_tcia_series_instance( check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") -def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): +def get_tcia_ref_uid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): """ Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object. The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from @@ -418,14 +429,14 @@ def get_ref_uuid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E) for elem in ds: if elem.VR == "SQ": for item in elem: - output = get_ref_uuid(item, find_sop) + output = get_tcia_ref_uid(item, find_sop) if elem.tag == ref_uid_tag: return elem.value return output -def match_ref_uid_in_study(study_uid, ref_sop_uid): +def match_tcia_ref_uid_in_study(study_uid, ref_sop_uid): """ Match the SeriesInstanceUID from all series in a study according to the input SOPInstanceUID. diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index c5375188cd..3b100af216 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -400,6 +400,8 @@ class PydicomReader(ImageReader): prune_metadata: whether to prune the saved information in metadata. This argument is used for `get_data` function. If True, only items that are related to the affine matrix will be saved. Default to ``True``. + labels: the labels of the dicom data. If provided, when calling the `_get_seg_data` function, + labels will be refered from it. kwargs: additional args for `pydicom.dcmread` API. more details about available args: https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread If the `get_data` function will be called @@ -414,6 +416,7 @@ def __init__( affine_lps_to_ras: bool = True, swap_ij: bool = True, prune_metadata: bool = True, + labels: Optional[bool] = None, **kwargs, ): super().__init__() @@ -422,6 +425,7 @@ def __init__( self.affine_lps_to_ras = affine_lps_to_ras self.swap_ij = swap_ij self.prune_metadata = prune_metadata + self.labels = labels def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: """ @@ -689,18 +693,26 @@ def _get_seg_data(self, img): """ metadata = self._get_meta_dict(img) - metadata["labels"] = {} + n_classes = len(img.SegmentSequence) + spatial_shape = list(img.pixel_array.shape) + spatial_shape[0] = spatial_shape[0] // n_classes + if self.labels is not None: + metadata["labels"] = self.labels + all_segs = np.zeros([*spatial_shape, len(self.labels)]) + else: + metadata["labels"] = {} + all_segs = np.zeros([*spatial_shape, n_classes]) try: - all_segs = [] for i, (frames, _, description) in enumerate(highdicom.seg.utils.iter_segments(img)): - all_segs.append(np.transpose(frames, [1, 2, 0])) - metadata["labels"][str(i)] = description.SegmentDescription - if len(all_segs) == 1: - all_segs = np.expand_dims(all_segs[0], axis=-1).astype(np.uint8) - else: - all_segs = np.stack(all_segs, axis=-1).astype(np.uint8) - metadata["spatial_shape"] = all_segs.shape[:-1] + class_name = description.SegmentDescription + if class_name not in metadata["labels"].keys(): + metadata["labels"][class_name] = i + class_num = metadata["labels"][class_name] + all_segs[..., class_num] = frames + + all_segs = all_segs.transpose([1, 2, 0, 3]) + metadata["spatial_shape"] = all_segs.shape[1:] except Exception as e: raise NotImplementedError(f"Highdicom cannot read dicom seg data: {img.filename}.") from e diff --git a/tests/test_tciadataset.py b/tests/test_tciadataset.py index 26024ac114..90c403c476 100644 --- a/tests/test_tciadataset.py +++ b/tests/test_tciadataset.py @@ -89,7 +89,6 @@ def _test_dataset(dataset): val_frac=val_frac, ) except RuntimeError as e: - print(str(e)) self.assertTrue(str(e).startswith("Cannot find dataset directory")) From 906b7c290bdef2183a2b4a4a95728849db67072c Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Fri, 15 Jul 2022 20:58:56 +0800 Subject: [PATCH 13/22] modify spatial shape Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 2 +- monai/data/image_reader.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 3d6ae87076..680c00d753 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -411,7 +411,7 @@ class TciaDataset(Randomizable, CacheDataset): transform: transforms to execute operations on input data. for further usage, use `AddChanneld` or `AsChannelFirstd` to convert the shape to [C, H, W, D]. If not specified, `LoadImaged(reader="PydicomReader", keys=["image"])` will be used as the default - transform. In addition, we suggest to set the argument `labels` of `PydicomReader` if segmentations + transform. In addition, we suggest to set the argument `labels` for `PydicomReader` if segmentations are needed to be loaded. The original labels for each dicom series may be different, using this argument is able to unify the format of labels. download: whether to download and extract the dataset, default is False. diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 3b100af216..881a0d6817 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -712,7 +712,7 @@ def _get_seg_data(self, img): all_segs[..., class_num] = frames all_segs = all_segs.transpose([1, 2, 0, 3]) - metadata["spatial_shape"] = all_segs.shape[1:] + metadata["spatial_shape"] = all_segs.shape[:-1] except Exception as e: raise NotImplementedError(f"Highdicom cannot read dicom seg data: {img.filename}.") from e From 91c8d29eba11f77141ee2d4532dd886c9fdb7425 Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Mon, 18 Jul 2022 18:04:55 +0800 Subject: [PATCH 14/22] rename label_dict Signed-off-by: Yiheng Wang --- monai/data/image_reader.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 881a0d6817..1970280145 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -400,8 +400,9 @@ class PydicomReader(ImageReader): prune_metadata: whether to prune the saved information in metadata. This argument is used for `get_data` function. If True, only items that are related to the affine matrix will be saved. Default to ``True``. - labels: the labels of the dicom data. If provided, when calling the `_get_seg_data` function, - labels will be refered from it. + label_dict: label of the dicom data. If provided, it will be used when loading segmentation data. + Keys of the dict are the classes, and values are the corresponding class number. For example: + for TCIA collection "C4KC-KiTS", it can be: {"Kidney": 0, "Renal Tumor": 1}. kwargs: additional args for `pydicom.dcmread` API. more details about available args: https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread If the `get_data` function will be called @@ -416,7 +417,7 @@ def __init__( affine_lps_to_ras: bool = True, swap_ij: bool = True, prune_metadata: bool = True, - labels: Optional[bool] = None, + label_dict: Optional[Dict] = None, **kwargs, ): super().__init__() @@ -425,7 +426,7 @@ def __init__( self.affine_lps_to_ras = affine_lps_to_ras self.swap_ij = swap_ij self.prune_metadata = prune_metadata - self.labels = labels + self.label_dict = label_dict def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: """ @@ -697,9 +698,9 @@ def _get_seg_data(self, img): spatial_shape = list(img.pixel_array.shape) spatial_shape[0] = spatial_shape[0] // n_classes - if self.labels is not None: - metadata["labels"] = self.labels - all_segs = np.zeros([*spatial_shape, len(self.labels)]) + if self.label_dict is not None: + metadata["labels"] = self.label_dict + all_segs = np.zeros([*spatial_shape, len(self.label_dict)]) else: metadata["labels"] = {} all_segs = np.zeros([*spatial_shape, n_classes]) From 5a73d81e5f61842704d7ca3fdf21fa0a220e34ef Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Mon, 18 Jul 2022 20:29:35 +0800 Subject: [PATCH 15/22] add label desc Signed-off-by: Yiheng Wang --- monai/apps/__init__.py | 1 + monai/apps/datasets.py | 32 ++++++++++++--------- monai/apps/tcia/__init__.py | 12 ++++++++ monai/apps/tcia/label_desc.py | 54 +++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 monai/apps/tcia/__init__.py create mode 100644 monai/apps/tcia/label_desc.py diff --git a/monai/apps/__init__.py b/monai/apps/__init__.py index 3df0e95a98..edb541831b 100644 --- a/monai/apps/__init__.py +++ b/monai/apps/__init__.py @@ -11,4 +11,5 @@ from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset, TciaDataset from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar +from .tcia import TCIA_LABEL_DICT from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 680c00d753..7e993bedc7 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -397,7 +397,12 @@ class TciaDataset(Randomizable, CacheDataset): The Highdicom library is used to load dicom data with modality "SEG", but only a part of collections are supoorted, such as: "C4KC-KiTS", "NSCLC-Radiomics", "NSCLC-Radiomics-Interobserver1", " QIN-PROSTATE-Repeatability" and "PROSTATEx". Therefore, if "seg" is included in `keys` of the `LoadImaged` transform and loading some - other collections, errors may be raised. + other collections, errors may be raised. For supported collections, the original "SEG" information may not + always be consistent for each dicom file. Therefore, to avoid creating different format of labels, please use + the `label_dict` argument of `PydicomReader` when calling the `LoadImaged` transform. The prepared label dicts + of collections that are mentioned above is also saved in: `monai.apps.tcia.label_desc`. You can also refer + to the second example bellow. + This class is based on :py:class:`monai.data.CacheDataset` to accelerate the training process. @@ -419,7 +424,7 @@ class TciaDataset(Randomizable, CacheDataset): user can manually copy tar file or dataset folder to the root directory. download_len: number of series that will be downloaded, the value should be larger than 0 or -1, where -1 means all series will be downloaded. Default is -1. - picked_modality: modality that is used to do the first step download. Default is "SEG". + seg_type: modality type of segmentation that is used to do the first step download. Default is "SEG". modality_tag: tag of modality. Default is (0x0008, 0x0060). ref_series_uid_tag: tag of referenced Series Instance UID. Default is (0x0020, 0x000e). ref_sop_uid_tag: tag of referenced SOP Instance UID. Default is (0x0008, 0x1155). @@ -447,15 +452,16 @@ class TciaDataset(Randomizable, CacheDataset): Example:: - # collection is "Pancreatic-CT-CBCT-SEG", picked_modality is "RTSTRUCT" + # collection is "Pancreatic-CT-CBCT-SEG", seg_type is "RTSTRUCT" data = TciaDataset( - root_dir="./", collection="Pancreatic-CT-CBCT-SEG", picked_modality="RTSTRUCT", download=True + root_dir="./", collection="Pancreatic-CT-CBCT-SEG", seg_type="RTSTRUCT", download=True ) - # collection is "C4KC-KiTS", picked_modality is "SEG", and load both images and segmentations + # collection is "C4KC-KiTS", seg_type is "SEG", and load both images and segmentations + from monai.apps import TCIA_LABEL_DICT transform = Compose( [ - LoadImaged(reader="PydicomReader", keys=["image", "seg"]), + LoadImaged(reader="PydicomReader", keys=["image", "seg"], label_dict=TCIA_LABEL_DICT["C4KC-KiTS"]), EnsureChannelFirstd(keys=["image", "seg"]), ResampleToMatchd(keys="image", key_dst="seg"), ] @@ -476,7 +482,7 @@ def __init__( transform: Union[Sequence[Callable], Callable] = (), download: bool = False, download_len: int = -1, - picked_modality: str = "SEG", + seg_type: str = "SEG", modality_tag: Tuple = (0x0008, 0x0060), ref_series_uid_tag: Tuple = (0x0020, 0x000E), ref_sop_uid_tag: Tuple = (0x0008, 0x1155), @@ -505,7 +511,7 @@ def __init__( self.section = section self.val_frac = val_frac - self.picked_modality = picked_modality + self.seg_type = seg_type self.modality_tag = modality_tag self.ref_series_uid_tag = ref_series_uid_tag self.ref_sop_uid_tag = ref_sop_uid_tag @@ -517,12 +523,12 @@ def __init__( self.load_tags = load_tags if download: seg_series_list = get_tcia_metadata( - query=f"getSeries?Collection={collection}&Modality={picked_modality}", attribute="SeriesInstanceUID" + query=f"getSeries?Collection={collection}&Modality={seg_type}", attribute="SeriesInstanceUID" ) if download_len > 0: seg_series_list = seg_series_list[:download_len] if len(seg_series_list) == 0: - raise ValueError(f"Cannot find data with collection: {collection} picked_modality: {picked_modality}") + raise ValueError(f"Cannot find data with collection: {collection} seg_type: {seg_type}") for series_uid in seg_series_list: self._download_series_reference_data(series_uid, download_dir) @@ -581,7 +587,7 @@ def _download_series_reference_data(self, series_uid: str, download_dir: str): series_num = 0 series_num = str(series_num) - seg_dir = os.path.join(download_dir, patient_id, series_num, self.picked_modality.lower()) + seg_dir = os.path.join(download_dir, patient_id, series_num, self.seg_type.lower()) dcm_dir = os.path.join(download_dir, patient_id, series_num, "image") # get ref uuid @@ -589,7 +595,7 @@ def _download_series_reference_data(self, series_uid: str, download_dir: str): for dcm_file in dicom_files: dcm_path = os.path.join(seg_first_dir, dcm_file) ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path) - if ds[self.modality_tag].value == self.picked_modality: + if ds[self.modality_tag].value == self.seg_type: ref_uid = get_tcia_ref_uid( ds, find_sop=False, ref_series_uid_tag=self.ref_series_uid_tag, ref_sop_uid_tag=self.ref_sop_uid_tag ) @@ -620,7 +626,7 @@ def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]: for patient_id in patient_list: series_list = [f.name for f in os.scandir(os.path.join(dataset_dir, patient_id)) if f.is_dir()] for series_num in series_list: - seg_key = self.picked_modality.lower() + seg_key = self.seg_type.lower() image_path = os.path.join(dataset_dir, patient_id, series_num, "image") mask_path = os.path.join(dataset_dir, patient_id, series_num, seg_key) diff --git a/monai/apps/tcia/__init__.py b/monai/apps/tcia/__init__.py new file mode 100644 index 0000000000..f2850ffbc9 --- /dev/null +++ b/monai/apps/tcia/__init__.py @@ -0,0 +1,12 @@ +# 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. + +from .label_desc import TCIA_LABEL_DICT diff --git a/monai/apps/tcia/label_desc.py b/monai/apps/tcia/label_desc.py new file mode 100644 index 0000000000..b0a2b0c064 --- /dev/null +++ b/monai/apps/tcia/label_desc.py @@ -0,0 +1,54 @@ +# 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. + + +from typing import Dict + +__all__ = ["TCIA_LABEL_DICT"] + + +TCIA_LABEL_DICT: Dict[str, Dict[str, int]] = { + "C4KC-KiTS": {"Kidney": 0, "Renal Tumor": 1}, + "NSCLC-Radiomics": { + "Esophagus": 0, + "GTV-1": 1, + "Lungs-Total": 2, + "Spinal-Cord": 3, + "Lung-Left": 4, + "Lung-Right": 5, + "Heart": 6, + }, + "NSCLC-Radiomics-Interobserver1": { + "GTV-1auto-1": 0, + "GTV-1auto-2": 1, + "GTV-1auto-3": 2, + "GTV-1auto-4": 3, + "GTV-1auto-5": 4, + "GTV-1vis-1": 5, + "GTV-1vis-2": 6, + "GTV-1vis-3": 7, + "GTV-1vis-4": 8, + "GTV-1vis-5": 9, + }, + "QIN-PROSTATE-Repeatability": { + "NormalROI_PZ_1": 0, + "TumorROI_PZ_1": 1, + "PeripheralZone": 2, + "WholeGland": 3, + }, + "PROSTATEx": { + "Prostate": 0, + "Peripheral zone of prostate": 1, + "Transition zone of prostate": 2, + "Distal prostatic urethra": 3, + "Anterior fibromuscular stroma of prostate": 4, + }, +} From 9c343829af4cdbe8e7737069aec91ec4909940e6 Mon Sep 17 00:00:00 2001 From: monai-bot Date: Mon, 18 Jul 2022 12:53:21 +0000 Subject: [PATCH 16/22] [MONAI] code formatting Signed-off-by: monai-bot --- monai/apps/tcia/label_desc.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/monai/apps/tcia/label_desc.py b/monai/apps/tcia/label_desc.py index b0a2b0c064..582f83154a 100644 --- a/monai/apps/tcia/label_desc.py +++ b/monai/apps/tcia/label_desc.py @@ -38,12 +38,7 @@ "GTV-1vis-4": 8, "GTV-1vis-5": 9, }, - "QIN-PROSTATE-Repeatability": { - "NormalROI_PZ_1": 0, - "TumorROI_PZ_1": 1, - "PeripheralZone": 2, - "WholeGland": 3, - }, + "QIN-PROSTATE-Repeatability": {"NormalROI_PZ_1": 0, "TumorROI_PZ_1": 1, "PeripheralZone": 2, "WholeGland": 3}, "PROSTATEx": { "Prostate": 0, "Peripheral zone of prostate": 1, From 844534f7aaeb26991f1cecd032e95c2b0f04852e Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 19 Jul 2022 06:57:30 +0100 Subject: [PATCH 17/22] update utils tcia Signed-off-by: Wenqi Li --- monai/apps/__init__.py | 1 - monai/apps/datasets.py | 8 +- monai/apps/tcia/__init__.py | 1 + monai/apps/tcia/utils.py | 150 ++++++++++++++++++++++++++++++++++++ monai/apps/utils.py | 146 +---------------------------------- 5 files changed, 157 insertions(+), 149 deletions(-) create mode 100644 monai/apps/tcia/utils.py diff --git a/monai/apps/__init__.py b/monai/apps/__init__.py index edb541831b..3df0e95a98 100644 --- a/monai/apps/__init__.py +++ b/monai/apps/__init__.py @@ -11,5 +11,4 @@ from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset, TciaDataset from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar -from .tcia import TCIA_LABEL_DICT from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 7e993bedc7..52054b6063 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -18,13 +18,13 @@ import numpy as np -from monai.apps.utils import ( - download_and_extract, +from monai.apps.tcia import ( download_tcia_series_instance, get_tcia_metadata, get_tcia_ref_uid, match_tcia_ref_uid_in_study, ) +from monai.apps.utils import download_and_extract from monai.config.type_definitions import PathLike from monai.data import ( CacheDataset, @@ -458,7 +458,7 @@ class TciaDataset(Randomizable, CacheDataset): ) # collection is "C4KC-KiTS", seg_type is "SEG", and load both images and segmentations - from monai.apps import TCIA_LABEL_DICT + from monai.apps.tcia import TCIA_LABEL_DICT transform = Compose( [ LoadImaged(reader="PydicomReader", keys=["image", "seg"], label_dict=TCIA_LABEL_DICT["C4KC-KiTS"]), @@ -609,7 +609,7 @@ def _download_series_reference_data(self, series_uid: str, download_dir: str): ref_uid = match_tcia_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid) if ref_uid != "": ref_uid_list.append(ref_uid) - if len(ref_uid_list) == 0: + if not ref_uid_list: warnings.warn(f"Cannot find the referenced Series Instance UID from series: {series_uid}.") else: download_tcia_series_instance( diff --git a/monai/apps/tcia/__init__.py b/monai/apps/tcia/__init__.py index f2850ffbc9..bd266704b8 100644 --- a/monai/apps/tcia/__init__.py +++ b/monai/apps/tcia/__init__.py @@ -10,3 +10,4 @@ # limitations under the License. from .label_desc import TCIA_LABEL_DICT +from .utils import download_tcia_series_instance, get_tcia_metadata, get_tcia_ref_uid, match_tcia_ref_uid_in_study diff --git a/monai/apps/tcia/utils.py b/monai/apps/tcia/utils.py new file mode 100644 index 0000000000..01e747cd61 --- /dev/null +++ b/monai/apps/tcia/utils.py @@ -0,0 +1,150 @@ +# 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 os +from typing import List, Optional + +import monai +from monai.config.type_definitions import PathLike +from monai.utils import optional_import + +requests_get, has_requests = optional_import("requests", name="get") +pd, has_pandas = optional_import("pandas") + +__all__ = ["get_tcia_metadata", "download_tcia_series_instance", "get_tcia_ref_uid", "match_tcia_ref_uid_in_study"] + +BASE_URL = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" + + +def get_tcia_metadata(query: str, attribute: Optional[str] = None): + """ + Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset. + + This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata + of objects in the TCIA database. + Please refer to the following link for more details: + https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide + + This function relies on `requests` package. + + Args: + query: queries used to achieve the corresponding metadata. A query is consisted with query name and + query parameters. The format is like: ?&. + For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG" + Please refer to the section of Image Metadata APIs in the link mentioned + above for more details. + attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes + will be ignored. + + """ + + if not has_requests: + raise ValueError("requests package is necessary, please install it.") + full_url = f"{BASE_URL}{query}" + resp = requests_get(full_url) + resp.raise_for_status() + metadata_list: List = [] + if len(resp.text) == 0: + return metadata_list + for d in resp.json(): + if attribute is not None and attribute in d: + metadata_list.append(d[attribute]) + else: + metadata_list.append(d) + + return metadata_list + + +def download_tcia_series_instance( + series_uid: str, + download_dir: PathLike, + output_dir: PathLike, + check_md5: bool = False, + hashes_filename: str = "md5hashes.csv", + progress: bool = True, +): + """ + Download a dicom series from a public The Cancer Imaging Archive (TCIA) dataset. + The downloaded compressed file will be stored in `download_dir`, and the uncompressed folder will be saved + in `output_dir`. + + Args: + series_uid: SeriesInstanceUID of a dicom series. + download_dir: the path to store the downloaded compressed file. The full path of the file is: + `os.path.join(download_dir, f"{series_uid}.zip")`. + output_dir: target directory to save extracted dicom series. + check_md5: whether to download the MD5 hash values as well. If True, will check hash values for all images in + the downloaded dicom series. + hashes_filename: file that contains hashes. + progress: whether to display progress bar. + + """ + query_name = "getImageWithMD5Hash" if check_md5 else "getImage" + download_url = f"{BASE_URL}{query_name}?SeriesInstanceUID={series_uid}" + + monai.apps.download_and_extract( + url=download_url, + filepath=os.path.join(download_dir, f"{series_uid}.zip"), + output_dir=output_dir, + progress=progress, + ) + if check_md5: + if not has_pandas: + raise ValueError("pandas package is necessary, please install it.") + hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename)) + for dcm, md5hash in hashes_df.values: + monai.apps.check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") + + +def get_tcia_ref_uid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): + """ + Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object. + The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from + the data element of the input object. If the data element is a sequence, each dataset within the sequence + will be detected iteratively. The first detected UID will be returned. + + Args: + ds: a pydicom dataset object. + find_sop: whether to achieve the referenced SOP Instance UID. + ref_series_uid_tag: tag of the referenced Series Instance UID. + ref_sop_uid_tag: tag of the referenced SOP Instance UID. + + """ + ref_uid_tag = ref_sop_uid_tag if find_sop else ref_series_uid_tag + output = "" + + for elem in ds: + if elem.VR == "SQ": + for item in elem: + output = get_tcia_ref_uid(item, find_sop) + if elem.tag == ref_uid_tag: + return elem.value + + return output + + +def match_tcia_ref_uid_in_study(study_uid, ref_sop_uid): + """ + Match the SeriesInstanceUID from all series in a study according to the input SOPInstanceUID. + + Args: + study_uid: StudyInstanceUID. + ref_sop_uid: SOPInstanceUID. + + """ + series_list = get_tcia_metadata(query=f"getSeries?StudyInstanceUID={study_uid}", attribute="SeriesInstanceUID") + for series_id in series_list: + sop_id_list = get_tcia_metadata( + query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", attribute="SOPInstanceUID" + ) + if ref_sop_uid in sop_id_list: + return series_id + return "" diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 1dd39011ef..cbfdcd7423 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -19,7 +19,7 @@ import warnings import zipfile from pathlib import Path -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Optional from urllib.error import ContentTooShortError, HTTPError, URLError from urllib.parse import urlparse from urllib.request import urlretrieve @@ -28,8 +28,6 @@ from monai.utils import look_up_option, min_version, optional_import gdown, has_gdown = optional_import("gdown", "4.4") -requests_get, has_requests = optional_import("requests", name="get") -pd, has_pandas = optional_import("pandas") if TYPE_CHECKING: from tqdm import tqdm @@ -38,18 +36,7 @@ else: tqdm, has_tqdm = optional_import("tqdm", "4.47.0", min_version, "tqdm") -__all__ = [ - "check_hash", - "download_url", - "extractall", - "download_and_extract", - "get_logger", - "SUPPORTED_HASH_TYPES", - "get_tcia_metadata", - "download_tcia_series_instance", - "get_tcia_ref_uid", - "match_tcia_ref_uid_in_study", -] +__all__ = ["check_hash", "download_url", "extractall", "download_and_extract", "get_logger", "SUPPORTED_HASH_TYPES"] DEFAULT_FMT = "%(asctime)s - %(levelname)s - %(message)s" SUPPORTED_HASH_TYPES = {"md5": hashlib.md5, "sha1": hashlib.sha1, "sha256": hashlib.sha256, "sha512": hashlib.sha512} @@ -324,132 +311,3 @@ def download_and_extract( filename = filepath or Path(tmp_dir, _basename(url)).resolve() download_url(url=url, filepath=filename, hash_val=hash_val, hash_type=hash_type, progress=progress) extractall(filepath=filename, output_dir=output_dir, file_type=file_type, has_base=has_base) - - -def get_tcia_metadata(query: str, attribute: Optional[str] = None): - """ - Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset. - - This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata - of objects in the TCIA database. - Please refer to the following link for more details: - https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide - - This function relys on `requests` package. - - Args: - query: queries used to achieve the corresponding metadata. A query is consisted with query name and - query parameters. The format is like: ?&. - For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG" - Please refer to the section of Image Metadata APIs in the link mentioned - above for more details. - attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes - will be ignored. - - """ - - if has_requests: - baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" - full_url = baseurl + query - resp = requests_get(full_url) - resp.raise_for_status() - else: - raise ValueError("requests package is necessary, please install it.") - metadata_list: List = [] - if len(resp.text) == 0: - return metadata_list - for d in resp.json(): - if attribute is not None and attribute in d: - metadata_list.append(d[attribute]) - else: - metadata_list.append(d) - - return metadata_list - - -def download_tcia_series_instance( - series_uid: str, - download_dir: PathLike, - output_dir: PathLike, - check_md5: bool = False, - hashes_filename: str = "md5hashes.csv", - progress: bool = True, -): - """ - Download a dicom series from a public The Cancer Imaging Archive (TCIA) dataset. - The downloaded compressed file will be stored in `download_dir`, and the uncompressed folder will be saved - in `output_dir`. - - Args: - series_uid: SeriesInstanceUID of a dicom series. - download_dir: the path to store the downloaded compressed file. The full path of the file is: - `os.path.join(download_dir, f"{series_uid}.zip")`. - output_dir: target directory to save extracted dicom series. - check_md5: whether to download the MD5 hash values as well. If True, will check hash values for all images in - the downloaded dicom series. - hashes_filename: file that contains hashes. - progress: whether to display progress bar. - - """ - query_name = "getImageWithMD5Hash" if check_md5 else "getImage" - baseurl = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" - download_url = f"{baseurl}{query_name}?SeriesInstanceUID={series_uid}" - - download_and_extract( - url=download_url, - filepath=os.path.join(download_dir, f"{series_uid}.zip"), - output_dir=output_dir, - progress=progress, - ) - if check_md5: - if not has_pandas: - raise ValueError("pandas package is necessary, please install it.") - hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename)) - for dcm, md5hash in hashes_df.values: - check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") - - -def get_tcia_ref_uid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): - """ - Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object. - The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from - the data element of the input object. If the data element is a sequence, each dataset within the sequence - will be detected iteratively. The first detected UID will be returned. - - Args: - ds: a pydicom dataset object. - find_sop: whether to achieve the referenced SOP Instance UID. - ref_series_uid_tag: tag of the referenced Series Instance UID. - ref_sop_uid_tag: tag of the referenced SOP Instance UID. - - """ - ref_uid_tag = ref_series_uid_tag if find_sop is False else ref_sop_uid_tag - output = "" - - for elem in ds: - if elem.VR == "SQ": - for item in elem: - output = get_tcia_ref_uid(item, find_sop) - if elem.tag == ref_uid_tag: - return elem.value - - return output - - -def match_tcia_ref_uid_in_study(study_uid, ref_sop_uid): - """ - Match the SeriesInstanceUID from all series in a study according to the input SOPInstanceUID. - - Args: - study_uid: StudyInstanceUID. - ref_sop_uid: SOPInstanceUID. - - """ - series_list = get_tcia_metadata(query=f"getSeries?StudyInstanceUID={study_uid}", attribute="SeriesInstanceUID") - for series_id in series_list: - sop_id_list = get_tcia_metadata( - query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", attribute="SOPInstanceUID" - ) - if ref_sop_uid in sop_id_list: - return series_id - return "" From 146637e0958eebf85f42e36a238a02a3ea7d6898 Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Tue, 19 Jul 2022 14:38:26 +0800 Subject: [PATCH 18/22] use new test dataset to reduce download time Signed-off-by: Yiheng Wang --- monai/apps/datasets.py | 2 +- tests/test_tciadataset.py | 28 +++++++++++++++++----------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index 52054b6063..99df264f90 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -400,7 +400,7 @@ class TciaDataset(Randomizable, CacheDataset): other collections, errors may be raised. For supported collections, the original "SEG" information may not always be consistent for each dicom file. Therefore, to avoid creating different format of labels, please use the `label_dict` argument of `PydicomReader` when calling the `LoadImaged` transform. The prepared label dicts - of collections that are mentioned above is also saved in: `monai.apps.tcia.label_desc`. You can also refer + of collections that are mentioned above is also saved in: `monai.apps.tcia.TCIA_LABEL_DICT`. You can also refer to the second example bellow. diff --git a/tests/test_tciadataset.py b/tests/test_tciadataset.py index 90c403c476..4da897f14b 100644 --- a/tests/test_tciadataset.py +++ b/tests/test_tciadataset.py @@ -14,6 +14,7 @@ import unittest from monai.apps import TciaDataset +from monai.apps.tcia import TCIA_LABEL_DICT from monai.data import MetaTensor from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd from tests.utils import skip_if_downloading_fails, skip_if_quick @@ -23,24 +24,25 @@ class TestTciaDataset(unittest.TestCase): @skip_if_quick def test_values(self): testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") + download_len = 1 + val_frac = 1.0 + collection = "QIN-PROSTATE-Repeatability" + transform = Compose( [ - LoadImaged(keys=["image", "seg"], reader="PydicomReader"), + LoadImaged(keys=["image", "seg"], reader="PydicomReader", label_dict=TCIA_LABEL_DICT[collection]), AddChanneld(keys="image"), ScaleIntensityd(keys="image"), ] ) - download_len = 2 - val_frac = 0.5 - collection = "C4KC-KiTS" def _test_dataset(dataset): self.assertEqual(len(dataset), int(download_len * val_frac)) self.assertTrue("image" in dataset[0]) self.assertTrue("seg" in dataset[0]) self.assertTrue(isinstance(dataset[0]["image"], MetaTensor)) - self.assertTupleEqual(dataset[0]["image"].shape, (1, 512, 512, 61)) - self.assertTupleEqual(dataset[0]["seg"].shape, (512, 512, 61, 2)) + self.assertTupleEqual(dataset[0]["image"].shape, (1, 256, 256, 24)) + self.assertTupleEqual(dataset[0]["seg"].shape, (256, 256, 24, 4)) with skip_if_downloading_fails(): data = TciaDataset( @@ -64,19 +66,23 @@ def _test_dataset(dataset): val_frac=val_frac, ) _test_dataset(data) - self.assertTrue(data[0]["image"].meta["filename_or_obj"].endswith("C4KC-KiTS/KiTS-00007/300/image")) - self.assertTrue(data[0]["seg"].meta["filename_or_obj"].endswith("C4KC-KiTS/KiTS-00007/300/seg")) + self.assertTrue( + data[0]["image"].meta["filename_or_obj"].endswith("QIN-PROSTATE-Repeatability/PCAMPMRI-00015/1901/image") + ) + self.assertTrue( + data[0]["seg"].meta["filename_or_obj"].endswith("QIN-PROSTATE-Repeatability/PCAMPMRI-00015/1901/seg") + ) # test validation without transforms data = TciaDataset( root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac ) - self.assertTupleEqual(data[0]["image"].shape, (512, 512, 61)) + self.assertTupleEqual(data[0]["image"].shape, (256, 256, 24)) self.assertEqual(len(data), int(download_len * val_frac)) data = TciaDataset( root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac ) - self.assertTupleEqual(data[0]["image"].shape, (512, 512, 61)) - self.assertEqual(len(data), int(download_len - download_len * val_frac)) + self.assertTupleEqual(data[0]["image"].shape, (256, 256, 24)) + self.assertEqual(len(data), download_len) shutil.rmtree(os.path.join(testing_dir, collection)) try: From 5fd71456ae49e301610f5203b927bb1f429db3c6 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 19 Jul 2022 08:39:43 +0100 Subject: [PATCH 19/22] trying to fix mypy errors Signed-off-by: Wenqi Li --- monai/apps/tcia/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/tcia/utils.py b/monai/apps/tcia/utils.py index 01e747cd61..ad95596223 100644 --- a/monai/apps/tcia/utils.py +++ b/monai/apps/tcia/utils.py @@ -90,7 +90,7 @@ def download_tcia_series_instance( query_name = "getImageWithMD5Hash" if check_md5 else "getImage" download_url = f"{BASE_URL}{query_name}?SeriesInstanceUID={series_uid}" - monai.apps.download_and_extract( + monai.apps.utils.download_and_extract( url=download_url, filepath=os.path.join(download_dir, f"{series_uid}.zip"), output_dir=output_dir, @@ -101,7 +101,7 @@ def download_tcia_series_instance( raise ValueError("pandas package is necessary, please install it.") hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename)) for dcm, md5hash in hashes_df.values: - monai.apps.check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") + monai.apps.utils.check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5") def get_tcia_ref_uid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)): From 8819b48f8c1633ade118d1eff965542cf16b319e Mon Sep 17 00:00:00 2001 From: Yiheng Wang Date: Tue, 19 Jul 2022 23:45:47 +0800 Subject: [PATCH 20/22] remove highdicom Signed-off-by: Yiheng Wang --- monai/data/image_reader.py | 67 ++++++++++++++++++++++++++++---------- requirements-dev.txt | 1 - 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 1970280145..64c9aa0afe 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -15,7 +15,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple, Union import numpy as np from torch.utils.data._utils.collate import np_str_obj_array_pattern @@ -38,14 +38,13 @@ from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_nrrd = has_itk = has_nib = has_pil = has_pydicom = has_highdicom = True + has_nrrd = has_itk = has_nib = has_pil = has_pydicom = True else: itk, has_itk = optional_import("itk", allow_namespace_pkg=True) nib, has_nib = optional_import("nibabel") Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image") PILImage, has_pil = optional_import("PIL.Image") pydicom, has_pydicom = optional_import("pydicom") - highdicom, has_highdicom = optional_import("highdicom") nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True) OpenSlide, _ = optional_import("openslide", name="OpenSlide") @@ -374,14 +373,16 @@ def _get_array_data(self, img): @require_pkg(pkg_name="pydicom") -@require_pkg(pkg_name="highdicom") class PydicomReader(ImageReader): """ Load medical images based on Pydicom library. All the supported image formats can be found at: https://dicom.nema.org/medical/dicom/current/output/chtml/part10/chapter_7.html - For dicom data with modality "SEG", Highdicom will be used. + PydicomReader is also able to load segmentations, if a dicom file contains tag: `SegmentSequence`, the reader + will consider it as segmentation data, and to load it successfully, `PerFrameFunctionalGroupsSequence` is required + for dicom file, and for each frame of dicom file, `SegmentIdentificationSequence` is required. + This method refers to the Highdicom library. This class refers to: https://nipy.org/nibabel/dicom/dicom_orientation.html#dicom-affine-formula @@ -437,7 +438,7 @@ def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: if a list of files, verify all the suffixes. """ - return has_pydicom and has_highdicom + return has_pydicom def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): """ @@ -684,6 +685,38 @@ def _get_affine(self, metadata: Dict, lps_to_ras: bool = True): affine = orientation_ras_lps(affine) return affine + def _get_frame_data(self, img) -> Iterator: + """ + yield frames and description from the segmentation image. + This function refers to Highdicom: + https://github.com/herrmannlab/highdicom/blob/master/src/highdicom/seg/utils.py + + Aegs: + img: a Pydicom dataset object that has attribute "SegmentSequence". + + """ + + if not hasattr(img, "PerFrameFunctionalGroupsSequence"): + raise NotImplementedError( + f"To read dicom seg: {img.filename}, 'PerFrameFunctionalGroupsSequence' is required." + ) + + frame_seg_nums = [] + for f in img.PerFrameFunctionalGroupsSequence: + if not hasattr(f, "SegmentIdentificationSequence"): + raise NotImplementedError( + f"To read dicom seg: {img.filename}, 'SegmentIdentificationSequence' is required for each frame." + ) + frame_seg_nums.append(int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber)) + + frame_seg_nums_arr = np.array(frame_seg_nums) + + seg_descriptions = {int(f.SegmentNumber): f for f in img.SegmentSequence} + + for i in np.unique(frame_seg_nums_arr): + indices = np.where(frame_seg_nums_arr == i)[0] + yield (img.pixel_array[indices, ...], seg_descriptions[i]) + def _get_seg_data(self, img): """ Get the array data and metadata of the segmentation image. @@ -704,18 +737,16 @@ def _get_seg_data(self, img): else: metadata["labels"] = {} all_segs = np.zeros([*spatial_shape, n_classes]) - try: - for i, (frames, _, description) in enumerate(highdicom.seg.utils.iter_segments(img)): - class_name = description.SegmentDescription - if class_name not in metadata["labels"].keys(): - metadata["labels"][class_name] = i - class_num = metadata["labels"][class_name] - all_segs[..., class_num] = frames - - all_segs = all_segs.transpose([1, 2, 0, 3]) - metadata["spatial_shape"] = all_segs.shape[:-1] - except Exception as e: - raise NotImplementedError(f"Highdicom cannot read dicom seg data: {img.filename}.") from e + + for i, (frames, description) in enumerate(self._get_frame_data(img)): + class_name = description.SegmentDescription + if class_name not in metadata["labels"].keys(): + metadata["labels"][class_name] = i + class_num = metadata["labels"][class_name] + all_segs[..., class_num] = frames + + all_segs = all_segs.transpose([1, 2, 0, 3]) + metadata["spatial_shape"] = all_segs.shape[:-1] if "52009229" in metadata.keys(): shared_func_group_seq = metadata["52009229"]["Value"][0] diff --git a/requirements-dev.txt b/requirements-dev.txt index b596240746..52087b1648 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -50,5 +50,4 @@ jsonschema pynrrd pre-commit pydicom -highdicom h5py From f94cee6b18655da1dd80e6cd2bb09a5d53ebbf1c Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 19 Jul 2022 17:02:03 +0100 Subject: [PATCH 21/22] update license Signed-off-by: Wenqi Li --- monai/data/image_reader.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 64c9aa0afe..e666720606 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -688,10 +688,36 @@ def _get_affine(self, metadata: Dict, lps_to_ras: bool = True): def _get_frame_data(self, img) -> Iterator: """ yield frames and description from the segmentation image. - This function refers to Highdicom: - https://github.com/herrmannlab/highdicom/blob/master/src/highdicom/seg/utils.py + This function is adapted from Highdicom: + https://github.com/herrmannlab/highdicom/blob/v0.18.2/src/highdicom/seg/utils.py + + which has the following license... + + # ========================================================================= + # https://github.com/herrmannlab/highdicom/blob/v0.18.2/LICENSE + # + # Copyright 2020 MGH Computational Pathology + # Permission is hereby granted, free of charge, to any person obtaining a + # copy of this software and associated documentation files (the + # "Software"), to deal in the Software without restriction, including + # without limitation the rights to use, copy, modify, merge, publish, + # distribute, sublicense, and/or sell copies of the Software, and to + # permit persons to whom the Software is furnished to do so, subject to + # the following conditions: + # The above copyright notice and this permission notice shall be included + # in all copies or substantial portions of the Software. + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + # ========================================================================= + + (https://github.com/herrmannlab/highdicom/issues/188) - Aegs: + Args: img: a Pydicom dataset object that has attribute "SegmentSequence". """ From 17598284e84a53b008f187e209ce5bcb2d315c3b Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 19 Jul 2022 18:54:51 +0100 Subject: [PATCH 22/22] fixes mypy updates Signed-off-by: Wenqi Li --- runtests.sh | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/runtests.sh b/runtests.sh index 9e6ef3d0e1..78f1672745 100755 --- a/runtests.sh +++ b/runtests.sh @@ -593,13 +593,7 @@ then install_deps fi ${cmdPrefix}${PY_EXE} -m mypy --version - - if [ $doDryRun = true ] - then - ${cmdPrefix}MYPYPATH="$(pwd)"/monai ${PY_EXE} -m mypy "$(pwd)" - else - MYPYPATH="$(pwd)"/monai ${PY_EXE} -m mypy "$(pwd)" # cmdPrefix does not work with MYPYPATH - fi + ${cmdPrefix}${PY_EXE} -m mypy "$(pwd)" mypy_status=$? if [ ${mypy_status} -ne 0 ]