Skip to content

Data and I/O API

Unified loaders

data_io

Unified I/O module for radiotherapy data.

This module provides high-level functions for loading radiotherapy data from various sources (DICOM, NIfTI) with automatic format detection and intelligent structure organization.

The API is organized in layers: 1. Low-level: Format-specific readers (dicom_io, nifti_io) 2. Mid-level: Type-specific loaders (load_volume, load_structure, etc.) 3. High-level: Auto-detecting folder loaders (load_from_folder, load_structure_set)

Functions:

detect_folder_format
detect_folder_format(folder_path: Union[str, Path]) -> str

Detect the data format in a folder (DICOM or NIfTI).

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder to inspect

required

Returns:

Type Description
str

'dicom' if DICOM files found, 'nifti' if NIfTI files found, 'unknown' otherwise

Source code in src/dosemetrics/io/data_io.py
def detect_folder_format(folder_path: Union[str, Path]) -> str:
    """
    Detect the data format in a folder (DICOM or NIfTI).

    Args:
        folder_path: Path to folder to inspect

    Returns:
        'dicom' if DICOM files found, 'nifti' if NIfTI files found, 'unknown' otherwise
    """
    folder_path = Path(folder_path)

    if not folder_path.exists():
        return 'unknown'

    # Check for DICOM files directly
    if list(folder_path.rglob('*.dcm')):
        return 'dicom'

    # Check for NIfTI files
    if list(folder_path.rglob('*.nii.gz')) or list(folder_path.rglob('*.nii')):
        return 'nifti'

    return 'unknown'
load_from_folder
load_from_folder(folder_path: Union[str, Path], format: Optional[str] = None, **kwargs: object) -> Union[StructureSet, Dict[str, Union[np.ndarray, Dict, Tuple]]]

Load radiotherapy data from a folder, auto-detecting format.

This is the highest-level function for loading data. It automatically detects whether the folder contains DICOM or NIfTI data and loads accordingly.

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing data

required
format Optional[str]

Force specific format ('dicom' or 'nifti'). If None, auto-detects.

None
**kwargs object

Additional arguments passed to format-specific loaders

{}

Returns:

Type Description
Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

A StructureSet by default. Pass return_as_structureset=False to

Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

receive a format-specific dictionary of arrays and metadata.

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

ValueError

If format cannot be determined

Source code in src/dosemetrics/io/data_io.py
def load_from_folder(
    folder_path: Union[str, Path],
    format: Optional[str] = None,
    **kwargs: object,
) -> Union["StructureSet", Dict[str, Union[np.ndarray, Dict, Tuple]]]:
    """
    Load radiotherapy data from a folder, auto-detecting format.

    This is the highest-level function for loading data. It automatically detects
    whether the folder contains DICOM or NIfTI data and loads accordingly.

    Args:
        folder_path: Path to folder containing data
        format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.
        **kwargs: Additional arguments passed to format-specific loaders

    Returns:
        A StructureSet by default. Pass ``return_as_structureset=False`` to
        receive a format-specific dictionary of arrays and metadata.

    Raises:
        FileNotFoundError: If folder doesn't exist
        ValueError: If format cannot be determined
    """
    folder_path = Path(folder_path)

    if not folder_path.exists():
        raise FileNotFoundError(f"Folder not found: {folder_path}")

    # Auto-detect format if not specified
    if format is None:
        format = detect_folder_format(folder_path)

    if format == 'dicom':
        return dicom_io.load_dicom_folder(folder_path, **kwargs)
    elif format == 'nifti':
        return nifti_io.load_nifti_folder(folder_path, **kwargs)
    else:
        raise ValueError(
            f"Unknown format in {folder_path}. "
            f"Folder should contain either DICOM files or NIfTI files."
        )
load_structure_set
load_structure_set(folder_path: Union[str, Path], format: Optional[str] = None, name: Optional[str] = None, structure_type_mapping: Optional[Dict[str, StructureType]] = None, **kwargs: object) -> StructureSet

Load a complete StructureSet from a folder, auto-detecting format.

This is the primary high-level function for loading radiotherapy data as a unified StructureSet object. It handles both DICOM and NIfTI formats.

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing data

required
format Optional[str]

Force specific format ('dicom' or 'nifti'). If None, auto-detects.

None
name Optional[str]

Name for the structure set. If None, uses folder name.

None
structure_type_mapping Optional[Dict[str, StructureType]]

Optional dict mapping structure names to StructureType

None
**kwargs object

Additional arguments passed to format-specific loaders For NIfTI: dose_filename (default: "Dose.nii.gz") For DICOM: dose_file_name (specific dose file to use)

{}

Returns:

Type Description
StructureSet

StructureSet object with loaded structures. Dose is loaded separately.

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

ValueError

If format cannot be determined or no structures found

Examples:

>>> # Load from DICOM folder
>>> structure_set = load_structure_set('path/to/dicom_folder')
>>> # Load from NIfTI folder with custom dose filename
>>> structure_set = load_structure_set('path/to/nifti_folder', 
...                                     dose_filename='dose_distribution.nii.gz')
>>> # Force format and provide structure types
>>> type_mapping = {'Liver': StructureType.OAR, 'PTV': StructureType.TARGET}
>>> structure_set = load_structure_set('path/to/folder',
...                                     format='nifti',
...                                     structure_type_mapping=type_mapping)
Source code in src/dosemetrics/io/data_io.py
def load_structure_set(
    folder_path: Union[str, Path],
    format: Optional[str] = None,
    name: Optional[str] = None,
    structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
    **kwargs: object,
) -> "StructureSet":
    """
    Load a complete StructureSet from a folder, auto-detecting format.

    This is the primary high-level function for loading radiotherapy data as a
    unified StructureSet object. It handles both DICOM and NIfTI formats.

    Args:
        folder_path: Path to folder containing data
        format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.
        name: Name for the structure set. If None, uses folder name.
        structure_type_mapping: Optional dict mapping structure names to StructureType
        **kwargs: Additional arguments passed to format-specific loaders
                 For NIfTI: dose_filename (default: "Dose.nii.gz")
                 For DICOM: dose_file_name (specific dose file to use)

    Returns:
        StructureSet object with loaded structures. Dose is loaded separately.

    Raises:
        FileNotFoundError: If folder doesn't exist
        ValueError: If format cannot be determined or no structures found

    Examples:
        >>> # Load from DICOM folder
        >>> structure_set = load_structure_set('path/to/dicom_folder')

        >>> # Load from NIfTI folder with custom dose filename
        >>> structure_set = load_structure_set('path/to/nifti_folder', 
        ...                                     dose_filename='dose_distribution.nii.gz')

        >>> # Force format and provide structure types
        >>> type_mapping = {'Liver': StructureType.OAR, 'PTV': StructureType.TARGET}
        >>> structure_set = load_structure_set('path/to/folder',
        ...                                     format='nifti',
        ...                                     structure_type_mapping=type_mapping)
    """
    from ..structure_set import StructureSet  # Import here to avoid circular dependency

    folder_path = Path(folder_path)

    if not folder_path.exists():
        raise FileNotFoundError(f"Folder not found: {folder_path}")

    # Auto-detect format if not specified
    if format is None:
        format = detect_folder_format(folder_path)

    # Use folder name as default name
    if name is None:
        name = folder_path.name

    # Load based on format
    if format == 'dicom':
        return dicom_io.create_structure_set_from_dicom(
            folder_path=folder_path,
            name=name,
            structure_type_mapping=structure_type_mapping,
            **kwargs
        )
    elif format == 'nifti':
        return nifti_io.create_structure_set_from_nifti_folder(
            folder_path=folder_path,
            name=name,
            structure_type_mapping=structure_type_mapping,
            **kwargs
        )
    else:
        raise ValueError(
            f"Unknown format in {folder_path}. "
            f"Folder should contain either DICOM files or NIfTI files."
        )
