Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature to read RT Dose files #35

Merged
merged 2 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyradise/data/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .annotator import Annotator
from .image import Image, ImageProperties, IntensityImage, SegmentationImage
from .image import Image, ImageProperties, IntensityImage, DoseImage, SegmentationImage
from .modality import Modality
from .organ import Organ, OrganAnnotatorCombination
from .subject import Subject
Expand Down
30 changes: 29 additions & 1 deletion pyradise/data/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

TransformInfo = TypeVar("TransformInfo")

__all__ = ["Image", "IntensityImage", "SegmentationImage", "ImageProperties"]
__all__ = ["Image", "IntensityImage", "SegmentationImage", "DoseImage", "ImageProperties"]


class ImageProperties:
Expand Down Expand Up @@ -744,6 +744,34 @@ def __str__(self) -> str:
return f"SegmentationImage: {self.organ.get_name()} / {self.annotator.get_name()}"


class DoseImage(IntensityImage):
"""A dose image class to specialize for properties of RTDose volumes.
Args:
image (Union[sitk.Image, itk.Image]): The image data as :class:`itk.Image` or :class:`SimpleITK.Image`.
modality (Union[Modality, str]): The image :class:`~pyradise.data.modality.Modality` or the modality's name.
"""

def __init__(self, image: Union[sitk.Image, itk.Image], modality: Union[Modality, str], scaling_value: float) -> None:

# Handle situation where RTDose intensity images are 4D - with a singleton dimensions.
if image.GetDimension() == 4:
if image.GetSize()[0] == 1:
image = image[0, :, :, :]
elif image.GetSize()[1] == 1:
image = image[:, 0, :, :]
elif image.GetSize()[2] == 1:
image = image[:, :, 0, :]
elif image.GetSize()[3] == 1:
image = image[:, :, :, 0]

# See here for why scaling is needed: https://dicom.innolitics.com/ciods/rt-dose/rt-dose/3004000e
image = sitk.Cast(image, sitk.sitkFloat32)
image = image * scaling_value

super().__init__(image, modality)


# Preparation for next release
# class DoseImage(Image):
# """A dose image class including a :class:`~pyradise.data.taping.TransformTape`.
Expand Down
4 changes: 2 additions & 2 deletions pyradise/data/subject.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from warnings import warn

from .annotator import Annotator
from .image import Image, IntensityImage, SegmentationImage
from .image import Image, IntensityImage, DoseImage, SegmentationImage
from .modality import Modality
from .organ import Organ

