From 0dda85d599a61403f303a62d582fdb0443be9243 Mon Sep 17 00:00:00 2001 From: Pablo Hernandez-Cerdan Date: Fri, 27 Oct 2023 02:00:36 +0200 Subject: [PATCH] Add dicom_utils, test upload_annotation --- mdai_utils/dicom_utils.py | 60 ++++++++++++++++++++++++++++++++ mdai_utils/upload_annotations.py | 5 ++- requirements.in | 1 + requirements.txt | 2 ++ tests/conftest.py | 4 +-- tests/test_parameters.json | 2 +- tests/upload_test.py | 31 +++++++++++++---- 7 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 mdai_utils/dicom_utils.py diff --git a/mdai_utils/dicom_utils.py b/mdai_utils/dicom_utils.py new file mode 100644 index 0000000..24e66eb --- /dev/null +++ b/mdai_utils/dicom_utils.py @@ -0,0 +1,60 @@ +import json +import os +from pathlib import Path +from shutil import copyfile +from tempfile import TemporaryDirectory +from typing import NamedTuple + +import SimpleITK as sitk + + +def file_list_from_directory(directory, extension=".dcm"): + """Return a list of files with the given extension in the given directory.""" + return [str(f) for f in Path(directory).glob(f"*{extension}")] + + +def write_image_and_metadata(image, metadata, output_image_filename): + """Write the image to a file, along with the metadata.""" + sitk.WriteImage(image, output_image_filename) + p = Path(output_image_filename) + metadata_filename = p.parent / (p.stem + "_SOPInstanceUIDs.json") + with open(metadata_filename, "w") as f: + json.dump(metadata, f, indent=2) + + +class ImageAndMetadata(NamedTuple): + image: sitk.Image + metadata: dict + + +def read_dicoms_into_volume(valid_dcm_file_list) -> ImageAndMetadata: + """Convert a list of DICOM files to a image volume. Also returns metadata + (SOPInstanceUID) for each slice in the volume. + + The metadata generated by this function is a map from slice index to + SOPInstanceUID. And can be used to upload annotations from non-dicom formats + back to MDai, which requires the SOPInstanceUID to identify the slice. + + PRECONDTIION: the input dicom files are in the same series, they are + valid DICOM files, with no SCOUTS or other special slices. + """ + # copy to temp directory in case multiple series occupy the same directory + with TemporaryDirectory() as temp_dir: + src_file_lookup = {} + for src_file in valid_dcm_file_list: + dst_file = os.path.join(temp_dir, os.path.basename(src_file)) + copyfile(src_file, dst_file) + src_file_lookup[dst_file] = src_file + reader = sitk.ImageSeriesReader() + # allow for reading of metadata + reader.SetMetaDataDictionaryArrayUpdate(True) + dicom_names = reader.GetGDCMSeriesFileNames(temp_dir) + reader.SetFileNames(dicom_names) + image = reader.Execute() + uids = [reader.GetMetaData(idx, "0008|0018") for idx in range(len(dicom_names))] + # keep info about slice, original file, and SOPInstanceUID + metadata = { + slice_idx: {"dicom_file": src_file_lookup[fn], "SOPInstanceUID": uid} + for slice_idx, (fn, uid) in enumerate(zip(dicom_names, uids)) + } + return ImageAndMetadata(image=image, metadata=metadata) diff --git a/mdai_utils/upload_annotations.py b/mdai_utils/upload_annotations.py index f931428..562dbbc 100644 --- a/mdai_utils/upload_annotations.py +++ b/mdai_utils/upload_annotations.py @@ -69,8 +69,11 @@ def upload_image_annotation_slice( failed_annotations (list): List of failed annotations. If empty, all annotations were uploaded successfully. """ data_np = read_data_image(segmentation_image_path) + if data_np.ndim == 3: + # The perpendicular dimension is at index 0 in the numpy array. + data_np = data_np.squeeze(0) return upload_data_annotation_slice( - data_np=data_np.T, + data_np=data_np, mdai_client=mdai_client, mdai_project_id=mdai_project_id, mdai_dataset_id=mdai_dataset_id, diff --git a/requirements.in b/requirements.in index 67697f0..3adf845 100644 --- a/requirements.in +++ b/requirements.in @@ -3,3 +3,4 @@ pydicom>=2.4.3 itk>=5.3.0 bidict>=0.22.1 opencv-python>=4.8.1.78 +simpleITK>=2.3.0 diff --git a/requirements.txt b/requirements.txt index 68e1261..896cf14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -120,6 +120,8 @@ scipy==1.11.3 # via # dicom2nifti # scikit-image +simpleitk==2.3.0 + # via -r requirements.in six==1.16.0 # via # python-dateutil diff --git a/tests/conftest.py b/tests/conftest.py index cbc5ce9..53e2985 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,11 +56,11 @@ def mdai_setup( with open(parameters_file) as f: parameters = json.load(f) - fixture_dir = _current_dir / "fixtures" + fixtures_dir = _current_dir / "fixtures" mdai_domain = parameters.get("mdai_domain") or "md.ai" mdai_client = mdai.Client(domain=mdai_domain, access_token=token) return { "mdai_client": mdai_client, "parameters": parameters, - "fixture_dir": fixture_dir, + "fixtures_dir": fixtures_dir, } diff --git a/tests/test_parameters.json b/tests/test_parameters.json index 9c111c7..37ead90 100644 --- a/tests/test_parameters.json +++ b/tests/test_parameters.json @@ -10,6 +10,6 @@ "mdai_annotations_only": true, "mdai_no_fixing_metadata": false, "labels": [ - "mylabel", + "mylabel" ] } diff --git a/tests/upload_test.py b/tests/upload_test.py index 5a69259..9ebbe09 100644 --- a/tests/upload_test.py +++ b/tests/upload_test.py @@ -1,5 +1,6 @@ import pytest +from mdai_utils.upload_annotations import upload_image_annotation_slice from mdai_utils.upload_dataset import upload_dataset @@ -13,11 +14,11 @@ def test_pytest_fixture(mdai_setup): reason="Only need to upload once. run pytest tests with --upload-only to run it." ) def test_upload_dataset(mdai_setup): - mdai_parameters = mdai_setup["parameters"] - mdai_dataset_id = mdai_parameters.get("mdai_dataset_id") - fixture_dir = mdai_setup["fixture_dir"] - dicom_dir = fixture_dir / "humanct_0002_1000_1004" - assert fixture_dir.exists() + parameters = mdai_setup["parameters"] + mdai_dataset_id = parameters.get("mdai_dataset_id") + fixtures_dir = mdai_setup["fixtures_dir"] + dicom_dir = fixtures_dir / "humanct_0002_1000_1004" + assert dicom_dir.exists() completed_process = upload_dataset(mdai_dataset_id, dicom_dir) process_message = completed_process.stdout.strip() print(process_message) @@ -26,4 +27,22 @@ def test_upload_dataset(mdai_setup): def test_upload_annotation(mdai_setup): - mdai_setup["parameters"] + parameters = mdai_setup["parameters"] + fixtures_dir = mdai_setup["fixtures_dir"] + mdai_client = mdai_setup["mdai_client"] + # sop_instance_uid can be acquired from mdai, or from the metadata generated + # by the function dicom_utils.read_dicoms_into_volume. + sop_instance_uid = "1.2.826.0.1.3680043.2.1125.1.75064541463040.2005072610414630768" + mdai_label_ids = parameters.get("mdai_label_ids") + labels_to_upload = parameters.get("labels") + label_id = mdai_label_ids.get(labels_to_upload[0]) + + failed_annotations = upload_image_annotation_slice( + segmentation_image_path=fixtures_dir / "humanct_0002_1000_seg.nii.gz", + sop_instance_uid=sop_instance_uid, + mdai_client=mdai_client, + mdai_project_id=parameters.get("mdai_project_id"), + mdai_dataset_id=parameters.get("mdai_dataset_id"), + mdai_label_id=label_id, + ) + assert len(failed_annotations) == 0