load_volume
load_volume(file_path: Union[str, Path], format: Optional[str] = None) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Load a single volume file (DICOM RTDOSE or NIfTI).

Parameters:

Name Type Description Default
file_path Union[str, Path]

Path to file or folder (for DICOM CT series)

required
format Optional[str]

Force specific format ('dicom' or 'nifti'). If None, auto-detects.

None

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Tuple of (volume, spacing, origin)

Raises:

Type Description
FileNotFoundError

If file doesn't exist

ValueError

If format cannot be determined

Source code in src/dosemetrics/io/data_io.py
def load_volume(
    file_path: Union[str, Path],
    format: Optional[str] = None,
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
    """
    Load a single volume file (DICOM RTDOSE or NIfTI).

    Args:
        file_path: Path to file or folder (for DICOM CT series)
        format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.

    Returns:
        Tuple of (volume, spacing, origin)

    Raises:
        FileNotFoundError: If file doesn't exist
        ValueError: If format cannot be determined
    """
    file_path = Path(file_path)

    if not file_path.exists():
        raise FileNotFoundError(f"File not found: {file_path}")

    # Auto-detect format if not specified
    if format is None:
        if file_path.is_dir():
            format = 'dicom'  # Assume directory is DICOM CT series
        elif file_path.suffix in ['.nii', '.gz']:
            format = 'nifti'
        elif file_path.suffix == '.dcm':
            format = 'dicom'
        else:
            raise ValueError(f"Cannot determine format for: {file_path}")

    # Load based on format
    if format == 'dicom':
        if file_path.is_dir():
            # CT series
            volume, spacing, origin = dicom_io.read_dicom_ct_volume(file_path)
            return volume, spacing, origin
        else:
            # Single RTDOSE file
            volume, spacing, origin, _ = dicom_io.read_dicom_rtdose(file_path)
            return volume, spacing, origin
    elif format == 'nifti':
        return nifti_io.read_nifti_volume(file_path)
    else:
        raise ValueError(f"Unknown format: {format}")
load_structure
load_structure(file_path: Union[str, Path], name: Optional[str] = None, structure_type: StructureType = None, format: Optional[str] = None, **kwargs: object) -> Structure

Load a single structure from a file.

Parameters:

Name Type Description Default
file_path Union[str, Path]

Path to NIfTI file containing structure mask

required
name Optional[str]

Name for the structure. If None, uses filename.

None
structure_type StructureType

Type of structure (OAR, TARGET, etc.)

None
format Optional[str]

Force specific format ('nifti'). If None, auto-detects.

None
**kwargs object

Additional arguments (e.g., threshold for binarization)

{}

Returns:

Type Description
Structure

Structure object

Raises:

Type Description
FileNotFoundError

If file doesn't exist

ValueError

If format is not supported (currently only NIfTI single files)

Note

For DICOM RTSTRUCT files, use load_structure_set() instead as they typically contain multiple structures.

Source code in src/dosemetrics/io/data_io.py
def load_structure(
    file_path: Union[str, Path],
    name: Optional[str] = None,
    structure_type: "StructureType" = None,
    format: Optional[str] = None,
    **kwargs: object,
) -> "Structure":
    """
    Load a single structure from a file.

    Args:
        file_path: Path to NIfTI file containing structure mask
        name: Name for the structure. If None, uses filename.
        structure_type: Type of structure (OAR, TARGET, etc.)
        format: Force specific format ('nifti'). If None, auto-detects.
        **kwargs: Additional arguments (e.g., threshold for binarization)

    Returns:
        Structure object

    Raises:
        FileNotFoundError: If file doesn't exist
        ValueError: If format is not supported (currently only NIfTI single files)

    Note:
        For DICOM RTSTRUCT files, use load_structure_set() instead as they
        typically contain multiple structures.
    """
    from ..structures import StructureType  # Import here to avoid circular dependency

    if structure_type is None:
        structure_type = StructureType.OAR

    file_path = Path(file_path)

    if not file_path.exists():
        raise FileNotFoundError(f"File not found: {file_path}")

    # Auto-detect format if not specified
    if format is None:
        if file_path.suffix in ['.nii', '.gz']:
            format = 'nifti'
        else:
            raise ValueError(
                f"Cannot determine format for: {file_path}. "
                f"For DICOM RTSTRUCT files, use load_structure_set() instead."
            )

    # Currently only NIfTI single-file structures supported
    if format == 'nifti':
        return nifti_io.read_nifti_structure(
            file_path,
            name=name,
            structure_type=structure_type,
            **kwargs
        )
    else:
        raise ValueError(
            f"Format '{format}' not supported for single structure loading. "
            f"Use load_structure_set() for DICOM RTSTRUCT files."
        )

NIfTI helpers

nifti_io

NIfTI I/O utilities for radiotherapy data.

This module provides functions to read NIfTI files for: - CT/MR image volumes (real-valued) - Dose distributions (real-valued) - Structure masks (binary)

Uses SimpleITK for robust NIfTI reading with proper handling of spacing and origin.

Classes

Functions:

read_nifti_volume
read_nifti_volume(nifti_file: Union[str, Path]) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Read a NIfTI file and return volume with geometric information.

Parameters:

Name Type Description Default
nifti_file Union[str, Path]

Path to NIfTI file (.nii or .nii.gz)

required

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Tuple of (volume, spacing, origin) where: - volume: 3D numpy array - spacing: (x, y, z) voxel spacing in mm - origin: (x, y, z) origin coordinates in mm

Raises:

Type Description
FileNotFoundError

If file doesn't exist

RuntimeError

If file cannot be read

Source code in src/dosemetrics/io/nifti_io.py
def read_nifti_volume(
    nifti_file: Union[str, Path],
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
    """
    Read a NIfTI file and return volume with geometric information.

    Args:
        nifti_file: Path to NIfTI file (.nii or .nii.gz)

    Returns:
        Tuple of (volume, spacing, origin) where:
            - volume: 3D numpy array
            - spacing: (x, y, z) voxel spacing in mm
            - origin: (x, y, z) origin coordinates in mm

    Raises:
        FileNotFoundError: If file doesn't exist
        RuntimeError: If file cannot be read
    """
    nifti_file = Path(nifti_file)

    if not nifti_file.exists():
        raise FileNotFoundError(f"NIfTI file not found: {nifti_file}")

    # Read with SimpleITK
    image = sitk.ReadImage(str(nifti_file))

    # Get volume as numpy array
    volume = sitk.GetArrayFromImage(image)

    # Get spacing and origin
    spacing = image.GetSpacing()  # (x, y, z)
    origin = image.GetOrigin()    # (x, y, z)

    return volume, spacing, origin
read_nifti_mask
read_nifti_mask(nifti_file: Union[str, Path], threshold: float = 0.5) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Read a NIfTI file as a binary mask.

Parameters:

Name Type Description Default
nifti_file Union[str, Path]

Path to NIfTI file containing binary or probability mask

required
threshold float

Threshold for binarization (values > threshold become True)

0.5

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Tuple of (mask, spacing, origin) where: - mask: 3D boolean numpy array - spacing: (x, y, z) voxel spacing in mm - origin: (x, y, z) origin coordinates in mm

Raises:

Type Description
FileNotFoundError

If file doesn't exist

Source code in src/dosemetrics/io/nifti_io.py
def read_nifti_mask(
    nifti_file: Union[str, Path],
    threshold: float = 0.5,
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
    """
    Read a NIfTI file as a binary mask.

    Args:
        nifti_file: Path to NIfTI file containing binary or probability mask
        threshold: Threshold for binarization (values > threshold become True)

    Returns:
        Tuple of (mask, spacing, origin) where:
            - mask: 3D boolean numpy array
            - spacing: (x, y, z) voxel spacing in mm
            - origin: (x, y, z) origin coordinates in mm

    Raises:
        FileNotFoundError: If file doesn't exist
    """
    volume, spacing, origin = read_nifti_volume(nifti_file)

    # Binarize
    mask = volume > threshold

    return mask, spacing, origin
read_nifti_dose
read_nifti_dose(nifti_file: Union[str, Path]) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Read a NIfTI file containing dose distribution.

This is an alias for read_nifti_volume with a more semantic name.

Parameters:

Name Type Description Default
nifti_file Union[str, Path]

Path to NIfTI file containing dose data

required

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Tuple of (dose_array, spacing, origin) where: - dose_array: 3D numpy array with dose values (typically in Gy) - spacing: (x, y, z) voxel spacing in mm - origin: (x, y, z) origin coordinates in mm

Raises:

Type Description
FileNotFoundError

If file doesn't exist

Source code in src/dosemetrics/io/nifti_io.py
def read_nifti_dose(
    nifti_file: Union[str, Path],
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
    """
    Read a NIfTI file containing dose distribution.

    This is an alias for read_nifti_volume with a more semantic name.

    Args:
        nifti_file: Path to NIfTI file containing dose data

    Returns:
        Tuple of (dose_array, spacing, origin) where:
            - dose_array: 3D numpy array with dose values (typically in Gy)
            - spacing: (x, y, z) voxel spacing in mm
            - origin: (x, y, z) origin coordinates in mm

    Raises:
        FileNotFoundError: If file doesn't exist
    """
    return read_nifti_volume(nifti_file)
read_from_nifti
read_from_nifti(nifti_file: Union[str, Path]) -> np.ndarray

Read a NIfTI file and return only the volume array (backward compatibility).

This function provides backward compatibility with older code that expects only the numpy array. For new code, use read_nifti_volume() to also get spacing and origin information.

Parameters:

Name Type Description Default
nifti_file Union[str, Path]

Path to NIfTI file

required

Returns:

Type Description
ndarray

3D numpy array

Raises:

Type Description
FileNotFoundError

If file doesn't exist

Note

Deprecated. Use read_nifti_volume() for new code to get spacing and origin.

Source code in src/dosemetrics/io/nifti_io.py
def read_from_nifti(nifti_file: Union[str, Path]) -> np.ndarray:
    """
    Read a NIfTI file and return only the volume array (backward compatibility).

    This function provides backward compatibility with older code that expects
    only the numpy array. For new code, use read_nifti_volume() to also get
    spacing and origin information.

    Args:
        nifti_file: Path to NIfTI file

    Returns:
        3D numpy array

    Raises:
        FileNotFoundError: If file doesn't exist

    Note:
        Deprecated. Use read_nifti_volume() for new code to get spacing and origin.
    """
    volume, _, _ = read_nifti_volume(nifti_file)
    return volume
is_binary_volume
is_binary_volume(volume: ndarray, tolerance: float = 1e-06) -> bool

Check if a volume contains only binary values (0 and 1).

Parameters:

Name Type Description Default
volume ndarray

Numpy array to check

required
tolerance float

Tolerance for checking if values are 0 or 1

1e-06

Returns:

Type Description
bool

True if volume is binary, False otherwise

Source code in src/dosemetrics/io/nifti_io.py
def is_binary_volume(volume: np.ndarray, tolerance: float = 1e-6) -> bool:
    """
    Check if a volume contains only binary values (0 and 1).

    Args:
        volume: Numpy array to check
        tolerance: Tolerance for checking if values are 0 or 1

    Returns:
        True if volume is binary, False otherwise
    """
    unique_values = np.unique(volume)

    # Check if all unique values are close to 0 or 1
    for val in unique_values:
        if not (np.abs(val) < tolerance or np.abs(val - 1) < tolerance):
            return False

    return True
load_nifti_folder
load_nifti_folder(folder_path: Union[str, Path], dose_filename: str = 'Dose.nii.gz', auto_detect_masks: bool = True, return_as_structureset: bool = True, structure_type_mapping: Optional[Dict[str, StructureType]] = None) -> Union[StructureSet, Dict[str, Union[np.ndarray, Dict, Tuple]]]

Load all NIfTI files from a folder.

This function automatically: 1. Detects and loads the dose file 2. Auto-detects whether each file is a binary mask (structure) or real-valued volume (image) 3. Returns a StructureSet (default) or dictionary with organized data

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing NIfTI files

required
dose_filename str

Name of the dose file (default: "Dose.nii.gz")

'Dose.nii.gz'
auto_detect_masks bool

Whether to auto-detect binary masks vs real-valued volumes

True
return_as_structureset bool

If True (default), returns a StructureSet object. If False, returns raw dictionary.

True
structure_type_mapping Optional[Dict[str, StructureType]]

Optional dict mapping structure names to StructureType (only used if return_as_structureset=True)

None

Returns:

Type Description
Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

If return_as_structureset=True: StructureSet object with loaded data

Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

If return_as_structureset=False: Dictionary with keys: - 'dose_volume': Dose distribution array (if found) - 'dose_spacing': Dose spacing tuple (if found) - 'dose_origin': Dose origin tuple (if found) - 'image_volumes': Dict of real-valued volumes {name: {'volume', 'spacing', 'origin'}} - 'structure_masks': Dict of binary masks {name: {'mask', 'spacing', 'origin'}} - 'spacing': Common spacing for all data - 'origin': Common origin for all data

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

Source code in src/dosemetrics/io/nifti_io.py
def load_nifti_folder(
    folder_path: Union[str, Path],
    dose_filename: str = "Dose.nii.gz",
    auto_detect_masks: bool = True,
    return_as_structureset: bool = True,
    structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
) -> Union["StructureSet", Dict[str, Union[np.ndarray, Dict, Tuple]]]:
    """
    Load all NIfTI files from a folder.

    This function automatically:
    1. Detects and loads the dose file
    2. Auto-detects whether each file is a binary mask (structure) or real-valued volume (image)
    3. Returns a StructureSet (default) or dictionary with organized data

    Args:
        folder_path: Path to folder containing NIfTI files
        dose_filename: Name of the dose file (default: "Dose.nii.gz")
        auto_detect_masks: Whether to auto-detect binary masks vs real-valued volumes
        return_as_structureset: If True (default), returns a StructureSet object.
                               If False, returns raw dictionary.
        structure_type_mapping: Optional dict mapping structure names to StructureType
                               (only used if return_as_structureset=True)

    Returns:
        If return_as_structureset=True: StructureSet object with loaded data
        If return_as_structureset=False: Dictionary with keys:
            - 'dose_volume': Dose distribution array (if found)
            - 'dose_spacing': Dose spacing tuple (if found)
            - 'dose_origin': Dose origin tuple (if found)
            - 'image_volumes': Dict of real-valued volumes {name: {'volume', 'spacing', 'origin'}}
            - 'structure_masks': Dict of binary masks {name: {'mask', 'spacing', 'origin'}}
            - 'spacing': Common spacing for all data
            - 'origin': Common origin for all data

    Raises:
        FileNotFoundError: If folder doesn't exist
    """
    folder_path = Path(folder_path)

    if not folder_path.exists():
        raise FileNotFoundError(f"Folder not found: {folder_path}")

    result = {
        'image_volumes': {},
        'structure_masks': {},
    }

    # Get all NIfTI files
    nifti_files = list(folder_path.glob("*.nii.gz")) + list(folder_path.glob("*.nii"))

    if not nifti_files:
        return result

    # Try to load dose file first
    dose_file = folder_path / dose_filename
    if dose_file.exists():
        try:
            dose_volume, dose_spacing, dose_origin = read_nifti_dose(dose_file)
            result['dose_volume'] = dose_volume
            result['dose_spacing'] = dose_spacing
            result['dose_origin'] = dose_origin
            result['spacing'] = dose_spacing
            result['origin'] = dose_origin
        except Exception as e:
            print(f"Warning: Could not load dose file {dose_filename}: {e}")

    # Process other NIfTI files
    for nifti_file in nifti_files:
        # Skip dose file
        if nifti_file.name == dose_filename:
            continue

        try:
            volume, spacing, origin = read_nifti_volume(nifti_file)

            # Set common spacing/origin from first file if not set
            if 'spacing' not in result:
                result['spacing'] = spacing
                result['origin'] = origin

            # Extract name from filename (remove .nii.gz or .nii)
            if nifti_file.name.endswith('.nii.gz'):
                name = nifti_file.name[:-7]
            else:
                name = nifti_file.stem

            # Auto-detect if this is a binary mask
            if auto_detect_masks and is_binary_volume(volume):
                # It's a binary mask (structure)
                mask = volume.astype(bool)
                result['structure_masks'][name] = {
                    'mask': mask,
                    'spacing': spacing,
                    'origin': origin,
                }
            else:
                # It's a real-valued volume (image)
                result['image_volumes'][name] = {
                    'volume': volume,
                    'spacing': spacing,
                    'origin': origin,
                }

        except Exception as e:
            print(f"Warning: Could not load {nifti_file.name}: {e}")

    # Return as StructureSet if requested
    if return_as_structureset:
        if not result['structure_masks']:
            raise ValueError(f"No structure masks found in: {folder_path}")
        return create_structure_set_from_nifti_folder(
            folder_path,
            dose_filename=dose_filename,
            structure_type_mapping=structure_type_mapping,
            name=folder_path.name if isinstance(folder_path, Path) else Path(folder_path).name,
        )

    return result
create_structure_set_from_nifti_folder
create_structure_set_from_nifti_folder(folder_path: Union[str, Path], dose_filename: str = 'Dose.nii.gz', structure_type_mapping: Optional[Dict[str, StructureType]] = None, name: Optional[str] = None) -> StructureSet

Create a StructureSet from NIfTI files in a folder.

This high-level function automatically: 1. Loads dose distribution 2. Auto-detects binary mask files as structures 3. Creates Structure objects with appropriate types 4. Returns a complete StructureSet

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing NIfTI files

required
dose_filename str

Name of the dose file (default: "Dose.nii.gz")

'Dose.nii.gz'
structure_type_mapping Optional[Dict[str, StructureType]]

Optional dict mapping structure names to StructureType. If not provided, guesses based on naming conventions.

None
name Optional[str]

Name for the structure set. If None, uses folder name.

None

Returns:

Type Description
StructureSet

StructureSet object with loaded structures and dose

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

ValueError

If no structures found

Source code in src/dosemetrics/io/nifti_io.py
def create_structure_set_from_nifti_folder(
    folder_path: Union[str, Path],
    dose_filename: str = "Dose.nii.gz",
    structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
    name: Optional[str] = None,
) -> "StructureSet":
    """
    Create a StructureSet from NIfTI files in a folder.

    This high-level function automatically:
    1. Loads dose distribution
    2. Auto-detects binary mask files as structures
    3. Creates Structure objects with appropriate types
    4. Returns a complete StructureSet

    Args:
        folder_path: Path to folder containing NIfTI files
        dose_filename: Name of the dose file (default: "Dose.nii.gz")
        structure_type_mapping: Optional dict mapping structure names to StructureType.
                               If not provided, guesses based on naming conventions.
        name: Name for the structure set. If None, uses folder name.

    Returns:
        StructureSet object with loaded structures and dose

    Raises:
        FileNotFoundError: If folder doesn't exist
        ValueError: If no structures found
    """
    from ..structures import StructureType
    from ..structure_set import StructureSet

    folder_path = Path(folder_path)

    # Load all data from folder (get raw dict to avoid recursion)
    data = load_nifti_folder(folder_path, dose_filename=dose_filename, return_as_structureset=False)

    if not data['structure_masks']:
        raise ValueError(f"No structure masks found in: {folder_path}")

    # Get spacing and origin
    spacing = data.get('spacing', (1.0, 1.0, 1.0))
    origin = data.get('origin', (0.0, 0.0, 0.0))

    # Create structure set name
    if name is None:
        name = folder_path.name

    # Create structure set
    structure_set = StructureSet(spacing=spacing, origin=origin, name=name)

    # Add structures
    for struct_name, struct_data in data['structure_masks'].items():
        # Determine structure type
        struct_type = StructureType.OAR  # Default

        if structure_type_mapping and struct_name in structure_type_mapping:
            struct_type = structure_type_mapping[struct_name]
        else:
            # Guess based on name
            name_upper = struct_name.upper()
            if any(keyword in name_upper for keyword in ['PTV', 'GTV', 'CTV', 'TARGET']):
                struct_type = StructureType.TARGET
            elif any(keyword in name_upper for keyword in ['AVOID', 'PRV']):
                struct_type = StructureType.AVOIDANCE

        structure_set.add_structure(
            name=struct_name,
            mask=struct_data['mask'],
            structure_type=struct_type,
        )

    # Note: In the new architecture, dose is loaded separately using Dose.from_nifti()
    # and not attached to the StructureSet

    return structure_set
read_nifti_structure
read_nifti_structure(nifti_file: Union[str, Path], name: Optional[str] = None, structure_type: StructureType = None, threshold: float = 0.5) -> Structure

Read a single NIfTI file as a Structure object.

Parameters:

Name Type Description Default
nifti_file Union[str, Path]

Path to NIfTI file containing binary mask

required
name Optional[str]

Name for the structure. If None, uses filename.

None
structure_type StructureType

Type of structure (OAR, TARGET, etc.)

None
threshold float

Threshold for binarization

0.5

Returns:

Type Description
Structure

Structure object (OAR, Target, or AvoidanceStructure based on type)

Raises:

Type Description
FileNotFoundError

If file doesn't exist

Source code in src/dosemetrics/io/nifti_io.py
def read_nifti_structure(
    nifti_file: Union[str, Path],
    name: Optional[str] = None,
    structure_type: "StructureType" = None,
    threshold: float = 0.5,
) -> "Structure":
    """
    Read a single NIfTI file as a Structure object.

    Args:
        nifti_file: Path to NIfTI file containing binary mask
        name: Name for the structure. If None, uses filename.
        structure_type: Type of structure (OAR, TARGET, etc.)
        threshold: Threshold for binarization

    Returns:
        Structure object (OAR, Target, or AvoidanceStructure based on type)

    Raises:
        FileNotFoundError: If file doesn't exist
    """
    from ..structures import Structure, OAR, Target, StructureType

    if structure_type is None:
        structure_type = StructureType.OAR

    nifti_file = Path(nifti_file)

    # Extract name from filename if not provided
    if name is None:
        if nifti_file.name.endswith('.nii.gz'):
            name = nifti_file.name[:-7]
        else:
            name = nifti_file.stem

    # Read mask
    mask, spacing, origin = read_nifti_mask(nifti_file, threshold=threshold)

    # Create appropriate Structure subclass
    if structure_type == StructureType.OAR:
        structure_class = OAR
    elif structure_type == StructureType.TARGET:
        structure_class = Target
    else:
        # For other types, use base class with type override
        structure_class = type(
            f"{structure_type.value.title()}Structure",
            (Structure,),
            {"structure_type": property(lambda self: structure_type)},
        )

    structure = structure_class(
        name=name,
        mask=mask,
        spacing=spacing,
        origin=origin,
    )

    return structure
write_nifti_volume
write_nifti_volume(volume: ndarray, output_file: Union[str, Path], spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0), origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)) -> None

Write a numpy array to a NIfTI file.

Parameters:

Name Type Description Default
volume ndarray

3D numpy array to write

required
output_file Union[str, Path]

Path for output NIfTI file

required
spacing Tuple[float, float, float]

Voxel spacing in (x, y, z) mm

(1.0, 1.0, 1.0)
origin Tuple[float, float, float]

Origin coordinates in (x, y, z) mm

(0.0, 0.0, 0.0)

Raises:

Type Description
ValueError

If volume is not 3D

Source code in src/dosemetrics/io/nifti_io.py
def write_nifti_volume(
    volume: np.ndarray,
    output_file: Union[str, Path],
    spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0),
    origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> None:
    """
    Write a numpy array to a NIfTI file.

    Args:
        volume: 3D numpy array to write
        output_file: Path for output NIfTI file
        spacing: Voxel spacing in (x, y, z) mm
        origin: Origin coordinates in (x, y, z) mm

    Raises:
        ValueError: If volume is not 3D
    """
    if volume.ndim != 3:
        raise ValueError(f"Volume must be 3D, got {volume.ndim}D")

    output_file = Path(output_file)

    # Create SimpleITK image
    image = sitk.GetImageFromArray(volume)
    image.SetSpacing(spacing)
    image.SetOrigin(origin)

    # Ensure output directory exists
    output_file.parent.mkdir(parents=True, exist_ok=True)

    # Write to file
    sitk.WriteImage(image, str(output_file))
write_structure_as_nifti
write_structure_as_nifti(structure: Structure, output_file: Union[str, Path]) -> None

Write a Structure's mask to a NIfTI file.

Parameters:

Name Type Description Default
structure Structure

Structure object to write

required
output_file Union[str, Path]

Path for output NIfTI file

required

Raises:

Type Description
ValueError

If structure has no mask

Source code in src/dosemetrics/io/nifti_io.py
def write_structure_as_nifti(
    structure: "Structure",
    output_file: Union[str, Path],
) -> None:
    """
    Write a Structure's mask to a NIfTI file.

    Args:
        structure: Structure object to write
        output_file: Path for output NIfTI file

    Raises:
        ValueError: If structure has no mask
    """
    if structure.mask is None:
        raise ValueError(f"Structure '{structure.name}' has no mask")

    # Convert boolean mask to uint8 for better compatibility
    mask_uint = structure.mask.astype(np.uint8)

    write_nifti_volume(
        volume=mask_uint,
        output_file=output_file,
        spacing=structure.spacing,
        origin=structure.origin,
    )
write_structure_set_as_nifti
write_structure_set_as_nifti(structure_set: StructureSet, output_folder: Union[str, Path], write_dose: bool = True, dose_filename: str = 'Dose.nii.gz') -> None

Write a StructureSet to NIfTI files in a folder.

Parameters:

Name Type Description Default
structure_set StructureSet

StructureSet to write

required
output_folder Union[str, Path]

Path to output folder

required
write_dose bool

Whether to write dose file

True
dose_filename str

Name for dose file

'Dose.nii.gz'

Raises:

Type Description
ValueError

If structure_set has no structures

Source code in src/dosemetrics/io/nifti_io.py
def write_structure_set_as_nifti(
    structure_set: "StructureSet",
    output_folder: Union[str, Path],
    write_dose: bool = True,
    dose_filename: str = "Dose.nii.gz",
) -> None:
    """
    Write a StructureSet to NIfTI files in a folder.

    Args:
        structure_set: StructureSet to write
        output_folder: Path to output folder
        write_dose: Whether to write dose file
        dose_filename: Name for dose file

    Raises:
        ValueError: If structure_set has no structures
    """
    output_folder = Path(output_folder)
    output_folder.mkdir(parents=True, exist_ok=True)

    # Write each structure
    for struct_name, structure in structure_set.structures.items():
        if structure.mask is not None:
            output_file = output_folder / f"{struct_name}.nii.gz"
            write_structure_as_nifti(structure, output_file)

DICOM helpers

dicom_io

DICOM I/O utilities for radiotherapy data.

This module provides functions to read DICOM radiotherapy data including: - CT image volumes - RTDOSE (dose distributions) - RTSTRUCT (structure sets/contours) - RTPLAN (treatment plans - metadata only)

Uses pydicom for DICOM parsing and SimpleITK for volume reconstruction.

Classes

Functions:

read_dicom_ct_volume
read_dicom_ct_volume(ct_directory: Union[str, Path]) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Read a CT volume from a directory of DICOM slices.

Parameters:

Name Type Description Default
ct_directory Union[str, Path]

Path to directory containing CT DICOM files

required

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float]]