Expand Down Expand Up @@ -385,7 +385,7 @@ def get_images_by_type(self, image_type: type) -> List[Image]:
Returns:
List[Image]: A list of all images of the specified type.
"""
if image_type == IntensityImage:
if image_type == IntensityImage or image_type == DoseImage:
return self.intensity_images
elif image_type == SegmentationImage:
return self.segmentation_images
Expand Down
57 changes: 52 additions & 5 deletions pyradise/fileio/crawling.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from .modality_config import ModalityConfiguration
from .series_info import (DicomSeriesImageInfo, DicomSeriesInfo,
DicomSeriesRegistrationInfo, DicomSeriesRTSSInfo,
FileSeriesInfo, IntensityFileSeriesInfo,
SegmentationFileSeriesInfo)
DicomSeriesDoseInfo, FileSeriesInfo,
IntensityFileSeriesInfo, SegmentationFileSeriesInfo)

__all__ = ["Crawler", "SubjectFileCrawler", "DatasetFileCrawler", "SubjectDicomCrawler", "DatasetDicomCrawler"]

Expand Down Expand Up @@ -451,6 +451,28 @@ def _get_rtss_files(paths: Tuple[str, ...]) -> Tuple[str, ...]:

return tuple(rtss_files)


@staticmethod
def _get_rtdose_files(paths: Tuple[str, ...]) -> Tuple[str, ...]:
"""Get all DICOM RTDOSE files in the subject directory.
Args:
paths (Tuple[str, ...]): The DICOM file paths to check if they specify a DICOM RTDOSE file.
Returns:
Tuple[str, ...]: The DICOM RTDOSE file paths.
"""
valid_sop_class_uid = "1.2.840.10008.5.1.4.1.1.481.2" # RT Structure Set Storage

rtdose_files = []
for path in paths:
dataset = load_dataset_tag(path, (Tag(0x0008, 0x0016),))

if dataset.get("SOPClassUID", None) == valid_sop_class_uid:
rtdose_files.append(path)

return tuple(rtdose_files)

@staticmethod
def _generate_image_infos(image_paths: Tuple[Tuple[str, ...], ...]) -> Tuple[DicomSeriesImageInfo]:
"""Generate the :class:`~pyradise.fileio.series_info.DicomSeriesImageInfo` entries for the DICOM file paths
Expand Down Expand Up @@ -504,7 +526,7 @@ def _generate_rtss_info(rtss_paths: Tuple[str, ...]) -> Tuple[DicomSeriesRTSSInf
rtss_paths (Tuple[str, ...]): The DICOM RTSS file paths.
Returns:
Tuple[DicomSeriesRTStructureSetInfo, ...]: AThe retrieved
Tuple[DicomSeriesRTStructureSetInfo, ...]: The retrieved
:class:`~pyradise.fileio.series_info.DicomSeriesRTStructureSetInfo` entries.
"""
infos = []
Expand All @@ -515,6 +537,27 @@ def _generate_rtss_info(rtss_paths: Tuple[str, ...]) -> Tuple[DicomSeriesRTSSInf

return tuple(infos)


@staticmethod
def _generate_rtdose_info(rtdose_paths: Tuple[str, ...]) -> Tuple[DicomSeriesImageInfo]:
"""Generate the :class:`~pyradise.fileio.series_info.DicomSeriesImageInfo` entries for the DICOM file
paths specified.
Args:
rtdose_paths (Tuple[str, ...]): The DICOM RTDOSE file paths.
Returns:
Tuple[DicomSeriesImageInfo, ...]: The retrieved
:class:`~pyradise.fileio.series_info.DicomSeriesImageInfo` entries.
"""
infos = []

for path in rtdose_paths:
rtdose_info = DicomSeriesDoseInfo(path)
infos.append(rtdose_info)

return tuple(infos)

def _export_modality_config(self, infos: Tuple[DicomSeriesInfo, ...]) -> None:
"""Export the retrieved :class:`~pyradise.fileio.modality_config.ModalityConfiguration` to a file.
Expand Down Expand Up @@ -647,16 +690,20 @@ def execute(self) -> Tuple[DicomSeriesInfo, ...]:
remaining_paths = tuple(set(remaining_paths) - set(registration_paths))

rtss_paths = self._get_rtss_files(remaining_paths)
remaining_paths = tuple(set(remaining_paths) - set(rtss_paths))

rtdose_paths = self._get_rtdose_files(remaining_paths)

# generate the series infos
image_infos = self._generate_image_infos(image_paths)
registration_infos = self._generate_registration_infos(registration_paths, image_infos)
rtss_infos = self._generate_rtss_info(rtss_paths)
rtdose_infos = self._generate_rtdose_info(rtdose_paths)

# apply the modality config and write it to disk if requested
self._apply_modality_config(image_infos)
self._apply_modality_config(image_infos + rtdose_infos)

return image_infos + registration_infos + rtss_infos
return image_infos + registration_infos + rtss_infos + rtdose_infos


class DatasetDicomCrawler(Crawler):
Expand Down
14 changes: 10 additions & 4 deletions pyradise/fileio/dicom_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@
from pydicom.uid import (PYDICOM_IMPLEMENTATION_UID, ImplicitVRLittleEndian,
generate_uid)

from pyradise.data import (IntensityImage, Modality, Organ, SegmentationImage,
from pyradise.data import (IntensityImage, Modality, Organ, SegmentationImage, DoseImage,
Subject, str_to_modality)
from pyradise.utils import (chunkify, convert_to_itk_image,
get_slice_direction, get_slice_position,
get_spacing_between_slices, load_dataset,
load_dataset_tag, load_datasets)

from .series_info import (DicomSeriesImageInfo, DicomSeriesRegistrationInfo,
from .series_info import (DicomSeriesImageInfo, DicomSeriesDoseInfo, DicomSeriesRegistrationInfo,
DicomSeriesRTSSInfo, RegistrationInfo, SeriesInfo)

__all__ = [
Expand Down Expand Up @@ -2594,7 +2594,10 @@ def convert(self) -> Tuple[IntensityImage, ...]:

# if no registration info is available, the image is added as is
if reg_info is None:
image_ = IntensityImage(image, info.modality)
if isinstance(info, DicomSeriesDoseInfo):
image_ = DoseImage(image, info.modality, info.scaling_value)
else:
image_ = IntensityImage(image, info.modality)
image_.add_data({"SeriesInstanceUID": info.series_instance_uid})
images.append(image_)

Expand All @@ -2610,7 +2613,10 @@ def convert(self) -> Tuple[IntensityImage, ...]:
)

image = self._transform_image(image, reg_info.transform, is_intensity=True)
image_ = IntensityImage(image, info.modality)
if isinstance(info, DicomSeriesDoseInfo):
image_ = DoseImage(image, info.modality, info.scaling_value)
else:
image_ = IntensityImage(image, info.modality)
image_.add_data({"SeriesInstanceUID": info.series_instance_uid})
images.append(image_)

Expand Down
18 changes: 18 additions & 0 deletions pyradise/fileio/series_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"SegmentationFileSeriesInfo",
"DicomSeriesInfo",
"DicomSeriesImageInfo",
"DicomSeriesDoseInfo",
"DicomSeriesRegistrationInfo",
"DicomSeriesRTSSInfo",
"ReferenceInfo",
Expand Down Expand Up @@ -417,6 +418,23 @@ def update(self) -> None:
self._is_updated = True


class DicomSeriesDoseInfo(DicomSeriesImageInfo):
"""A :class:`DicomSeriesDoseInfo` class for DICOM Dose images. In addition to the information provided by the
:class:`DicomSeriesImageInfo` class, this class contains a flag to indicate the image is a Dose volume.
Args:
paths (Tuple[str, ...]): The paths to the DICOM image files to load.
"""

def __init__(self, paths: Tuple[str, ...]) -> None:
super().__init__(paths)
scaling_tag = [Tag(0x3004,0x000E)]
dataset = load_dataset_tag(self.path[0], scaling_tag)

self.scaling_value = str(dataset.get("DoseGridScaling", 1.0))
self.is_dose_image = True


# noinspection PyUnresolvedReferences
@dataclass
class ReferenceInfo:
Expand Down