Tuple of (volume, spacing, origin) where: - volume: 3D numpy array with shape (slices, rows, cols) - spacing: (x, y, z) voxel spacing in mm - origin: (x, y, z) origin coordinates in mm

Raises:

Type Description
FileNotFoundError

If directory doesn't exist or contains no DICOM files

ValueError

If DICOM files don't form a valid CT series

Source code in src/dosemetrics/io/dicom_io.py
def read_dicom_ct_volume(
    ct_directory: Union[str, Path],
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
    """
    Read a CT volume from a directory of DICOM slices.

    Args:
        ct_directory: Path to directory containing CT DICOM files

    Returns:
        Tuple of (volume, spacing, origin) where:
            - volume: 3D numpy array with shape (slices, rows, cols)
            - spacing: (x, y, z) voxel spacing in mm
            - origin: (x, y, z) origin coordinates in mm

    Raises:
        FileNotFoundError: If directory doesn't exist or contains no DICOM files
        ValueError: If DICOM files don't form a valid CT series
    """
    ct_directory = Path(ct_directory)

    if not ct_directory.exists():
        raise FileNotFoundError(f"CT directory not found: {ct_directory}")

    # Get all DICOM files in directory
    dicom_files = sorted(ct_directory.glob("*.dcm"))

    if not dicom_files:
        raise FileNotFoundError(f"No DICOM files found in: {ct_directory}")

    # Use SimpleITK for robust volume reconstruction
    reader = sitk.ImageSeriesReader()
    dicom_names = reader.GetGDCMSeriesFileNames(str(ct_directory))

    if not dicom_names:
        raise ValueError(f"No valid DICOM series found in: {ct_directory}")

    reader.SetFileNames(dicom_names)
    image = reader.Execute()

    # Get volume as numpy array (SimpleITK uses (z, y, x) ordering)
    volume = sitk.GetArrayFromImage(image)

    # Get spacing and origin
    spacing = image.GetSpacing()  # (x, y, z)
    origin = image.GetOrigin()    # (x, y, z)

    return volume, spacing, origin
read_dicom_rtdose
read_dicom_rtdose(rtdose_file: Union[str, Path]) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float], float]

Read an RTDOSE DICOM file.

Parameters:

Name Type Description Default
rtdose_file Union[str, Path]

Path to RTDOSE DICOM file

required

Returns:

Type Description
Tuple[ndarray, Tuple[float, float, float], Tuple[float, float, float], float]

Tuple of (dose_array, spacing, origin, dose_scaling) where: - dose_array: 3D numpy array with dose values in Gy - spacing: (x, y, z) voxel spacing in mm - origin: (x, y, z) origin coordinates in mm - dose_scaling: Dose grid scaling factor

Raises:

Type Description
FileNotFoundError

If file doesn't exist

ValueError

If file is not a valid RTDOSE DICOM

Source code in src/dosemetrics/io/dicom_io.py
def read_dicom_rtdose(
    rtdose_file: Union[str, Path],
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float], float]:
    """
    Read an RTDOSE DICOM file.

    Args:
        rtdose_file: Path to RTDOSE DICOM file

    Returns:
        Tuple of (dose_array, spacing, origin, dose_scaling) where:
            - dose_array: 3D numpy array with dose values in Gy
            - spacing: (x, y, z) voxel spacing in mm
            - origin: (x, y, z) origin coordinates in mm
            - dose_scaling: Dose grid scaling factor

    Raises:
        FileNotFoundError: If file doesn't exist
        ValueError: If file is not a valid RTDOSE DICOM
    """
    rtdose_file = Path(rtdose_file)

    if not rtdose_file.exists():
        raise FileNotFoundError(f"RTDOSE file not found: {rtdose_file}")

    # Read with pydicom
    ds = pydicom.dcmread(rtdose_file)

    # Verify it's an RTDOSE file
    if ds.Modality != 'RTDOSE':
        raise ValueError(f"File is not RTDOSE, got modality: {ds.Modality}")

    # Get dose array
    dose_array = ds.pixel_array.astype(np.float32)

    # Apply dose grid scaling to get doses in Gy
    dose_scaling = float(ds.DoseGridScaling)
    dose_array = dose_array * dose_scaling

    # Get geometric information
    # DICOM uses (row, col) for pixel spacing, need to add slice thickness
    pixel_spacing = ds.PixelSpacing  # [row_spacing, col_spacing]

    # Get slice thickness or use grid frame offset vector
    if hasattr(ds, 'SliceThickness') and ds.SliceThickness is not None:
        slice_spacing = float(ds.SliceThickness)
    elif hasattr(ds, 'GridFrameOffsetVector') and len(ds.GridFrameOffsetVector) > 1:
        # Calculate from frame offset vector
        slice_spacing = abs(float(ds.GridFrameOffsetVector[1]) - float(ds.GridFrameOffsetVector[0]))
    else:
        slice_spacing = 1.0  # Default fallback

    # Spacing in (x, y, z) format
    spacing = (float(pixel_spacing[1]), float(pixel_spacing[0]), slice_spacing)

    # Get origin (ImagePositionPatient)
    if hasattr(ds, 'ImagePositionPatient'):
        origin = tuple(float(x) for x in ds.ImagePositionPatient)
    else:
        origin = (0.0, 0.0, 0.0)

    return dose_array, spacing, origin, dose_scaling
read_dicom_rtstruct
read_dicom_rtstruct(rtstruct_file: Union[str, Path], reference_image: Optional[Union[Image, Tuple[Tuple[int, ...], Tuple[float, ...], Tuple[float, ...]]]] = None) -> Dict[str, Dict[str, Union[np.ndarray, List]]]

Read an RTSTRUCT DICOM file and extract structure information.

Parameters:

Name Type Description Default
rtstruct_file Union[str, Path]

Path to RTSTRUCT DICOM file

required
reference_image Optional[Union[Image, Tuple[Tuple[int, ...], Tuple[float, ...], Tuple[float, ...]]]]

Optional reference image or (shape, spacing, origin) tuple for mask generation. If None, only contour points are returned without generating binary masks.

None

Returns:

Type Description
Dict[str, Dict[str, Union[ndarray, List]]]

Dictionary mapping structure names to dictionaries containing: - 'contours': List of contour point arrays (each is Nx3 array of (x, y, z) points) - 'mask': Binary mask array (only if reference_image provided) - 'roi_number': ROI number from DICOM - 'color': RGB color tuple

Raises:

Type Description
FileNotFoundError

If file doesn't exist

ValueError

If file is not a valid RTSTRUCT DICOM

Source code in src/dosemetrics/io/dicom_io.py
def read_dicom_rtstruct(
    rtstruct_file: Union[str, Path],
    reference_image: Optional[Union[sitk.Image, Tuple[Tuple[int, ...], Tuple[float, ...], Tuple[float, ...]]]] = None,
) -> Dict[str, Dict[str, Union[np.ndarray, List]]]:
    """
    Read an RTSTRUCT DICOM file and extract structure information.

    Args:
        rtstruct_file: Path to RTSTRUCT DICOM file
        reference_image: Optional reference image or (shape, spacing, origin) tuple for mask generation.
                        If None, only contour points are returned without generating binary masks.

    Returns:
        Dictionary mapping structure names to dictionaries containing:
            - 'contours': List of contour point arrays (each is Nx3 array of (x, y, z) points)
            - 'mask': Binary mask array (only if reference_image provided)
            - 'roi_number': ROI number from DICOM
            - 'color': RGB color tuple

    Raises:
        FileNotFoundError: If file doesn't exist
        ValueError: If file is not a valid RTSTRUCT DICOM
    """
    rtstruct_file = Path(rtstruct_file)

    if not rtstruct_file.exists():
        raise FileNotFoundError(f"RTSTRUCT file not found: {rtstruct_file}")

    # Read with pydicom
    ds = pydicom.dcmread(rtstruct_file)

    # Verify it's an RTSTRUCT file
    if ds.Modality != 'RTSTRUCT':
        raise ValueError(f"File is not RTSTRUCT, got modality: {ds.Modality}")

    structures = {}

    # Build ROI number to name mapping
    roi_dict = {}
    if hasattr(ds, 'StructureSetROISequence'):
        for roi in ds.StructureSetROISequence:
            roi_number = roi.ROINumber
            roi_name = roi.ROIName
            roi_dict[roi_number] = roi_name

    # Extract contours for each ROI
    if hasattr(ds, 'ROIContourSequence'):
        for roi_contour in ds.ROIContourSequence:
            roi_number = roi_contour.ReferencedROINumber

            if roi_number not in roi_dict:
                continue

            roi_name = roi_dict[roi_number]

            # Get color if available
            if hasattr(roi_contour, 'ROIDisplayColor'):
                color = tuple(int(c) for c in roi_contour.ROIDisplayColor)
            else:
                color = (255, 0, 0)  # Default red

            # Extract contour points
            contours = []
            if hasattr(roi_contour, 'ContourSequence'):
                for contour in roi_contour.ContourSequence:
                    if hasattr(contour, 'ContourData'):
                        # ContourData is a flat list of [x1, y1, z1, x2, y2, z2, ...]
                        points = np.array(contour.ContourData).reshape(-1, 3)
                        contours.append(points)

            structures[roi_name] = {
                'contours': contours,
                'roi_number': roi_number,
                'color': color,
            }

    # Generate binary masks if reference image provided
    if reference_image is not None:
        if isinstance(reference_image, sitk.Image):
            shape = reference_image.GetSize()[::-1]  # SimpleITK uses (x,y,z), numpy uses (z,y,x)
            spacing = reference_image.GetSpacing()
            origin = reference_image.GetOrigin()
        else:
            shape, spacing, origin = reference_image

        # Generate masks for each structure
        for roi_name, roi_data in structures.items():
            mask = _generate_mask_from_contours(
                roi_data['contours'],
                shape,
                spacing,
                origin
            )
            roi_data['mask'] = mask

    return structures
load_dicom_folder
load_dicom_folder(folder_path: Union[str, Path], load_ct: bool = True, load_rtdose: bool = True, load_rtstruct: bool = True, return_as_structureset: bool = True, dose_file_name: Optional[str] = None, structure_type_mapping: Optional[Dict[str, StructureType]] = None) -> Union[StructureSet, Dict[str, Union[np.ndarray, Dict, Tuple]]]

Load all DICOM data from a folder containing CT, RTDOSE, and RTSTRUCT files.

This is a high-level function that automatically detects and loads all DICOM modalities present in the folder.

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing DICOM files organized in subfolders (e.g., CT/, RTDOSE/, RTSTRUCT/)

required
load_ct bool

Whether to load CT volume

True
load_rtdose bool

Whether to load dose distributions

True
load_rtstruct bool

Whether to load structure sets

True
return_as_structureset bool

If True (default), returns a StructureSet object. If False, returns raw dictionary.

True
dose_file_name Optional[str]

Specific dose file to use (only if return_as_structureset=True)

None
structure_type_mapping Optional[Dict[str, StructureType]]

Optional dict mapping structure names to StructureType (only used if return_as_structureset=True)

None

Returns:

Type Description
Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

If return_as_structureset=True: StructureSet object with loaded data

Union[StructureSet, Dict[str, Union[ndarray, Dict, Tuple]]]

If return_as_structureset=False: Dictionary with keys: - 'ct_volume': CT volume array (if loaded) - 'ct_spacing': CT spacing tuple (if loaded) - 'ct_origin': CT origin tuple (if loaded) - 'dose_volumes': Dict of dose volumes {filename: (array, spacing, origin, scaling)} - 'structures': Dict of structures from RTSTRUCT - 'spacing': Common spacing for all data - 'origin': Common origin for all data

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

Source code in src/dosemetrics/io/dicom_io.py
def load_dicom_folder(
    folder_path: Union[str, Path],
    load_ct: bool = True,
    load_rtdose: bool = True,
    load_rtstruct: bool = True,
    return_as_structureset: bool = True,
    dose_file_name: Optional[str] = None,
    structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
) -> Union["StructureSet", Dict[str, Union[np.ndarray, Dict, Tuple]]]:
    """
    Load all DICOM data from a folder containing CT, RTDOSE, and RTSTRUCT files.

    This is a high-level function that automatically detects and loads all DICOM
    modalities present in the folder.

    Args:
        folder_path: Path to folder containing DICOM files organized in subfolders
                    (e.g., CT/, RTDOSE/, RTSTRUCT/)
        load_ct: Whether to load CT volume
        load_rtdose: Whether to load dose distributions
        load_rtstruct: Whether to load structure sets
        return_as_structureset: If True (default), returns a StructureSet object.
                               If False, returns raw dictionary.
        dose_file_name: Specific dose file to use (only if return_as_structureset=True)
        structure_type_mapping: Optional dict mapping structure names to StructureType
                               (only used if return_as_structureset=True)

    Returns:
        If return_as_structureset=True: StructureSet object with loaded data
        If return_as_structureset=False: Dictionary with keys:
            - 'ct_volume': CT volume array (if loaded)
            - 'ct_spacing': CT spacing tuple (if loaded)
            - 'ct_origin': CT origin tuple (if loaded)
            - 'dose_volumes': Dict of dose volumes {filename: (array, spacing, origin, scaling)}
            - 'structures': Dict of structures from RTSTRUCT
            - 'spacing': Common spacing for all data
            - 'origin': Common origin for all data

    Raises:
        FileNotFoundError: If folder doesn't exist
    """
    folder_path = Path(folder_path)

    if not folder_path.exists():
        raise FileNotFoundError(f"Folder not found: {folder_path}")

    result = {}

    # Load CT volume
    if load_ct:
        ct_dir = folder_path / 'CT'
        if ct_dir.exists():
            try:
                ct_volume, ct_spacing, ct_origin = read_dicom_ct_volume(ct_dir)
                result['ct_volume'] = ct_volume
                result['ct_spacing'] = ct_spacing
                result['ct_origin'] = ct_origin
                result['spacing'] = ct_spacing
                result['origin'] = ct_origin
            except Exception as e:
                print(f"Warning: Could not load CT volume: {e}")

    # Load RTDOSE files
    if load_rtdose:
        rtdose_dir = folder_path / 'RTDOSE'
        if rtdose_dir.exists():
            dose_volumes = {}
            for dose_file in rtdose_dir.glob('*.dcm'):
                try:
                    dose_array, spacing, origin, scaling = read_dicom_rtdose(dose_file)
                    dose_volumes[dose_file.stem] = {
                        'array': dose_array,
                        'spacing': spacing,
                        'origin': origin,
                        'scaling': scaling,
                    }
                    # Use first dose for common spacing/origin if CT not available
                    if 'spacing' not in result:
                        result['spacing'] = spacing
                        result['origin'] = origin
                except Exception as e:
                    print(f"Warning: Could not load RTDOSE {dose_file.name}: {e}")

            if dose_volumes:
                result['dose_volumes'] = dose_volumes

    # Load RTSTRUCT files
    if load_rtstruct:
        rtstruct_dir = folder_path / 'RTSTRUCT'
        if rtstruct_dir.exists():
            # Use CT or dose as reference for mask generation
            reference = None
            if 'ct_volume' in result:
                reference = (
                    result['ct_volume'].shape,
                    result['ct_spacing'],
                    result['ct_origin']
                )
            elif 'dose_volumes' in result:
                first_dose = next(iter(result['dose_volumes'].values()))
                reference = (
                    first_dose['array'].shape,
                    first_dose['spacing'],
                    first_dose['origin']
                )

            for rtstruct_file in rtstruct_dir.glob('*.dcm'):
                try:
                    structures = read_dicom_rtstruct(rtstruct_file, reference)
                    result['structures'] = structures
                    break  # Usually only one RTSTRUCT file
                except Exception as e:
                    print(f"Warning: Could not load RTSTRUCT {rtstruct_file.name}: {e}")

    # Return as StructureSet if requested
    if return_as_structureset:
        if 'structures' not in result or not result['structures']:
            raise ValueError(f"No structures found in: {folder_path}")
        return create_structure_set_from_dicom(
            folder_path,
            dose_file_name=dose_file_name,
            structure_type_mapping=structure_type_mapping,
            name=f"DICOM - {folder_path.name if isinstance(folder_path, Path) else Path(folder_path).name}",
        )

    return result
create_structure_set_from_dicom
create_structure_set_from_dicom(folder_path: Union[str, Path], dose_file_name: Optional[str] = None, structure_type_mapping: Optional[Dict[str, StructureType]] = None, name: str = 'DICOM StructureSet') -> StructureSet

Create a StructureSet object from DICOM data in a folder.

This is a high-level convenience function that loads DICOM data and creates a complete StructureSet object.

Parameters:

Name Type Description Default
folder_path Union[str, Path]

Path to folder containing DICOM subfolders

required
dose_file_name Optional[str]

Specific dose file to use (e.g., 'RD_1'). If None, uses first found.

None
structure_type_mapping Optional[Dict[str, StructureType]]

Optional dict mapping structure names to StructureType

None
name str

Name for the structure set

'DICOM StructureSet'

Returns:

Type Description
StructureSet

StructureSet object with loaded structures and dose data

Raises:

Type Description
FileNotFoundError

If folder doesn't exist

ValueError

If no structures found

Source code in src/dosemetrics/io/dicom_io.py
def create_structure_set_from_dicom(
    folder_path: Union[str, Path],
    dose_file_name: Optional[str] = None,
    structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
    name: str = "DICOM StructureSet",
) -> "StructureSet":
    """
    Create a StructureSet object from DICOM data in a folder.

    This is a high-level convenience function that loads DICOM data and creates
    a complete StructureSet object.

    Args:
        folder_path: Path to folder containing DICOM subfolders
        dose_file_name: Specific dose file to use (e.g., 'RD_1'). If None, uses first found.
        structure_type_mapping: Optional dict mapping structure names to StructureType
        name: Name for the structure set

    Returns:
        StructureSet object with loaded structures and dose data

    Raises:
        FileNotFoundError: If folder doesn't exist
        ValueError: If no structures found
    """
    from ..structures import StructureType
    from ..structure_set import StructureSet

    # Load all DICOM data (get raw dict to avoid recursion)
    data = load_dicom_folder(folder_path, return_as_structureset=False)

    if 'structures' not in data or not data['structures']:
        raise ValueError(f"No structures found in: {folder_path}")

    # Get spacing and origin
    spacing = data.get('spacing', (1.0, 1.0, 1.0))
    origin = data.get('origin', (0.0, 0.0, 0.0))

    # Create structure set
    structure_set = StructureSet(spacing=spacing, origin=origin, name=name)

    # Add structures
    for struct_name, struct_data in data['structures'].items():
        if 'mask' not in struct_data:
            continue

        # Determine structure type
        struct_type = StructureType.OAR  # Default
        if structure_type_mapping and struct_name in structure_type_mapping:
            struct_type = structure_type_mapping[struct_name]
        elif 'PTV' in struct_name.upper() or 'GTV' in struct_name.upper() or 'CTV' in struct_name.upper():
            struct_type = StructureType.TARGET

        structure_set.add_structure(
            name=struct_name,
            mask=struct_data['mask'],
            structure_type=struct_type,
        )

    # Note: In the new architecture, dose is loaded separately using Dose.from_dicom()
    # and not attached to the StructureSet. Multiple dose files can be loaded independently.

    return structure_set

Typical NIfTI workflow

from dosemetrics import Dose, StructureType
from dosemetrics.io import load_structure, load_structure_set

dose = Dose.from_nifti("patient/Dose.nii.gz")
structures = load_structure_set("patient")
ptv = load_structure(
    "patient/PTV.nii.gz",
    name="PTV",
    structure_type=StructureType.TARGET,
)

Typical DICOM workflow

from dosemetrics import Dose
from dosemetrics.io import load_structure_set

dose = Dose.from_dicom("patient/RTDOSE/plan.dcm")
structures = load_structure_set("patient", format="dicom")

Batch over patient folders

from pathlib import Path
from dosemetrics import Dose
from dosemetrics.io import load_structure_set
from dosemetrics.metrics import dvh

results = []
for patient_dir in Path("data").glob("patient_*"):
    dose = Dose.from_nifti(patient_dir / "Dose.nii.gz")
    structures = load_structure_set(patient_dir)
    for name, structure in structures:
        stats = dvh.compute_dose_statistics(dose, structure)
        results.append({"patient": patient_dir.name, "structure": name, **stats})

Supported formats

Data NIfTI DICOM
Dose .nii, .nii.gz RTDOSE .dcm
Structure binary .nii, .nii.gz RTSTRUCT .dcm with reference grid
Image .nii, .nii.gz CT series

NRRD is not currently supported by the public API.