From ba4ff53de7adde30c51bebe8780f0e4bd3bc744f Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 08:21:47 -0800 Subject: [PATCH 01/21] Add totalsegmentator dependencies under optional dependencies --- CONTRIBUTING.md | 17 +++++++++++++++-- README.md | 12 ++++++++++++ pyproject.toml | 10 +++++++++- setup_dev.py | 3 ++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59be7d1..335870d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,10 +41,23 @@ We love your input! We want to make contributing to PyTheranostics as easy and t This script will: - Verify you're in a virtual environment - - Install the package in editable mode - - Install all development dependencies (pytest, black, flake8, mypy, etc.) + - Install the package in editable mode with all development dependencies + - Install dependencies for segmentation tools (TotalSegmentator, torch, torchvision) + - Install testing, linting, and type checking tools (pytest, black, flake8, mypy, etc.) - Set up pre-commit hooks for automated code quality checks +### Segmentation Support + +Developers can test segmentation features using TotalSegmentator. The development setup automatically includes the required dependencies (torch, torchvision, TotalSegmentator), so you can immediately work with and test segmentation tools: + +```python +from pytheranostics.segmentation import total_seg_pipeline + +# You can now use TotalSegmentator without additional installation +``` + +If you prefer a lightweight installation without segmentation support, manually install the package without the `dev` extras (though this is not recommended for development). + ### Pre-commit Hooks Pre-commit hooks automatically run quality checks when you commit code. They only check **files you've modified**, making them non-disruptive to existing code: diff --git a/README.md b/README.md index a3c4b04..c543357 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,22 @@ PyTheranostics is a powerful toolkit designed for processing nuclear medicine sc ## Installation +### Basic Installation + ```bash pip install pytheranostics ``` +### Installation with Segmentation Support + +If you plan to use the automatic segmentation tools with TotalSegmentator: + +```bash +pip install pytheranostics[totalsegmentator] +``` + +This installs additional dependencies required for segmentation (torch, torchvision, and TotalSegmentator). + ## Quick Start ```python diff --git a/pyproject.toml b/pyproject.toml index 5cbc512..91ca8f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,11 @@ dependencies = [ "Bug Tracker" = "https://github.com/qurit/PyTheranostics/issues" [project.optional-dependencies] +totalsegmentator = [ + "torch", + "torchvision", + "TotalSegmentator", +] test = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -75,7 +80,10 @@ dev = [ "pandoc>=2.4", "ipython>=8.0", "pydocstyle>=6.0", - "flake8-docstrings>=1.7" + "flake8-docstrings>=1.7", + "torch", + "torchvision", + "TotalSegmentator" ] [tool.hatch.build] diff --git a/setup_dev.py b/setup_dev.py index d9333b2..db7f0ee 100644 --- a/setup_dev.py +++ b/setup_dev.py @@ -48,8 +48,9 @@ def main(): [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], check=True ) - # Install package in editable mode with dev dependencies + # Install package in editable mode with dev dependencies (includes segmentation tools) print("\nšŸ”§ Installing PyTheranostics in editable mode with dev dependencies...") + print(" This includes segmentation tools (TotalSegmentator, torch, torchvision)") subprocess.run([sys.executable, "-m", "pip", "install", "-e", ".[dev]"], check=True) # Install and setup pre-commit hooks From 7aafeec186788a7e2ca9ac66be6139c1d11c02d4 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 08:24:15 -0800 Subject: [PATCH 02/21] Create .json template for configuration of TotalSegmentator VOIs for user flexibility --- .../project_templates/total_seg_config.json | 653 ++++++++++++++++++ 1 file changed, 653 insertions(+) create mode 100644 pytheranostics/data/project_templates/total_seg_config.json diff --git a/pytheranostics/data/project_templates/total_seg_config.json b/pytheranostics/data/project_templates/total_seg_config.json new file mode 100644 index 0000000..fb7db0b --- /dev/null +++ b/pytheranostics/data/project_templates/total_seg_config.json @@ -0,0 +1,653 @@ +{ + "vois": [ + { + "voi_name": "adrenal_gland_left", + "include": true, + "new_name": null + }, + { + "voi_name": "adrenal_gland_right", + "include": true, + "new_name": null + }, + { + "voi_name": "aorta", + "include": true, + "new_name": null + }, + { + "voi_name": "atrial_appendage_left", + "include": false, + "new_name": null + }, + { + "voi_name": "autochthon_left", + "include": false, + "new_name": null + }, + { + "voi_name": "autochthon_right", + "include": false, + "new_name": null + }, + { + "voi_name": "brachiocephalic_trunk", + "include": false, + "new_name": null + }, + { + "voi_name": "brachiocephalic_vein_left", + "include": false, + "new_name": null + }, + { + "voi_name": "brachiocephalic_vein_right", + "include": false, + "new_name": null + }, + { + "voi_name": "brain", + "include": false, + "new_name": null + }, + { + "voi_name": "clavicula_left", + "include": false, + "new_name": null + }, + { + "voi_name": "clavicula_right", + "include": false, + "new_name": null + }, + { + "voi_name": "colon", + "include": false, + "new_name": null + }, + { + "voi_name": "common_carotid_artery_left", + "include": false, + "new_name": null + }, + { + "voi_name": "common_carotid_artery_right", + "include": false, + "new_name": null + }, + { + "voi_name": "costal_cartilages", + "include": true, + "new_name": null + }, + { + "voi_name": "duodenum", + "include": false, + "new_name": null + }, + { + "voi_name": "esophagus", + "include": false, + "new_name": null + }, + { + "voi_name": "femur_left", + "include": false, + "new_name": null + }, + { + "voi_name": "femur_right", + "include": false, + "new_name": null + }, + { + "voi_name": "gallbladder", + "include": true, + "new_name": null + }, + { + "voi_name": "gluteus_maximus_left", + "include": false, + "new_name": null + }, + { + "voi_name": "gluteus_maximus_right", + "include": false, + "new_name": null + }, + { + "voi_name": "gluteus_medius_left", + "include": false, + "new_name": null + }, + { + "voi_name": "gluteus_medius_right", + "include": false, + "new_name": null + }, + { + "voi_name": "gluteus_minimus_left", + "include": false, + "new_name": null + }, + { + "voi_name": "gluteus_minimus_right", + "include": false, + "new_name": null + }, + { + "voi_name": "heart", + "include": true, + "new_name": null + }, + { + "voi_name": "hip_left", + "include": true, + "new_name": null + }, + { + "voi_name": "hip_right", + "include": true, + "new_name": null + }, + { + "voi_name": "humerus_left", + "include": false, + "new_name": null + }, + { + "voi_name": "humerus_right", + "include": false, + "new_name": null + }, + { + "voi_name": "iliac_artery_left", + "include": false, + "new_name": null + }, + { + "voi_name": "iliac_artery_right", + "include": false, + "new_name": null + }, + { + "voi_name": "iliac_vena_left", + "include": false, + "new_name": null + }, + { + "voi_name": "iliac_vena_right", + "include": false, + "new_name": null + }, + { + "voi_name": "iliopsoas_left", + "include": false, + "new_name": null + }, + { + "voi_name": "iliopsoas_right", + "include": false, + "new_name": null + }, + { + "voi_name": "inferior_vena_cava", + "include": true, + "new_name": null + }, + { + "voi_name": "kidney_cyst_left", + "include": true, + "new_name": null + }, + { + "voi_name": "kidney_cyst_right", + "include": true, + "new_name": null + }, + { + "voi_name": "kidney_left", + "include": true, + "new_name": null + }, + { + "voi_name": "kidney_right", + "include": true, + "new_name": null + }, + { + "voi_name": "liver", + "include": true, + "new_name": null + }, + { + "voi_name": "lung_lower_lobe_left", + "include": true, + "new_name": null + }, + { + "voi_name": "lung_lower_lobe_right", + "include": true, + "new_name": null + }, + { + "voi_name": "lung_middle_lobe_right", + "include": true, + "new_name": null + }, + { + "voi_name": "lung_upper_lobe_left", + "include": true, + "new_name": null + }, + { + "voi_name": "lung_upper_lobe_right", + "include": true, + "new_name": null + }, + { + "voi_name": "pancreas", + "include": true, + "new_name": null + }, + { + "voi_name": "portal_vein_and_splenic_vein", + "include": false, + "new_name": null + }, + { + "voi_name": "prostate", + "include": false, + "new_name": null + }, + { + "voi_name": "pulmonary_vein", + "include": false, + "new_name": null + }, + { + "voi_name": "rib_left_1", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_10", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_11", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_12", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_2", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_3", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_4", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_5", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_6", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_7", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_8", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_left_9", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_1", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_10", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_11", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_12", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_2", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_3", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_4", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_5", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_6", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_7", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_8", + "include": true, + "new_name": null + }, + { + "voi_name": "rib_right_9", + "include": true, + "new_name": null + }, + { + "voi_name": "sacrum", + "include": true, + "new_name": null + }, + { + "voi_name": "scapula_left", + "include": true, + "new_name": null + }, + { + "voi_name": "scapula_right", + "include": true, + "new_name": null + }, + { + "voi_name": "skull", + "include": false, + "new_name": null + }, + { + "voi_name": "small_bowel", + "include": false, + "new_name": null + }, + { + "voi_name": "spinal_cord", + "include": true, + "new_name": null + }, + { + "voi_name": "spleen", + "include": true, + "new_name": null + }, + { + "voi_name": "sternum", + "include": true, + "new_name": null + }, + { + "voi_name": "stomach", + "include": false, + "new_name": null + }, + { + "voi_name": "subclavian_artery_left", + "include": false, + "new_name": null + }, + { + "voi_name": "subclavian_artery_right", + "include": false, + "new_name": null + }, + { + "voi_name": "superior_vena_cava", + "include": false, + "new_name": null + }, + { + "voi_name": "thyroid_gland", + "include": false, + "new_name": null + }, + { + "voi_name": "trachea", + "include": false, + "new_name": null + }, + { + "voi_name": "urinary_bladder", + "include": false, + "new_name": null + }, + { + "voi_name": "vertebrae_C1", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C2", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C3", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C4", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C5", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C6", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_C7", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_L1", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_L2", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_L3", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_L4", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_L5", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_S1", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T1", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T10", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T11", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T12", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T2", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T3", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T4", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T5", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T6", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T7", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T8", + "include": true, + "new_name": null + }, + { + "voi_name": "vertebrae_T9", + "include": true, + "new_name": null + }, + { + "voi_name": "COMBINE", + "include": false, + "new_name": null + }, + { + "voi_name": "Rib ROI + sternum + scapula", + "include": false, + "new_name": null + }, + { + "voi_name": "Thoracic spine", + "include": false, + "new_name": null + }, + { + "voi_name": "Lumbar spine", + "include": false, + "new_name": null + }, + { + "voi_name": "Sacrum + Pelvis", + "include": false, + "new_name": null + }, + { + "voi_name": "Cervical spine", + "include": false, + "new_name": null + } + ], + "combine": [ + { + "combined_voi_name": "ribs", + "sources": [ + "rib_left_1", + "rib_left_10", + "rib_left_11", + "rib_left_12", + "rib_left_2", + "rib_left_3", + "rib_left_4", + "rib_left_5", + "rib_left_6", + "rib_left_7", + "rib_left_8", + "rib_left_9", + "rib_right_1", + "rib_right_10", + "rib_right_11", + "rib_right_12", + "rib_right_2", + "rib_right_3", + "rib_right_4", + "rib_right_5", + "rib_right_6", + "rib_right_7", + "rib_right_8", + "rib_right_9", + "scapula_left", + "scapula_right", + "sternum" + ] + } + ] +} From e9cef33c0cc585d4b82851db29500f0401e5e83a Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 08:38:31 -0800 Subject: [PATCH 03/21] Add nibabel to the totalsegmentator options --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 91ca8f4..ec73121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ totalsegmentator = [ "torch", "torchvision", "TotalSegmentator", + "nibabel", ] test = [ "pytest>=7.0", From 0b1cc811be482b58c16bfbc30809bf001e6d4641 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 08:42:02 -0800 Subject: [PATCH 04/21] rename tools to rtst_utilities so that is clear and has the tools needed for totalsegmentator --- pytheranostics/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytheranostics/__init__.py b/pytheranostics/__init__.py index 878bd5d..589aa92 100644 --- a/pytheranostics/__init__.py +++ b/pytheranostics/__init__.py @@ -12,7 +12,8 @@ from pytheranostics.qc.dosecal_qc import DosecalQC # Core from pytheranostics.qc.planar_qc import PlanarQC # Core from pytheranostics.qc.spect_qc import SPECTQC # Core -from pytheranostics.segmentation.tools import rtst_to_mask # Image processing + +# Note: segmentation tools require optional dependencies - import from pytheranostics.segmentation from pytheranostics.shared.corrections import tew_scatt from pytheranostics.shared.evaluation_metrics import perc_diff from pytheranostics.shared.radioactive_decay import decay_act, get_activity_at_injection From cc9c169b9dbf09eb4e30ce12cecdd73e54811af0 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 10:43:48 -0800 Subject: [PATCH 05/21] Add the totalsegmentator modules --- pytheranostics/segmentation/__init__.py | 25 +- pytheranostics/segmentation/rtst_utilities.py | 290 ++++++++++++++++++ pytheranostics/segmentation/tools.py | 27 -- .../segmentation/total_seg_pipeline.py | 220 +++++++++++++ .../segmentation/total_seg_segmentation.py | 91 ++++++ 5 files changed, 625 insertions(+), 28 deletions(-) create mode 100644 pytheranostics/segmentation/rtst_utilities.py delete mode 100644 pytheranostics/segmentation/tools.py create mode 100644 pytheranostics/segmentation/total_seg_pipeline.py create mode 100644 pytheranostics/segmentation/total_seg_segmentation.py diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index 3731650..7886fa7 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -1 +1,24 @@ -"""PyTheranostics package.""" +"""PyTheranostics Package. + +Medical image segmentation processing tools. +""" + +from .rtst_utilities import ( + RTStructConverter, + export_multiple_rtstructs_to_csv, + export_rtstruct_rois_to_csv, + get_rtstruct_roi_names, + print_rtstruct_info, +) +from .total_seg_pipeline import run_full_pipeline +from .total_seg_segmentation import SegmentationProcessor + +__all__ = [ + "SegmentationProcessor", + "RTStructConverter", + "get_rtstruct_roi_names", + "print_rtstruct_info", + "export_rtstruct_rois_to_csv", + "export_multiple_rtstructs_to_csv", + "run_full_pipeline", +] diff --git a/pytheranostics/segmentation/rtst_utilities.py b/pytheranostics/segmentation/rtst_utilities.py new file mode 100644 index 0000000..b864be0 --- /dev/null +++ b/pytheranostics/segmentation/rtst_utilities.py @@ -0,0 +1,290 @@ +"""Helpers for working with RT structure sets.""" + +import csv +import os +from pathlib import Path +from typing import List, Optional + +import nibabel as nib +import pydicom +from rt_utils import RTStructBuilder + + +class RTStructConverter: + """Convert NIfTI segmentation masks to DICOM RT-STRUCT.""" + + def __init__(self, ct_dicom_folder: str): + """Initialize the RT-STRUCT converter. + + Parameters + ---------- + ct_dicom_folder : str + Path to CT DICOM series folder. + """ + self.ct_dicom_folder = ct_dicom_folder + self.rtstruct = RTStructBuilder.create_new(dicom_series_path=ct_dicom_folder) + + def add_mask_from_nifti( + self, + mask_path: str, + roi_name: Optional[str] = None, + permute_axes: bool = True, + flip_x: bool = True, + ): + """Add a NIfTI mask as an ROI to the RT-STRUCT. + + Parameters + ---------- + mask_path : str + Path to the NIfTI mask file. + roi_name : str, optional + Name for the ROI (defaults to filename). + permute_axes : bool, optional + Whether to swap X and Y axes, by default True. + flip_x : bool, optional + Whether to flip the X axis, by default True. + """ + mask_path = Path(mask_path) + + # Use filename as ROI name if not provided + if roi_name is None: + roi_name = mask_path.stem.replace(".nii", "") + + # Load the NIfTI mask + mask_nii = nib.load(str(mask_path)) + mask_array = mask_nii.get_fdata().astype(bool) + + # Apply transformations if needed + if permute_axes: + mask_array = mask_array.transpose(1, 0, 2) + if flip_x: + mask_array = mask_array[::-1, :, :] + + # Add to RT-STRUCT + self.rtstruct.add_roi(mask=mask_array, name=roi_name) + print(f"Added ROI: {roi_name}") + + def add_masks_from_folder( + self, nifti_folder: str, permute_axes: bool = True, flip_x: bool = True + ): + """Add all NIfTI masks from a folder to the RT-STRUCT. + + Parameters + ---------- + nifti_folder : str + Path to folder containing NIfTI masks. + permute_axes : bool, optional + Whether to swap X and Y axes, by default True. + flip_x : bool, optional + Whether to flip the X axis, by default True. + """ + nifti_folder = Path(nifti_folder) + + for fname in os.listdir(nifti_folder): + if fname.endswith(".nii") or fname.endswith(".nii.gz"): + mask_path = nifti_folder / fname + self.add_mask_from_nifti( + str(mask_path), permute_axes=permute_axes, flip_x=flip_x + ) + + def save(self, output_path: str): + """Save the RT-STRUCT to a DICOM file. + + Parameters + ---------- + output_path : str + Path for the output RT-STRUCT file. + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + self.rtstruct.save(str(output_path)) + print(f"RT-STRUCT saved to: {output_path}") + + +def get_rtstruct_roi_names(rtstruct_path: str) -> List[str]: + """Get list of ROI names from an RT-STRUCT file. + + Parameters + ---------- + rtstruct_path : str + Path to the RT-STRUCT DICOM file. + + Returns + ------- + List[str] + List of ROI names. + """ + ds = pydicom.dcmread(rtstruct_path) + roi_names = [] + + if hasattr(ds, "StructureSetROISequence"): + for roi in ds.StructureSetROISequence: + roi_names.append(roi.ROIName) + + return roi_names + + +def print_rtstruct_info(rtstruct_path: str): + """Print detailed information about an RT-STRUCT file. + + Parameters + ---------- + rtstruct_path : str + Path to the RT-STRUCT DICOM file. + """ + ds = pydicom.dcmread(rtstruct_path) + + print(f"\n{'='*60}") + print(f"RT-STRUCT: {Path(rtstruct_path).name}") + print(f"{'='*60}") + + # Basic info + if hasattr(ds, "PatientName"): + print(f"Patient Name: {ds.PatientName}") + if hasattr(ds, "PatientID"): + print(f"Patient ID: {ds.PatientID}") + if hasattr(ds, "StudyDate"): + print(f"Study Date: {ds.StudyDate}") + if hasattr(ds, "StructureSetLabel"): + print(f"Structure Set Label: {ds.StructureSetLabel}") + + # ROI information + if hasattr(ds, "StructureSetROISequence"): + print(f"\nNumber of ROIs: {len(ds.StructureSetROISequence)}") + print("\nROI List:") + for i, roi in enumerate(ds.StructureSetROISequence, 1): + roi_number = roi.ROINumber + roi_name = roi.ROIName + print(f" {i}. [{roi_number}] {roi_name}") + else: + print("\nNo ROIs found in this RT-STRUCT") + + print(f"{'='*60}\n") + + +def export_rtstruct_rois_to_csv(rtstruct_path: str, output_csv: str): + """Export RT-STRUCT ROI information to a CSV file. + + Parameters + ---------- + rtstruct_path : str + Path to the RT-STRUCT DICOM file. + output_csv : str + Path for the output CSV file. + """ + ds = pydicom.dcmread(rtstruct_path) + + # Prepare data for CSV + rows = [] + + if hasattr(ds, "StructureSetROISequence"): + for roi in ds.StructureSetROISequence: + roi_number = roi.ROINumber + roi_name = roi.ROIName + rows.append( + { + "ROI_Number": roi_number, + "ROI_Name": roi_name, + "RT_STRUCT_File": Path(rtstruct_path).name, + } + ) + + # Write to CSV + output_path = Path(output_csv) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w", newline="") as csvfile: + if rows: + fieldnames = ["ROI_Number", "ROI_Name", "RT_STRUCT_File"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + print(f"āœ“ Exported {len(rows)} ROIs to: {output_path}") + else: + print(f"āš ļø No ROIs found in {rtstruct_path}") + + +def export_multiple_rtstructs_to_csv(rtstruct_dir: str, output_csv: str): + """Export ROI information from multiple RT-STRUCT files to a single CSV. + + Parameters + ---------- + rtstruct_dir : str + Directory containing RT-STRUCT files. + output_csv : str + Path for the output CSV file. + """ + rtstruct_dir = Path(rtstruct_dir) + all_rows = [] + + # Process all DICOM files in the directory (recursively) + for dcm_file in sorted(rtstruct_dir.rglob("*.dcm")): + try: + ds = pydicom.dcmread(str(dcm_file)) + + # Extract timepoint from filename (e.g., "rtstruct_0p5h.dcm" -> "0p5h") + filename = dcm_file.stem # rtstruct_0p5h + timepoint = filename.replace("rtstruct_", "") + # Patient folder assumed to be parent directory name + patient_id = dcm_file.parent.name + + if hasattr(ds, "StructureSetROISequence"): + for roi in ds.StructureSetROISequence: + all_rows.append( + { + "PatientID": patient_id, + "Timepoint": timepoint, + "ROI_Number": roi.ROINumber, + "ROI_Name": roi.ROIName, + "RT_STRUCT_File": dcm_file.name, + } + ) + except Exception as e: + print(f"āš ļø Error processing {dcm_file.name}: {e}") + + # Write to CSV + output_path = Path(output_csv) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if all_rows: + with open(output_path, "w", newline="") as csvfile: + fieldnames = [ + "PatientID", + "Timepoint", + "ROI_Number", + "ROI_Name", + "RT_STRUCT_File", + ] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(all_rows) + print( + f"āœ“ Exported {len(all_rows)} ROIs from {len(set((r['PatientID'], r['Timepoint']) for r in all_rows))} patient-timepoints to: {output_path}" + ) + else: + print(f"āš ļø No ROIs found in {rtstruct_dir}") + + +def rtst_to_mask(dicom_series_path, rt_struct_path): + """Load an RTSTRUCT and return a dict of ROI masks keyed by ROI name.""" + # Load existing RT Struct. Requires the series path and existing RT Struct path + rtstruct = RTStructBuilder.create_from( + dicom_series_path=dicom_series_path, rt_struct_path=rt_struct_path + ) + + # View all of the ROI names from within the image + print(rtstruct.get_roi_names()) + rois = rtstruct.get_roi_names() + + # Loading the 3D Mask from within the RT Struct + mask_3d = {} + + for voi in rois: + mask_3d[voi] = rtstruct.get_roi_mask_by_name(voi) + + return mask_3d + # # Display one slice of the region + # first_mask_slice = mask_3d[voi][:, :, 0] + # plt.imshow(first_mask_slice) + # plt.show() diff --git a/pytheranostics/segmentation/tools.py b/pytheranostics/segmentation/tools.py deleted file mode 100644 index f18b29b..0000000 --- a/pytheranostics/segmentation/tools.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Helpers for working with RT structure sets.""" - -from rt_utils import RTStructBuilder - - -def rtst_to_mask(dicom_series_path, rt_struct_path): - """Load an RTSTRUCT and return a dict of ROI masks keyed by ROI name.""" - # Load existing RT Struct. Requires the series path and existing RT Struct path - rtstruct = RTStructBuilder.create_from( - dicom_series_path=dicom_series_path, rt_struct_path=rt_struct_path - ) - - # View all of the ROI names from within the image - print(rtstruct.get_roi_names()) - rois = rtstruct.get_roi_names() - - # Loading the 3D Mask from within the RT Struct - mask_3d = {} - - for voi in rois: - mask_3d[voi] = rtstruct.get_roi_mask_by_name(voi) - - return mask_3d - # # Display one slice of the region - # first_mask_slice = mask_3d[voi][:, :, 0] - # plt.imshow(first_mask_slice) - # plt.show() diff --git a/pytheranostics/segmentation/total_seg_pipeline.py b/pytheranostics/segmentation/total_seg_pipeline.py new file mode 100644 index 0000000..89ed376 --- /dev/null +++ b/pytheranostics/segmentation/total_seg_pipeline.py @@ -0,0 +1,220 @@ +"""High-level pipeline to run segmentation, RT-STRUCT conversion, and ROI CSV export. + +With minimal notebook code. +""" + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import pydicom + +from .rtst_utilities import RTStructConverter, export_multiple_rtstructs_to_csv +from .total_seg_segmentation import SegmentationProcessor + + +def _seg_one_worker(args: Tuple[str, str, str, str, str]): + """Top-level worker function for process pool (must be picklable). + + Args tuple: (ct_path, patient_id, timepoint, out_dir, device) + """ + ct_path_str, patient_id, tp, out_dir_str, device = args + ct_path = Path(ct_path_str) + out_dir = Path(out_dir_str) + print( + f"Segmentation: patient={patient_id} timepoint={tp}\n CT={ct_path}\n OUT={out_dir}" + ) + out_dir.mkdir(parents=True, exist_ok=True) + from totalsegmentator.python_api import totalsegmentator + + totalsegmentator(str(ct_path), str(out_dir), device=device) + + +def _discover_ct_series(root_dir: str | Path) -> List[Path]: + """Recursively find CT series folders under a root directory. + + Heuristics: + - Directory name contains 'CT' + - Directory name does NOT contain 'RTst' or 'NM' + - Timepoint token like '-CT.<...>h' is present (validated later) + """ + root = Path(root_dir) + candidates: List[Path] = [] + for p in root.rglob("*"): + if p.is_dir(): + name = p.name + if "CT" in name and "RTst" not in name and "NM" not in name: + candidates.append(p) + return candidates + + +def _read_patient_id_from_series(series_dir: Path) -> Optional[str]: + """Read PatientID from any DICOM file within a CT series folder. + + Returns a sanitized PatientID or None if not found. + """ + try: + # Try a few files in the series directory + for f in series_dir.iterdir(): + if f.is_file(): + try: + ds = pydicom.dcmread(str(f), stop_before_pixels=True, force=True) + pid = getattr(ds, "PatientID", None) + if pid: + return _sanitize_id(str(pid)) + except Exception: + continue + except FileNotFoundError: + pass + return None + + +def _sanitize_id(text: str) -> str: + return "".join( + ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in text + ).strip("_") + + +def run_full_pipeline( + input_folders: Optional[List[str]] = None, + base_output_dir: str | Path = ".", + rtstruct_output_dir: str | Path = "./RTStructs", + *, + root_dir: Optional[str | Path] = None, + device: str = "mps", + parallel: bool = False, + max_workers: int = 2, + export_csv: bool = True, +) -> Dict[str, Dict[str, Path]]: + """Run the complete workflow. + + 1) Discover CT DICOM series under a root directory (if provided) or use input_folders + 2) Segment each input CT series with TotalSegmentator into per-patient/per-timepoint subfolders + 3) Convert all masks to RT-STRUCT per timepoint per patient + 4) Optionally export ROI inventory CSV (recursively) under rtstruct_output_dir + + Parameters + ---------- + input_folders : List[str], optional + Explicit list of CT DICOM series folders (overrides discovery if given). + base_output_dir : str | Path, optional + Base directory where TotalSegmentator results are written, by default '.'. + rtstruct_output_dir : str | Path, optional + Directory where RT-STRUCT files will be saved, by default './RTStructs'. + root_dir : str | Path, optional + Root folder to discover CT series (if input_folders is None). + device : str, optional + 'mps' (Apple), 'cuda', or 'cpu', by default "mps". + parallel : bool, optional + If True, run segmentation in parallel, by default False. + max_workers : int, optional + Number of workers for parallel processing, by default 2. + export_csv : bool, optional + If True, writes all_rois.csv in rtstruct_output_dir (recursive), by default True. + + Returns + ------- + Dict[str, Dict[str, Path]] + Mapping {patient_id -> {timepoint -> rtstruct_path}}. + """ + base_output_dir = Path(base_output_dir) + rtstruct_output_dir = Path(rtstruct_output_dir) + rtstruct_output_dir.mkdir(parents=True, exist_ok=True) + + # Determine and announce effective device + resolved_device = device + try: + import torch # local import to avoid hard dependency at import time + + mps_avail = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() + cuda_avail = torch.cuda.is_available() + + if device.lower() == "auto": + if cuda_avail: + resolved_device = "cuda" + elif mps_avail: + resolved_device = "mps" + else: + resolved_device = "cpu" + elif device.lower() == "cuda" and not cuda_avail: + print("Requested CUDA but not available; falling back to CPU") + resolved_device = "cpu" + elif device.lower() == "mps" and not mps_avail: + print("Requested MPS but not available; falling back to CPU") + resolved_device = "cpu" + else: + resolved_device = device.lower() + except Exception: + resolved_device = device.lower() + + print(f"Using device: {resolved_device.upper()}") + + # 0) Determine input series + if input_folders is None or len(input_folders) == 0: + if root_dir is None: + raise ValueError("Provide either input_folders or root_dir for discovery.") + discovered = _discover_ct_series(root_dir) + if not discovered: + raise FileNotFoundError(f"No CT series found under root: {root_dir}") + input_paths = discovered + else: + input_paths = [Path(p) for p in input_folders] + + # 1) Prepare segmentation tasks: compute patient_id and timepoint + sp = SegmentationProcessor(str(base_output_dir), device=resolved_device) + tasks: List[Tuple[Path, str, str, Path]] = ( + [] + ) # (ct_path, patient_id, timepoint, out_dir) + + for ct_path in input_paths: + tp = sp.extract_timepoint(ct_path) + if tp == "unknown": + # Skip entries without a parsable timepoint + continue + patient_id = _read_patient_id_from_series(ct_path) or _sanitize_id( + ct_path.parent.name + ) + out_dir = base_output_dir / patient_id / tp + tasks.append((ct_path, patient_id, tp, out_dir)) + + if not tasks: + raise RuntimeError("No valid CT series with timepoints found.") + + # 2) Run segmentation (optionally in parallel) + if parallel: + from concurrent.futures import ProcessPoolExecutor + + # Convert tasks into simple tuples of strings for pickling safety + safe_tasks = [ + (str(ct_path), patient_id, tp, str(out_dir), resolved_device) + for ct_path, patient_id, tp, out_dir in tasks + ] + with ProcessPoolExecutor(max_workers=max_workers) as ex: + list(ex.map(_seg_one_worker, safe_tasks)) + else: + for ct_path, patient_id, tp, out_dir in tasks: + _seg_one_worker( + (str(ct_path), patient_id, tp, str(out_dir), resolved_device) + ) + + # 3) Convert masks to RT-STRUCT per patient/timepoint + patient_map: Dict[str, Dict[str, Path]] = {} + for ct_path, patient_id, tp, out_dir in tasks: + if not out_dir.exists(): + print(f"āš ļø Skipping RT-STRUCT for {patient_id}/{tp}: {out_dir} not found") + continue + rt_out_dir = rtstruct_output_dir / patient_id + rt_out_dir.mkdir(parents=True, exist_ok=True) + out_file = rt_out_dir / f"rtstruct_{tp}.dcm" + print(f"RT-STRUCT: patient={patient_id} timepoint={tp} -> {out_file}") + converter = RTStructConverter(ct_dicom_folder=str(ct_path)) + converter.add_masks_from_folder(str(out_dir), permute_axes=True, flip_x=True) + converter.save(str(out_file)) + patient_map.setdefault(patient_id, {})[tp] = out_file + + # 4) Export a single CSV for all RT-STRUCTs (recursive) + if export_csv: + export_multiple_rtstructs_to_csv( + str(rtstruct_output_dir), str(rtstruct_output_dir / "all_rois.csv") + ) + + return patient_map diff --git a/pytheranostics/segmentation/total_seg_segmentation.py b/pytheranostics/segmentation/total_seg_segmentation.py new file mode 100644 index 0000000..8756e27 --- /dev/null +++ b/pytheranostics/segmentation/total_seg_segmentation.py @@ -0,0 +1,91 @@ +"""Segmentation processing module for TotalSegmentator workflows.""" + +import re +from pathlib import Path +from typing import List + +from totalsegmentator.python_api import totalsegmentator + + +class SegmentationProcessor: + """Handler for batch processing CT scans with TotalSegmentator.""" + + def __init__(self, base_output_dir: str, device: str = "mps"): + """Initialize the segmentation processor. + + Parameters + ---------- + base_output_dir : str + Base directory for all segmentation outputs. + device : str, optional + Computing device ('mps', 'cuda', or 'cpu'), by default "mps". + """ + self.base_output_dir = Path(base_output_dir) + self.device = device + + def extract_timepoint(self, folder_path: Path) -> str: + """Extract timepoint from folder name using regex. + + Parameters + ---------- + folder_path : Path + Path to the input folder. + + Returns + ------- + str + Timepoint string (e.g., '0p5h', '6h', '24h'). + """ + match = re.search(r"CT\.(\d+(?:\.\d+)?h)", folder_path.name) + if match: + timepoint = match.group(1).replace(".", "p") + return timepoint + return "unknown" + + def process_folder(self, input_folder: str) -> str: + """Process a single input folder with TotalSegmentator. + + Parameters + ---------- + input_folder : str + Path to input DICOM folder. + + Returns + ------- + str + Timepoint identifier. + """ + input_path = Path(input_folder) + timepoint = self.extract_timepoint(input_path) + output_subfolder = self.base_output_dir / timepoint + + print(f"Processing {timepoint}: {input_path}") + print(f"Output to: {output_subfolder}") + + totalsegmentator(str(input_path), str(output_subfolder), device=self.device) + + print(f"āœ“ Completed {timepoint}") + return timepoint + + def process_batch( + self, input_folders: List[str], parallel: bool = False, max_workers: int = 2 + ): + """Process multiple input folders. + + Parameters + ---------- + input_folders : List[str] + List of input folder paths. + parallel : bool, optional + Whether to process in parallel, by default False. + max_workers : int, optional + Number of parallel workers (if parallel=True), by default 2. + """ + if parallel: + from concurrent.futures import ProcessPoolExecutor + + with ProcessPoolExecutor(max_workers=max_workers) as executor: + executor.map(self.process_folder, input_folders) + else: + for folder in input_folders: + self.process_folder(folder) From 76b2abb0be7a0f88ca19cc45063fd25099c47330 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 15:01:26 -0800 Subject: [PATCH 06/21] Create fetchers modules to download data for testing and evaluation. --- docs/EXAMPLE_DATA_GUIDE.md | 291 +++++++++++++++++++++++ pytheranostics/__init__.py | 1 + pytheranostics/data_fetchers/__init__.py | 21 ++ pytheranostics/data_fetchers/fetchers.py | 257 ++++++++++++++++++++ 4 files changed, 570 insertions(+) create mode 100644 docs/EXAMPLE_DATA_GUIDE.md create mode 100644 pytheranostics/data_fetchers/__init__.py create mode 100644 pytheranostics/data_fetchers/fetchers.py diff --git a/docs/EXAMPLE_DATA_GUIDE.md b/docs/EXAMPLE_DATA_GUIDE.md new file mode 100644 index 0000000..7bb9e25 --- /dev/null +++ b/docs/EXAMPLE_DATA_GUIDE.md @@ -0,0 +1,291 @@ +# Managing Example Data for PyTheranostics + +This guide explains how PyTheranostics manages example DICOM data for tutorials and testing. + +## Current Data Source + +PyTheranostics uses example data hosted in the **University of Michigan Deep Blue repository**: + +- **DOI**: https://doi.org/10.7302/864r-tb45 +- **Dataset**: Multi-timepoint Lu-177 SPECT/CT for Patient 004 +- **Download URL**: https://deepblue.lib.umich.edu/data/downloads/tb09j589z +- **More details**: https://deepblue.lib.umich.edu/data/concern/data_sets/th83kz366?locale=en + +## How Users Access Data + +Users simply run: + +```python +from pytheranostics.data_fetchers import fetch_snmmi_dosimetry_challenge + +fetch_snmmi_dosimetry_challenge() +# Output: Data ready at ~/.pytheranostics_example_data/snmmi_dose_challenge + +# Access multi-timepoint SPECT/CT data +from pathlib import Path +data_home = Path.home() / ".pytheranostics_example_data" / "snmmi_dose_challenge" +patient_004 = data_home / "Patient_004" / "SPECT_Cts" + +# List available scans (4 timepoints) +scans = sorted([d.name for d in patient_004.iterdir() if d.is_dir()]) +print(f"Available scans: {scans}") # ['scan1', 'scan2', 'scan3', 'scan4'] + +# Access specific scan data +scan1_ct = list((patient_004 / "scan1" / "ct").glob("*.dcm")) +scan1_spect = list((patient_004 / "scan1" / "spect").glob("*.dcm")) +print(f"Scan 1 - CT: {len(scan1_ct)} slices, SPECT: {len(scan1_spect)} slices") +``` + +### Automatic Data Organization + +PyTheranostics automatically extracts and organizes the Deep Blue dataset into a clean, scannable structure: + +**Organized to multi-timepoint scan structure:** +``` +~/.pytheranostics_example_data/ +└── snmmi_dose_challenge/ + └── Patient_004/ + └── SPECT_Cts/ + ā”œā”€ā”€ scan1/ + │ ā”œā”€ā”€ ct/ (CT DICOM series) + │ └── spect/ (SPECT DICOM series) + ā”œā”€ā”€ scan2/ + │ ā”œā”€ā”€ ct/ + │ └── spect/ + ā”œā”€ā”€ scan3/ + │ ā”œā”€ā”€ ct/ + │ └── spect/ + └── scan4/ + ā”œā”€ā”€ ct/ + └── spect/ +``` + +This structure makes it easy to work with longitudinal/multi-timepoint data for dosimetry calculations! + +## How to Update Example Data (For Maintainers) + +If you need to update the example datasets: + +### Uploading to GitHub Releases + +1. **Prepare your data:** + - Anonymize all DICOM files (remove PHI/PII) + - Organize in logical directory structure + - Create ZIP archive + - Verify integrity + +2. **Calculate checksum:** + ```bash + md5sum example_ct_data.zip + # or on macOS: + md5 example_ct_data.zip + ``` + Save this checksum - you'll need it for the download function. + +3. **Create GitHub Release:** + - Go to: https://github.com/qurit/PyTheranostics/releases + - Click "Create a new release" + - Tag version: `data-v1.0` (or increment as needed) + - Release title: "Example Data v1.0" + - Description: + ``` + Example anonymized CT and SPECT DICOM data for PyTheranostics tutorials. + + - Size: XX MB + - Content: Anonymized medical imaging data for segmentation and dosimetry testing + - Anonymization: All PHI removed per HIPAA/GDPR requirements + - Source: University of Michigan + - DOI: https://doi.org/10.7302/864r-tb45 + ``` + - Upload your ZIP files (both `example_ct_data.zip` and `example_spect_data.zip`) + - Publish release + +4. **Update download function:** + Edit `pytheranostics/data/example_data.py`: + ```python + # Get actual download URL from release assets page + ct_url = "https://github.com/qurit/PyTheranostics/releases/download/data-v1.0/example_ct_data.zip" + spect_url = "https://github.com/qurit/PyTheranostics/releases/download/data-v1.0/example_spect_data.zip" + + # Use checksums from step 2 + ct_md5 = "abc123def456..." + spect_md5 = "xyz789uvw123..." + ``` + +5. **Test the download:** + ```python + from pytheranostics.data_fetchers import fetch_snmmi_dosimetry_challenge + + # Test download with force_download=True + fetch_snmmi_dosimetry_challenge() + + print("Data download test successful!") + ``` + +6. **Update documentation:** + - Update links in README + - Update this guide with new release version + - Update citation information if needed + - Update tutorial documentation + +## Data Preparation Requirements + +### Anonymization + +Before uploading **any** medical data: + +- [ ] Remove patient name, ID, date of birth +- [ ] Remove study dates (or shift consistently) +- [ ] Remove institution/physician names +- [ ] Strip all identifiable metadata +- [ ] Use DICOM anonymization tool to verify +- [ ] Obtain IRB approval if required +- [ ] Check institutional data sharing policy +- [ ] Document anonymization process + +### DICOM Quality + +**For CT Data:** +- Complete series (all slices) +- Proper DICOM metadata (ImagePositionPatient, etc.) +- Consistent spacing and orientation +- Suitable for TotalSegmentator +- Reasonable image quality + +**For SPECT Data:** +- Multiple timepoints (0.5h, 6h, 24h) +- Proper calibration metadata +- Suitable for dosimetry calculations +- Energy windows properly set + +### File Organization + +``` +data/ +ā”œā”€ā”€ Patient_001/ +│ ā”œā”€ā”€ CT_0p5h/ +│ │ ā”œā”€ā”€ 001.dcm +│ │ ā”œā”€ā”€ 002.dcm +│ │ └── ... +│ └── SPECT_0p5h/ +│ ā”œā”€ā”€ 001.dcm +│ └── ... +└── Patient_002/ + ā”œā”€ā”€ CT_24h/ + └── SPECT_24h/ +``` + +## Update Checklist + +When adding/updating example data: + +- [ ] Data properly anonymized and verified +- [ ] DICOM quality checked +- [ ] Compressed and organized +- [ ] Uploaded to repository (Deep Blue, Zenodo, etc.) +- [ ] DOI obtained +- [ ] URLs updated in `pytheranostics/data/example_data.py` +- [ ] Citation information updated +- [ ] Tutorial documentation updated +- [ ] Tested download and extraction +- [ ] Pre-commit checks pass +- [ ] Release notes updated + +## Citing Example Data + +If you use PyTheranostics with the provided example data, cite both: + +```bibtex +@software{pytheranostics2024, + author = {Uribe, Carlos and Esquinas, Pedro and Kurkowska, Sara}, + title = {PyTheranostics: A Python Library for Nuclear Medicine Processing and Dosimetry}, + year = {2024}, + url = {https://github.com/qurit/PyTheranostics} +} + +@dataset{umich_imaging_data, + title = {Example CT and SPECT DICOM Data for Medical Image Processing}, + doi = {10.7302/864r-tb45}, + url = {https://deepblue.lib.umich.edu/}, + note = {University of Michigan Deep Blue Repository} +} +``` + +Get citation programmatically: + +```python +from pytheranostics.data import get_example_data_citation +print(get_example_data_citation()) +``` + +## Accessing Data Programmatically + +```python +from pathlib import Path +from pytheranostics.datasets import fetch_snmmi_dosimetry_challenge + +# Download and organize data +fetch_snmmi_dosimetry_challenge() + +# Access data location +data_home = Path.home() / ".pytheranostics_example_data" / "snmmi_dose_challenge" +patient_004 = data_home / "Patient_004" / "SPECT_Cts" + +# Iterate through all scans +for scan_dir in sorted(patient_004.iterdir()): + if scan_dir.is_dir(): + ct_files = list((scan_dir / "ct").glob("*.dcm")) + spect_files = list((scan_dir / "spect").glob("*.dcm")) + print(f"{scan_dir.name}: {len(ct_files)} CT + {len(spect_files)} SPECT") + +# Access specific scan +scan2_ct_path = patient_004 / "scan2" / "ct" +scan2_spect_path = patient_004 / "scan2" / "spect" +``` + +## Example Anonymization Script + +```python +import pydicom +from pathlib import Path + +def anonymize_dicom(input_dir, output_dir): + """Anonymize DICOM files by removing PHI.""" + input_path = Path(input_dir) + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + for dcm_file in input_path.rglob("*.dcm"): + ds = pydicom.dcmread(dcm_file) + + # Remove patient identifiers + ds.PatientName = "ANONYMOUS" + ds.PatientID = "ANON001" + ds.PatientBirthDate = "" + + # Shift dates consistently + if hasattr(ds, 'StudyDate'): + ds.StudyDate = "20200101" + if hasattr(ds, 'SeriesDate'): + ds.SeriesDate = "20200101" + + # Remove institution info + ds.InstitutionName = "" + ds.InstitutionAddress = "" + + # Save anonymized file + rel_path = dcm_file.relative_to(input_path) + out_file = output_path / rel_path + out_file.parent.mkdir(parents=True, exist_ok=True) + ds.save_as(out_file) + + print(f"Anonymized {len(list(input_path.rglob('*.dcm')))} files") + print(f"Saved to: {output_path}") + +# Usage +# anonymize_dicom("raw_data/", "anonymized_data/") +``` + +## Questions? + +Contact the PyTheranostics maintainers or open an issue on GitHub. diff --git a/pytheranostics/__init__.py b/pytheranostics/__init__.py index 589aa92..35c198a 100644 --- a/pytheranostics/__init__.py +++ b/pytheranostics/__init__.py @@ -28,6 +28,7 @@ "shared": "pytheranostics.shared", "plots": "pytheranostics.plots", "qc": "pytheranostics.qc", + "data_fetchers": "pytheranostics.data_fetchers", "dicomtools": "pytheranostics.dicomtools", "fits": "pytheranostics.fits", "calibrations": "pytheranostics.calibrations", diff --git a/pytheranostics/data_fetchers/__init__.py b/pytheranostics/data_fetchers/__init__.py new file mode 100644 index 0000000..cadb9a5 --- /dev/null +++ b/pytheranostics/data_fetchers/__init__.py @@ -0,0 +1,21 @@ +"""Data fetchers module for PyTheranostics. + +Provides simple functions to download and access example datasets for tutorials +and testing. +""" + +from .fetchers import ( + clear_data_cache, + fetch_snmmi_dosimetry_challenge, + get_data_home, + get_example_data_citation, + list_cached_data, +) + +__all__ = [ + "fetch_snmmi_dosimetry_challenge", + "get_data_home", + "clear_data_cache", + "list_cached_data", + "get_example_data_citation", +] diff --git a/pytheranostics/data_fetchers/fetchers.py b/pytheranostics/data_fetchers/fetchers.py new file mode 100644 index 0000000..270f2f2 --- /dev/null +++ b/pytheranostics/data_fetchers/fetchers.py @@ -0,0 +1,257 @@ +"""Download and cache example data for tutorials and testing.""" + +import hashlib +import shutil +import zipfile +from pathlib import Path +from typing import Optional +from urllib.request import Request, urlopen + + +def get_data_home(data_home: Optional[str] = None) -> Path: + """Return the path to the pytheranostics example data directory. + + By default, data is stored in the user's cache directory. + + Parameters + ---------- + data_home : str, optional + The path to the pytheranostics example data directory. If None, the default + path is used: `~/.pytheranostics_example_data`. + + Returns + ------- + Path + The path to the data home directory. + """ + if data_home is None: + data_home = Path.home() / ".pytheranostics_example_data" + else: + data_home = Path(data_home) + + data_home.mkdir(parents=True, exist_ok=True) + return data_home + + +def _verify_checksum(filepath: Path, expected_md5: str) -> bool: + """Verify file integrity using MD5 checksum. + + Parameters + ---------- + filepath : Path + Path to the file to verify. + expected_md5 : str + Expected MD5 hash. + + Returns + ------- + bool + True if checksum matches, False otherwise. + """ + md5_hash = hashlib.md5() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + md5_hash.update(chunk) + return md5_hash.hexdigest() == expected_md5 + + +def clear_data_cache(data_home: Optional[str] = None): + """Remove all cached example data. + + Parameters + ---------- + data_home : str, optional + The path to the pytheranostics data directory. If None, uses default. + + Examples + -------- + >>> from pytheranostics.data import clear_data_cache + >>> clear_data_cache() + """ + data_home = get_data_home(data_home) + if data_home.exists(): + shutil.rmtree(data_home) + print(f"Cleared data cache at: {data_home}") + else: + print("No cached data to clear") + + +def list_cached_data(data_home: Optional[str] = None): + """List all cached example datasets. + + Parameters + ---------- + data_home : str, optional + The path to the pytheranostics data directory. If None, uses default. + + Examples + -------- + >>> from pytheranostics.data import list_cached_data + >>> list_cached_data() + """ + data_home = get_data_home(data_home) + if not data_home.exists(): + print("No cached data found") + return + + print(f"Cached data in {data_home}:") + for item in data_home.iterdir(): + if item.is_dir(): + size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file()) + size_mb = size / (1024 * 1024) + print(f" - {item.name}: {size_mb:.2f} MB") + + +def get_example_data_citation() -> str: + """Get proper citation for example datasets. + + Returns + ------- + str + BibTeX citation for the example data. + """ + citation = """@dataset{umich_imaging_data_2024, + title = {Example CT and SPECT DICOM Data for Medical Image Processing}, + author = {Contributors}, + year = {2024}, + doi = {10.7302/864r-tb45}, + url = {https://deepblue.lib.umich.edu/}, + note = {University of Michigan Deep Blue Repository} +}""" + return citation + + +def fetch_snmmi_dosimetry_challenge( + data_home: Optional[str] = None, download: bool = True +) -> None: + """Fetch the SNMMI Dosimetry Challenge dataset. + + This dataset contains anonymized CT and SPECT DICOM images suitable for + testing segmentation and dosimetry workflows. Data is sourced from the + University of Michigan Deep Blue repository (DOI: 10.7302/864r-tb45). + + Parameters + ---------- + data_home : str, optional + The path to store the data. If None, uses `~/.pytheranostics_example_data`. + download : bool, optional + If True (default), download the data if not already present. + If False, raise an error if data is not found. + + Returns + ------- + None + Prints download information to console. Access data from the cache directory. + + Raises + ------ + RuntimeError + If download=False and data is not found locally. + + Examples + -------- + >>> from pytheranostics.data_fetchers import fetch_snmmi_dosimetry_challenge + >>> fetch_snmmi_dosimetry_challenge() + Downloading multi-timepoint Lu-177 SPECT/CT data... + Extracting... + Extraction complete āœ“ + + Data ready at: ~/.pytheranostics_example_data/snmmi_dose_challenge + + >>> # Access multi-timepoint data + >>> from pathlib import Path + >>> data_home = Path.home() / ".pytheranostics_example_data" / "snmmi_dose_challenge" + >>> patient_004 = data_home / "Patient_004" / "SPECT_Cts" + >>> + >>> # List available scans (scan1-scan4) + >>> scans = sorted([d.name for d in patient_004.iterdir() if d.is_dir()]) + >>> print(scans) # ['scan1', 'scan2', 'scan3', 'scan4'] + >>> + >>> # Access specific scan data + >>> scan1_ct = list((patient_004 / "scan1" / "ct").glob("*.dcm")) + >>> scan1_spect = list((patient_004 / "scan1" / "spect").glob("*.dcm")) + + Notes + ----- + - Data is automatically cached in `~/.pytheranostics_example_data/` + - First download may take several minutes depending on connection speed + - Subsequent calls use cached data instantly + - Dataset contains multi-timepoint SPECT/CT for Patient_004 + + References + ---------- + Dataset DOI: https://doi.org/10.7302/864r-tb45 + Repository: https://deepblue.lib.umich.edu/ + """ + home = get_data_home(str(data_home) if data_home else None) + + dataset_base = "snmmi_dose_challenge" + patient_dir = home / dataset_base / "Patient_004" + + if download: + if patient_dir.exists(): + print(f"Example data already exists at: {patient_dir}") + print("Use download=False to skip re-download") + else: + url = "https://deepblue.lib.umich.edu/data/downloads/tb09j589z" + zip_path = home / "snmmi_dosimetry_challenge.zip" + + print("Downloading multi-timepoint Lu-177 SPECT/CT data...") + try: + request = Request( + url, + headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Referer": "https://deepblue.lib.umich.edu/", + "DNT": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + }, + ) + with urlopen(request) as response: + with open(zip_path, "wb") as out_file: + out_file.write(response.read()) + except Exception as e: + raise RuntimeError( + f"Failed to download data from Deep Blue: {e}\n\n" + f"If you have the data files locally, place them in:\n" + f"~/.pytheranostics_example_data/snmmi_dose_challenge/" + ) + + print("Extracting...") + temp_extract_dir = home / "snmmi_dosimetry_challenge_temp" + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(temp_extract_dir) + print("Extraction complete āœ“") + + patient_dir.parent.mkdir(parents=True, exist_ok=True) + if patient_dir.exists(): + shutil.rmtree(patient_dir) + + extracted_contents = list(temp_extract_dir.iterdir()) + if len(extracted_contents) == 1 and extracted_contents[0].is_dir(): + shutil.move(str(extracted_contents[0]), str(patient_dir)) + else: + shutil.move(str(temp_extract_dir), str(patient_dir)) + + zip_path.unlink() + if temp_extract_dir.exists(): + shutil.rmtree(temp_extract_dir) + + print(f"\nData ready at: {patient_dir.parent}") + print("\nDataset citation:") + print(" DOI: https://doi.org/10.7302/864r-tb45") + print(" Repository: University of Michigan Deep Blue") + else: + if not patient_dir.exists(): + raise RuntimeError( + f"Dataset not found in {home}. " + "Use fetch_snmmi_dosimetry_challenge() to download, or download manually from: " + "https://deepblue.lib.umich.edu/data/concern/data_sets/th83kz366" + ) From 506a7ae9e6c5925edf56e68be704d2d8ba77c62a Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 15:05:27 -0800 Subject: [PATCH 07/21] Add title for dataset being downloaded --- pytheranostics/data_fetchers/fetchers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pytheranostics/data_fetchers/fetchers.py b/pytheranostics/data_fetchers/fetchers.py index 270f2f2..1755625 100644 --- a/pytheranostics/data_fetchers/fetchers.py +++ b/pytheranostics/data_fetchers/fetchers.py @@ -246,6 +246,7 @@ def fetch_snmmi_dosimetry_challenge( print(f"\nData ready at: {patient_dir.parent}") print("\nDataset citation:") + print(". SNMMI Lu-177 Dosimetry Challenge Dataset") print(" DOI: https://doi.org/10.7302/864r-tb45") print(" Repository: University of Michigan Deep Blue") else: From f290c1f02367183f9870bade15106c7c1926179d Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 15:32:00 -0800 Subject: [PATCH 08/21] Recognize parent folder as timepoint --- .../segmentation/total_seg_pipeline.py | 19 +++++++++++++++++-- .../segmentation/total_seg_segmentation.py | 5 +++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pytheranostics/segmentation/total_seg_pipeline.py b/pytheranostics/segmentation/total_seg_pipeline.py index 89ed376..ad7695c 100644 --- a/pytheranostics/segmentation/total_seg_pipeline.py +++ b/pytheranostics/segmentation/total_seg_pipeline.py @@ -39,10 +39,25 @@ def _discover_ct_series(root_dir: str | Path) -> List[Path]: """ root = Path(root_dir) candidates: List[Path] = [] + + # Include the root itself if it looks like a CT folder + root_name = root.name.lower() + if ( + root.is_dir() + and "ct" in root_name + and "rtst" not in root_name + and "nm" not in root_name + ): + candidates.append(root) + for p in root.rglob("*"): if p.is_dir(): - name = p.name - if "CT" in name and "RTst" not in name and "NM" not in name: + name_lower = p.name.lower() + if ( + "ct" in name_lower + and "rtst" not in name_lower + and "nm" not in name_lower + ): candidates.append(p) return candidates diff --git a/pytheranostics/segmentation/total_seg_segmentation.py b/pytheranostics/segmentation/total_seg_segmentation.py index 8756e27..52a0446 100644 --- a/pytheranostics/segmentation/total_seg_segmentation.py +++ b/pytheranostics/segmentation/total_seg_segmentation.py @@ -40,6 +40,11 @@ def extract_timepoint(self, folder_path: Path) -> str: if match: timepoint = match.group(1).replace(".", "p") return timepoint + + # Fallback: use parent folder name (e.g., scan1/ct -> timepoint 'scan1') + parent_name = folder_path.parent.name + if parent_name: + return parent_name return "unknown" def process_folder(self, input_folder: str) -> str: From b382c6888f887b98b6dcf8a41d73a7edc6166788 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 16:01:40 -0800 Subject: [PATCH 09/21] Add a total_seg_config to determine which regions to include, rename, and combine --- pytheranostics/segmentation/rtst_utilities.py | 104 ++++++++++++++++++ .../segmentation/total_seg_pipeline.py | 19 +++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/pytheranostics/segmentation/rtst_utilities.py b/pytheranostics/segmentation/rtst_utilities.py index b864be0..5c21cd1 100644 --- a/pytheranostics/segmentation/rtst_utilities.py +++ b/pytheranostics/segmentation/rtst_utilities.py @@ -1,6 +1,7 @@ """Helpers for working with RT structure sets.""" import csv +import json import os from pathlib import Path from typing import List, Optional @@ -64,6 +65,109 @@ def add_mask_from_nifti( self.rtstruct.add_roi(mask=mask_array, name=roi_name) print(f"Added ROI: {roi_name}") + def add_masks_from_folder_with_config( + self, + nifti_folder: str, + config_path: str, + permute_axes: bool = True, + flip_x: bool = True, + ): + """Add NIfTI masks using config for filtering, renaming, and combining. + + Parameters + ---------- + nifti_folder : str + Path to folder containing NIfTI masks. + config_path : str + Path to JSON config file with vois and combine rules. + permute_axes : bool, optional + Whether to swap X and Y axes, by default True. + flip_x : bool, optional + Whether to flip the X axis, by default True. + """ + # Load config + with open(config_path, "r") as f: + config = json.load(f) + + # Build include/rename maps from vois + voi_map = {} # voi_name -> (include, new_name) + for voi in config.get("vois", []): + voi_name = voi.get("voi_name", "") + include = voi.get("include", False) + new_name = voi.get("new_name", None) + if voi_name: + voi_map[voi_name.lower()] = (include, new_name) + + # Build combine rules + combine_rules = {} # combined_voi_name -> [sources] + for rule in config.get("combine", []): + combined_name = rule.get("combined_voi_name", "") + sources = rule.get("sources", []) + if combined_name: + combine_rules[combined_name] = sources + + nifti_folder = Path(nifti_folder) + added_masks = {} # mask_name -> roi_name (for combining) + + # Add individual masks (filtered and renamed) + for fname in sorted(os.listdir(nifti_folder)): + if fname.endswith(".nii") or fname.endswith(".nii.gz"): + stem = fname.replace(".nii.gz", "").replace(".nii", "") + stem_lower = stem.lower() + + # Check if included in config + if stem_lower in voi_map: + include, new_name = voi_map[stem_lower] + if not include: + print(f"Skipped (include=False): {stem}") + continue + roi_name = new_name if new_name else stem + else: + # Not in config, skip it + print(f"Skipped (not in config): {stem}") + continue + + mask_path = nifti_folder / fname + self.add_mask_from_nifti( + str(mask_path), + roi_name=roi_name, + permute_axes=permute_axes, + flip_x=flip_x, + ) + added_masks[stem_lower] = roi_name + + # Handle combining + for combined_name, source_list in combine_rules.items(): + # Check if all sources are present + missing = [s for s in source_list if s.lower() not in added_masks] + if missing: + print(f"Skipped combined ROI '{combined_name}': missing {missing}") + continue + + # Load and combine masks + combined_mask = None + for source in source_list: + mask_file = None + for fname in os.listdir(nifti_folder): + if fname.replace(".nii.gz", "").replace(".nii", "") == source: + mask_file = nifti_folder / fname + break + if mask_file: + mask_nii = nib.load(str(mask_file)) + mask_array = mask_nii.get_fdata().astype(bool) + if permute_axes: + mask_array = mask_array.transpose(1, 0, 2) + if flip_x: + mask_array = mask_array[::-1, :, :] + if combined_mask is None: + combined_mask = mask_array + else: + combined_mask = combined_mask | mask_array + + if combined_mask is not None: + self.rtstruct.add_roi(mask=combined_mask, name=combined_name) + print(f"Added combined ROI: {combined_name} (from {source_list})") + def add_masks_from_folder( self, nifti_folder: str, permute_axes: bool = True, flip_x: bool = True ): diff --git a/pytheranostics/segmentation/total_seg_pipeline.py b/pytheranostics/segmentation/total_seg_pipeline.py index ad7695c..776572c 100644 --- a/pytheranostics/segmentation/total_seg_pipeline.py +++ b/pytheranostics/segmentation/total_seg_pipeline.py @@ -213,6 +213,14 @@ def run_full_pipeline( # 3) Convert masks to RT-STRUCT per patient/timepoint patient_map: Dict[str, Dict[str, Path]] = {} + + # Check for config file in current working directory + config_path = Path.cwd() / "total_seg_config.json" + use_config = config_path.exists() + + if use_config: + print(f"Found config at: {config_path}") + for ct_path, patient_id, tp, out_dir in tasks: if not out_dir.exists(): print(f"āš ļø Skipping RT-STRUCT for {patient_id}/{tp}: {out_dir} not found") @@ -222,7 +230,16 @@ def run_full_pipeline( out_file = rt_out_dir / f"rtstruct_{tp}.dcm" print(f"RT-STRUCT: patient={patient_id} timepoint={tp} -> {out_file}") converter = RTStructConverter(ct_dicom_folder=str(ct_path)) - converter.add_masks_from_folder(str(out_dir), permute_axes=True, flip_x=True) + + if use_config: + converter.add_masks_from_folder_with_config( + str(out_dir), str(config_path), permute_axes=True, flip_x=True + ) + else: + converter.add_masks_from_folder( + str(out_dir), permute_axes=True, flip_x=True + ) + converter.save(str(out_file)) patient_map.setdefault(patient_id, {})[tp] = out_file From ca905cbcc75f66b0176f23ae62803038b1a21564 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 16:20:55 -0800 Subject: [PATCH 10/21] Fix bug combining VOIs --- pytheranostics/segmentation/rtst_utilities.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pytheranostics/segmentation/rtst_utilities.py b/pytheranostics/segmentation/rtst_utilities.py index 5c21cd1..c67aeb7 100644 --- a/pytheranostics/segmentation/rtst_utilities.py +++ b/pytheranostics/segmentation/rtst_utilities.py @@ -100,11 +100,14 @@ def add_masks_from_folder_with_config( # Build combine rules combine_rules = {} # combined_voi_name -> [sources] + sources_to_combine = set() # Track which sources are part of combine rules for rule in config.get("combine", []): combined_name = rule.get("combined_voi_name", "") sources = rule.get("sources", []) if combined_name: combine_rules[combined_name] = sources + # Add all sources to the set (case-insensitive) + sources_to_combine.update([s.lower() for s in sources]) nifti_folder = Path(nifti_folder) added_masks = {} # mask_name -> roi_name (for combining) @@ -115,6 +118,12 @@ def add_masks_from_folder_with_config( stem = fname.replace(".nii.gz", "").replace(".nii", "") stem_lower = stem.lower() + # Skip if this will be part of a combined ROI + if stem_lower in sources_to_combine: + print(f"Reserved for combining: {stem}") + added_masks[stem_lower] = stem # Track it but don't add as ROI + continue + # Check if included in config if stem_lower in voi_map: include, new_name = voi_map[stem_lower] @@ -148,8 +157,10 @@ def add_masks_from_folder_with_config( combined_mask = None for source in source_list: mask_file = None + source_lower = source.lower() for fname in os.listdir(nifti_folder): - if fname.replace(".nii.gz", "").replace(".nii", "") == source: + stem = fname.replace(".nii.gz", "").replace(".nii", "") + if stem.lower() == source_lower: mask_file = nifti_folder / fname break if mask_file: @@ -166,7 +177,9 @@ def add_masks_from_folder_with_config( if combined_mask is not None: self.rtstruct.add_roi(mask=combined_mask, name=combined_name) - print(f"Added combined ROI: {combined_name} (from {source_list})") + print( + f"Added combined ROI: {combined_name} (from {len(source_list)} sources)" + ) def add_masks_from_folder( self, nifti_folder: str, permute_axes: bool = True, flip_x: bool = True From b43cabce5f450ffe30228ccb181306697099592b Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 16:45:18 -0800 Subject: [PATCH 11/21] Split total segmentator from RTst generation --- pytheranostics/segmentation/__init__.py | 8 +- .../segmentation/total_seg_pipeline.py | 204 +++++++++++++----- 2 files changed, 160 insertions(+), 52 deletions(-) diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index 7886fa7..fbd8be3 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -10,7 +10,11 @@ get_rtstruct_roi_names, print_rtstruct_info, ) -from .total_seg_pipeline import run_full_pipeline +from .total_seg_pipeline import ( + convert_masks_to_rtstruct, + run_full_pipeline, + run_segmentation_pipeline, +) from .total_seg_segmentation import SegmentationProcessor __all__ = [ @@ -21,4 +25,6 @@ "export_rtstruct_rois_to_csv", "export_multiple_rtstructs_to_csv", "run_full_pipeline", + "run_segmentation_pipeline", + "convert_masks_to_rtstruct", ] diff --git a/pytheranostics/segmentation/total_seg_pipeline.py b/pytheranostics/segmentation/total_seg_pipeline.py index 776572c..d11ff35 100644 --- a/pytheranostics/segmentation/total_seg_pipeline.py +++ b/pytheranostics/segmentation/total_seg_pipeline.py @@ -89,23 +89,19 @@ def _sanitize_id(text: str) -> str: ).strip("_") -def run_full_pipeline( +def run_segmentation_pipeline( input_folders: Optional[List[str]] = None, base_output_dir: str | Path = ".", - rtstruct_output_dir: str | Path = "./RTStructs", *, root_dir: Optional[str | Path] = None, device: str = "mps", parallel: bool = False, max_workers: int = 2, - export_csv: bool = True, ) -> Dict[str, Dict[str, Path]]: - """Run the complete workflow. + """Run CT segmentation with TotalSegmentator. - 1) Discover CT DICOM series under a root directory (if provided) or use input_folders - 2) Segment each input CT series with TotalSegmentator into per-patient/per-timepoint subfolders - 3) Convert all masks to RT-STRUCT per timepoint per patient - 4) Optionally export ROI inventory CSV (recursively) under rtstruct_output_dir + This performs steps 1-2: discovery and segmentation. The output masks can then + be converted to RT-STRUCT multiple times with different configurations. Parameters ---------- @@ -113,8 +109,6 @@ def run_full_pipeline( Explicit list of CT DICOM series folders (overrides discovery if given). base_output_dir : str | Path, optional Base directory where TotalSegmentator results are written, by default '.'. - rtstruct_output_dir : str | Path, optional - Directory where RT-STRUCT files will be saved, by default './RTStructs'. root_dir : str | Path, optional Root folder to discover CT series (if input_folders is None). device : str, optional @@ -123,22 +117,20 @@ def run_full_pipeline( If True, run segmentation in parallel, by default False. max_workers : int, optional Number of workers for parallel processing, by default 2. - export_csv : bool, optional - If True, writes all_rois.csv in rtstruct_output_dir (recursive), by default True. Returns ------- - Dict[str, Dict[str, Path]] - Mapping {patient_id -> {timepoint -> rtstruct_path}}. + dict + Dictionary with keys: + - 'segmentations': {patient_id -> {timepoint -> segmentation_output_dir}} + - 'ct_paths': {patient_id -> {timepoint -> ct_dicom_folder}} """ base_output_dir = Path(base_output_dir) - rtstruct_output_dir = Path(rtstruct_output_dir) - rtstruct_output_dir.mkdir(parents=True, exist_ok=True) # Determine and announce effective device resolved_device = device try: - import torch # local import to avoid hard dependency at import time + import torch mps_avail = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() cuda_avail = torch.cuda.is_available() @@ -174,16 +166,13 @@ def run_full_pipeline( else: input_paths = [Path(p) for p in input_folders] - # 1) Prepare segmentation tasks: compute patient_id and timepoint + # 1) Prepare segmentation tasks sp = SegmentationProcessor(str(base_output_dir), device=resolved_device) - tasks: List[Tuple[Path, str, str, Path]] = ( - [] - ) # (ct_path, patient_id, timepoint, out_dir) + tasks: List[Tuple[Path, str, str, Path]] = [] for ct_path in input_paths: tp = sp.extract_timepoint(ct_path) if tp == "unknown": - # Skip entries without a parsable timepoint continue patient_id = _read_patient_id_from_series(ct_path) or _sanitize_id( ct_path.parent.name @@ -194,11 +183,10 @@ def run_full_pipeline( if not tasks: raise RuntimeError("No valid CT series with timepoints found.") - # 2) Run segmentation (optionally in parallel) + # 2) Run segmentation if parallel: from concurrent.futures import ProcessPoolExecutor - # Convert tasks into simple tuples of strings for pickling safety safe_tasks = [ (str(ct_path), patient_id, tp, str(out_dir), resolved_device) for ct_path, patient_id, tp, out_dir in tasks @@ -211,42 +199,156 @@ def run_full_pipeline( (str(ct_path), patient_id, tp, str(out_dir), resolved_device) ) - # 3) Convert masks to RT-STRUCT per patient/timepoint - patient_map: Dict[str, Dict[str, Path]] = {} + # Return mapping of segmentation outputs and CT paths + seg_map: Dict[str, Dict[str, Path]] = {} + ct_paths: Dict[str, Dict[str, str]] = {} + for ct_path, patient_id, tp, out_dir in tasks: + seg_map.setdefault(patient_id, {})[tp] = out_dir + ct_paths.setdefault(patient_id, {})[tp] = str(ct_path) - # Check for config file in current working directory - config_path = Path.cwd() / "total_seg_config.json" - use_config = config_path.exists() + return {"segmentations": seg_map, "ct_paths": ct_paths} + + +def convert_masks_to_rtstruct( + segmentation_base_dir: str | Path, + ct_series_paths: Dict[str, Dict[str, str]], + rtstruct_output_dir: str | Path = "./RTStructs", + config_path: Optional[str | Path] = None, + export_csv: bool = True, +) -> Dict[str, Dict[str, Path]]: + """Convert NIfTI segmentation masks to RT-STRUCT files. + + This performs steps 3-4: RT-STRUCT conversion and CSV export. Can be run + multiple times with different configs to generate different RT-STRUCTs. + + Parameters + ---------- + segmentation_base_dir : str | Path + Base directory containing segmentation outputs (patient_id/timepoint/*.nii.gz). + ct_series_paths : Dict[str, Dict[str, str]] + Mapping {patient_id -> {timepoint -> ct_dicom_folder}}. + rtstruct_output_dir : str | Path, optional + Directory where RT-STRUCT files will be saved, by default './RTStructs'. + config_path : str | Path, optional + Path to total_seg_config.json. If None, checks current directory. + export_csv : bool, optional + If True, writes all_rois.csv in rtstruct_output_dir, by default True. + + Returns + ------- + Dict[str, Dict[str, Path]] + Mapping {patient_id -> {timepoint -> rtstruct_path}}. + """ + segmentation_base_dir = Path(segmentation_base_dir) + rtstruct_output_dir = Path(rtstruct_output_dir) + rtstruct_output_dir.mkdir(parents=True, exist_ok=True) + # Determine config path + if config_path is None: + config_path = Path.cwd() / "total_seg_config.json" + else: + config_path = Path(config_path) + + use_config = config_path.exists() if use_config: - print(f"Found config at: {config_path}") + print(f"Using config: {config_path}") + else: + print("No config found, adding all masks") - for ct_path, patient_id, tp, out_dir in tasks: - if not out_dir.exists(): - print(f"āš ļø Skipping RT-STRUCT for {patient_id}/{tp}: {out_dir} not found") - continue - rt_out_dir = rtstruct_output_dir / patient_id - rt_out_dir.mkdir(parents=True, exist_ok=True) - out_file = rt_out_dir / f"rtstruct_{tp}.dcm" - print(f"RT-STRUCT: patient={patient_id} timepoint={tp} -> {out_file}") - converter = RTStructConverter(ct_dicom_folder=str(ct_path)) - - if use_config: - converter.add_masks_from_folder_with_config( - str(out_dir), str(config_path), permute_axes=True, flip_x=True - ) - else: - converter.add_masks_from_folder( - str(out_dir), permute_axes=True, flip_x=True - ) + # Convert masks to RT-STRUCT + patient_map: Dict[str, Dict[str, Path]] = {} + + for patient_id, timepoints in ct_series_paths.items(): + for tp, ct_path in timepoints.items(): + mask_dir = segmentation_base_dir / patient_id / tp + if not mask_dir.exists(): + print(f"āš ļø Skipping {patient_id}/{tp}: {mask_dir} not found") + continue + + rt_out_dir = rtstruct_output_dir / patient_id + rt_out_dir.mkdir(parents=True, exist_ok=True) + out_file = rt_out_dir / f"rtstruct_{tp}.dcm" + print(f"RT-STRUCT: patient={patient_id} timepoint={tp} -> {out_file}") - converter.save(str(out_file)) - patient_map.setdefault(patient_id, {})[tp] = out_file + converter = RTStructConverter(ct_dicom_folder=str(ct_path)) - # 4) Export a single CSV for all RT-STRUCTs (recursive) + if use_config: + converter.add_masks_from_folder_with_config( + str(mask_dir), str(config_path), permute_axes=True, flip_x=True + ) + else: + converter.add_masks_from_folder( + str(mask_dir), permute_axes=True, flip_x=True + ) + + converter.save(str(out_file)) + patient_map.setdefault(patient_id, {})[tp] = out_file + + # Export CSV if export_csv: export_multiple_rtstructs_to_csv( str(rtstruct_output_dir), str(rtstruct_output_dir / "all_rois.csv") ) return patient_map + + +def run_full_pipeline( + input_folders: Optional[List[str]] = None, + base_output_dir: str | Path = ".", + rtstruct_output_dir: str | Path = "./RTStructs", + *, + root_dir: Optional[str | Path] = None, + device: str = "mps", + parallel: bool = False, + max_workers: int = 2, + export_csv: bool = True, +) -> Dict[str, Dict[str, Path]]: + """Run the complete workflow (segmentation + RT-STRUCT conversion). + + Convenience function that runs both segmentation and RT-STRUCT conversion. + For more control, use run_segmentation_pipeline() and convert_masks_to_rtstruct() + separately. + + Parameters + ---------- + input_folders : List[str], optional + Explicit list of CT DICOM series folders (overrides discovery if given). + base_output_dir : str | Path, optional + Base directory where TotalSegmentator results are written, by default '.'. + rtstruct_output_dir : str | Path, optional + Directory where RT-STRUCT files will be saved, by default './RTStructs'. + root_dir : str | Path, optional + Root folder to discover CT series (if input_folders is None). + device : str, optional + 'mps' (Apple), 'cuda', or 'cpu', by default "mps". + parallel : bool, optional + If True, run segmentation in parallel, by default False. + max_workers : int, optional + Number of workers for parallel processing, by default 2. + export_csv : bool, optional + If True, writes all_rois.csv in rtstruct_output_dir (recursive), by default True. + + Returns + ------- + Dict[str, Dict[str, Path]] + Mapping {patient_id -> {timepoint -> rtstruct_path}}. + """ + # Step 1-2: Run segmentation + result = run_segmentation_pipeline( + input_folders=input_folders, + base_output_dir=base_output_dir, + root_dir=root_dir, + device=device, + parallel=parallel, + max_workers=max_workers, + ) + + # Step 3-4: Convert to RT-STRUCT and export CSV + return convert_masks_to_rtstruct( + segmentation_base_dir=base_output_dir, + ct_series_paths=result["ct_paths"], + rtstruct_output_dir=rtstruct_output_dir, + config_path=None, # Will auto-detect in cwd + export_csv=export_csv, + ) From 152f08e1be557dfcd86a28bfef09562af5c194c3 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 16:53:22 -0800 Subject: [PATCH 12/21] Consolidate methods into just one total segmentator module --- pytheranostics/segmentation/__init__.py | 4 +- .../segmentation/total_seg_segmentation.py | 96 ------------------- ...l_seg_pipeline.py => total_segmentator.py} | 31 +++++- 3 files changed, 29 insertions(+), 102 deletions(-) delete mode 100644 pytheranostics/segmentation/total_seg_segmentation.py rename pytheranostics/segmentation/{total_seg_pipeline.py => total_segmentator.py} (94%) diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index fbd8be3..0090354 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -10,15 +10,13 @@ get_rtstruct_roi_names, print_rtstruct_info, ) -from .total_seg_pipeline import ( +from .total_segmentator import ( convert_masks_to_rtstruct, run_full_pipeline, run_segmentation_pipeline, ) -from .total_seg_segmentation import SegmentationProcessor __all__ = [ - "SegmentationProcessor", "RTStructConverter", "get_rtstruct_roi_names", "print_rtstruct_info", diff --git a/pytheranostics/segmentation/total_seg_segmentation.py b/pytheranostics/segmentation/total_seg_segmentation.py deleted file mode 100644 index 52a0446..0000000 --- a/pytheranostics/segmentation/total_seg_segmentation.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Segmentation processing module for TotalSegmentator workflows.""" - -import re -from pathlib import Path -from typing import List - -from totalsegmentator.python_api import totalsegmentator - - -class SegmentationProcessor: - """Handler for batch processing CT scans with TotalSegmentator.""" - - def __init__(self, base_output_dir: str, device: str = "mps"): - """Initialize the segmentation processor. - - Parameters - ---------- - base_output_dir : str - Base directory for all segmentation outputs. - device : str, optional - Computing device ('mps', 'cuda', or 'cpu'), by default "mps". - """ - self.base_output_dir = Path(base_output_dir) - self.device = device - - def extract_timepoint(self, folder_path: Path) -> str: - """Extract timepoint from folder name using regex. - - Parameters - ---------- - folder_path : Path - Path to the input folder. - - Returns - ------- - str - Timepoint string (e.g., '0p5h', '6h', '24h'). - """ - match = re.search(r"CT\.(\d+(?:\.\d+)?h)", folder_path.name) - if match: - timepoint = match.group(1).replace(".", "p") - return timepoint - - # Fallback: use parent folder name (e.g., scan1/ct -> timepoint 'scan1') - parent_name = folder_path.parent.name - if parent_name: - return parent_name - return "unknown" - - def process_folder(self, input_folder: str) -> str: - """Process a single input folder with TotalSegmentator. - - Parameters - ---------- - input_folder : str - Path to input DICOM folder. - - Returns - ------- - str - Timepoint identifier. - """ - input_path = Path(input_folder) - timepoint = self.extract_timepoint(input_path) - output_subfolder = self.base_output_dir / timepoint - - print(f"Processing {timepoint}: {input_path}") - print(f"Output to: {output_subfolder}") - - totalsegmentator(str(input_path), str(output_subfolder), device=self.device) - - print(f"āœ“ Completed {timepoint}") - return timepoint - - def process_batch( - self, input_folders: List[str], parallel: bool = False, max_workers: int = 2 - ): - """Process multiple input folders. - - Parameters - ---------- - input_folders : List[str] - List of input folder paths. - parallel : bool, optional - Whether to process in parallel, by default False. - max_workers : int, optional - Number of parallel workers (if parallel=True), by default 2. - """ - if parallel: - from concurrent.futures import ProcessPoolExecutor - - with ProcessPoolExecutor(max_workers=max_workers) as executor: - executor.map(self.process_folder, input_folders) - else: - for folder in input_folders: - self.process_folder(folder) diff --git a/pytheranostics/segmentation/total_seg_pipeline.py b/pytheranostics/segmentation/total_segmentator.py similarity index 94% rename from pytheranostics/segmentation/total_seg_pipeline.py rename to pytheranostics/segmentation/total_segmentator.py index d11ff35..b2662a9 100644 --- a/pytheranostics/segmentation/total_seg_pipeline.py +++ b/pytheranostics/segmentation/total_segmentator.py @@ -9,7 +9,33 @@ import pydicom from .rtst_utilities import RTStructConverter, export_multiple_rtstructs_to_csv -from .total_seg_segmentation import SegmentationProcessor + + +def _extract_timepoint(folder_path: Path) -> str: + """Extract timepoint from folder name using regex. + + Parameters + ---------- + folder_path : Path + Path to the input folder. + + Returns + ------- + str + Timepoint string (e.g., '0p5h', '6h', '24h', or parent folder name). + """ + import re + + match = re.search(r"CT\.(\d+(?:\.\d+)?h)", folder_path.name) + if match: + timepoint = match.group(1).replace(".", "p") + return timepoint + + # Fallback: use parent folder name (e.g., scan1/ct -> timepoint 'scan1') + parent_name = folder_path.parent.name + if parent_name: + return parent_name + return "unknown" def _seg_one_worker(args: Tuple[str, str, str, str, str]): @@ -167,11 +193,10 @@ def run_segmentation_pipeline( input_paths = [Path(p) for p in input_folders] # 1) Prepare segmentation tasks - sp = SegmentationProcessor(str(base_output_dir), device=resolved_device) tasks: List[Tuple[Path, str, str, Path]] = [] for ct_path in input_paths: - tp = sp.extract_timepoint(ct_path) + tp = _extract_timepoint(ct_path) if tp == "unknown": continue patient_id = _read_patient_id_from_series(ct_path) or _sanitize_id( From 907847430c8a2f43d7e52c84d33d4cd66441de61 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Wed, 21 Jan 2026 16:57:58 -0800 Subject: [PATCH 13/21] Rename method to totalseg_segment --- CONTRIBUTING.md | 2 +- pytheranostics/segmentation/__init__.py | 4 ++-- pytheranostics/segmentation/total_segmentator.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 335870d..964a736 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ We love your input! We want to make contributing to PyTheranostics as easy and t Developers can test segmentation features using TotalSegmentator. The development setup automatically includes the required dependencies (torch, torchvision, TotalSegmentator), so you can immediately work with and test segmentation tools: ```python -from pytheranostics.segmentation import total_seg_pipeline +from pytheranostics.segmentation import run_full_pipeline # You can now use TotalSegmentator without additional installation ``` diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index 0090354..cf43ffa 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -13,7 +13,7 @@ from .total_segmentator import ( convert_masks_to_rtstruct, run_full_pipeline, - run_segmentation_pipeline, + totalseg_segment, ) __all__ = [ @@ -23,6 +23,6 @@ "export_rtstruct_rois_to_csv", "export_multiple_rtstructs_to_csv", "run_full_pipeline", - "run_segmentation_pipeline", + "totalseg_segment", "convert_masks_to_rtstruct", ] diff --git a/pytheranostics/segmentation/total_segmentator.py b/pytheranostics/segmentation/total_segmentator.py index b2662a9..5865cba 100644 --- a/pytheranostics/segmentation/total_segmentator.py +++ b/pytheranostics/segmentation/total_segmentator.py @@ -115,7 +115,7 @@ def _sanitize_id(text: str) -> str: ).strip("_") -def run_segmentation_pipeline( +def totalseg_segment( input_folders: Optional[List[str]] = None, base_output_dir: str | Path = ".", *, @@ -124,10 +124,10 @@ def run_segmentation_pipeline( parallel: bool = False, max_workers: int = 2, ) -> Dict[str, Dict[str, Path]]: - """Run CT segmentation with TotalSegmentator. + """Run TotalSegmentator segmentation (steps 1-2 only). - This performs steps 1-2: discovery and segmentation. The output masks can then - be converted to RT-STRUCT multiple times with different configurations. + Discovers CT series and runs TotalSegmentator. Outputs can be reused for + multiple RT-STRUCT conversions with different configs. Parameters ---------- @@ -332,7 +332,7 @@ def run_full_pipeline( """Run the complete workflow (segmentation + RT-STRUCT conversion). Convenience function that runs both segmentation and RT-STRUCT conversion. - For more control, use run_segmentation_pipeline() and convert_masks_to_rtstruct() + For more control, use totalseg_segment() and convert_masks_to_rtstruct() separately. Parameters @@ -360,7 +360,7 @@ def run_full_pipeline( Mapping {patient_id -> {timepoint -> rtstruct_path}}. """ # Step 1-2: Run segmentation - result = run_segmentation_pipeline( + result = totalseg_segment( input_folders=input_folders, base_output_dir=base_output_dir, root_dir=root_dir, From 7f72938a66b0603998378efe6b12e91f96bc370d Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 12:50:17 -0800 Subject: [PATCH 14/21] Add method to initialize a pytheranostics project --- pytheranostics/__init__.py | 9 +- pytheranostics/project.py | 294 +++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 pytheranostics/project.py diff --git a/pytheranostics/__init__.py b/pytheranostics/__init__.py index 35c198a..bfe43a7 100644 --- a/pytheranostics/__init__.py +++ b/pytheranostics/__init__.py @@ -9,11 +9,12 @@ from pytheranostics.dicomtools.dicomtools import DicomModify # DICOM handling from pytheranostics.fits.fits import biexp_fun, monoexp_fun, triexp_fun # Analysis from pytheranostics.plots.plots import ewin_montage, plot_tac_residuals # Visualization + +# Note: segmentation tools require optional dependencies - import from pytheranostics.segmentation +from pytheranostics.project import init_project, list_templates from pytheranostics.qc.dosecal_qc import DosecalQC # Core from pytheranostics.qc.planar_qc import PlanarQC # Core from pytheranostics.qc.spect_qc import SPECTQC # Core - -# Note: segmentation tools require optional dependencies - import from pytheranostics.segmentation from pytheranostics.shared.corrections import tew_scatt from pytheranostics.shared.evaluation_metrics import perc_diff from pytheranostics.shared.radioactive_decay import decay_act, get_activity_at_injection @@ -32,6 +33,7 @@ "dicomtools": "pytheranostics.dicomtools", "fits": "pytheranostics.fits", "calibrations": "pytheranostics.calibrations", + "project": "pytheranostics.project", } @@ -72,6 +74,8 @@ def __getattr__(name): "biexp_fun", "triexp_fun", "DicomModify", + "init_project", + "list_templates", # Expose subpackage names at the package level for discoverability "imaging_ds", "imaging_tools", @@ -85,6 +89,7 @@ def __getattr__(name): "dicomtools", "fits", "calibrations", + "project", # Legacy aliases "MiscTools", "ImagingTools", diff --git a/pytheranostics/project.py b/pytheranostics/project.py new file mode 100644 index 0000000..0d26102 --- /dev/null +++ b/pytheranostics/project.py @@ -0,0 +1,294 @@ +"""Project initialization and scaffolding utilities for PyTheranostics. + +This module provides tools to create and configure new PyTheranostics projects +with standardized directory structures and configuration templates. +""" + +from pathlib import Path +from typing import List, Optional + + +def _get_template_dir() -> Path: + """Get the path to configuration templates directory.""" + # Navigate from this file to the data/configuration_templates directory + module_dir = Path(__file__).parent + template_dir = module_dir / "data" / "configuration_templates" + + if not template_dir.exists(): + raise FileNotFoundError( + f"Template directory not found at {template_dir}. " + "PyTheranostics may not be installed correctly." + ) + + return template_dir + + +def init_project( + project_dir: str | Path, + project_name: Optional[str] = None, + templates: Optional[List[str]] = None, + create_subdirs: bool = True, + overwrite: bool = False, +) -> Path: + """Initialize a new PyTheranostics project with directory structure and configs. + + Creates a project directory with: + - Configuration files from templates + - Standard subdirectories for data, results, segmentations, etc. + - README with basic project information + + Parameters + ---------- + project_dir : str | Path + Path where the project should be created. Will be created if it doesn't exist. + project_name : str, optional + Name of the project. If None, uses the directory name. + templates : List[str], optional + List of template names to copy. If None, copies all available templates. + Available: ['total_seg_config.json', 'voi_mappings_config.json'] + create_subdirs : bool, optional + If True, creates standard subdirectories (data/, results/, etc.), by default True. + overwrite : bool, optional + If True, overwrites existing config files. If False, skips existing files, + by default False. + + Returns + ------- + Path + The path to the created project directory. + + Examples + -------- + >>> from pytheranostics.project import init_project + >>> init_project("./my_dosimetry_project") + Created project: /path/to/my_dosimetry_project + ā”œā”€ā”€ total_seg_config.json + ā”œā”€ā”€ voi_mappings_config.json + ā”œā”€ā”€ README.md + ā”œā”€ā”€ data/ + ā”œā”€ā”€ results/ + ā”œā”€ā”€ segmentations/ + └── rtstructs/ + + >>> # Initialize with only specific templates + >>> init_project("./kidney_study", templates=['voi_mappings_config.json']) + + >>> # Minimal setup without subdirectories + >>> init_project("./simple_project", create_subdirs=False) + """ + project_dir = Path(project_dir).resolve() + project_name = project_name or project_dir.name + + # Create project directory + project_dir.mkdir(parents=True, exist_ok=True) + print(f"Initializing PyTheranostics project: {project_dir}") + + # Get template directory + template_dir = _get_template_dir() + + # Determine which templates to copy + available_templates = { + "total_seg_config.json": "TotalSegmentator ROI filtering/renaming/combining", + "voi_mappings_config.json": "VOI name mappings for CT/SPECT analysis", + } + + if templates is None: + templates_to_copy = list(available_templates.keys()) + else: + templates_to_copy = templates + # Validate template names + for t in templates_to_copy: + if t not in available_templates: + print( + f"āš ļø Warning: Unknown template '{t}'. " + f"Available: {list(available_templates.keys())}" + ) + + # Copy configuration templates + copied_configs = [] + skipped_configs = [] + + for template_name in templates_to_copy: + if template_name not in available_templates: + continue + + dest_path = project_dir / template_name + if dest_path.exists() and not overwrite: + skipped_configs.append(template_name) + continue + + try: + template_path = template_dir / template_name + # Read template content and write to destination + template_content = template_path.read_text() + dest_path.write_text(template_content) + copied_configs.append(template_name) + except Exception as e: + print(f"āš ļø Could not copy {template_name}: {e}") + + # Create standard subdirectories + subdirs_created = [] + if create_subdirs: + standard_dirs = { + "data": "Raw DICOM data and downloaded datasets", + "results": "Analysis outputs, plots, and reports", + "segmentations": "TotalSegmentator outputs (.nii.gz files)", + "rtstructs": "RT-STRUCT DICOM files", + "notebooks": "Jupyter notebooks for analysis", + } + + for dirname, description in standard_dirs.items(): + dir_path = project_dir / dirname + if not dir_path.exists(): + dir_path.mkdir(parents=True, exist_ok=True) + subdirs_created.append(dirname) + + # Create README + readme_path = project_dir / "README.md" + if not readme_path.exists() or overwrite: + readme_content = f"""# {project_name} + +PyTheranostics project initialized on {Path.cwd()} + +## Project Structure + +``` +{project_name}/ +ā”œā”€ā”€ total_seg_config.json # TotalSegmentator configuration +ā”œā”€ā”€ voi_mappings_config.json # VOI name mappings +ā”œā”€ā”€ data/ # Raw DICOM data +ā”œā”€ā”€ results/ # Analysis outputs +ā”œā”€ā”€ segmentations/ # TotalSegmentator outputs +ā”œā”€ā”€ rtstructs/ # RT-STRUCT DICOM files +└── notebooks/ # Jupyter notebooks +``` + +## Configuration Files + +### total_seg_config.json +Configure which anatomical structures to include in RT-STRUCT files: +- Set `include: true/false` to filter organs +- Use `new_name` to rename structures +- Use `combine` section to merge structures (e.g., all ribs → "Ribs") + +### voi_mappings_config.json +Map VOI names between different naming conventions: +- `ct_mappings`: Morphology-based names (e.g., "Kidney_L_m") +- `spect_mappings`: Activity-based names (e.g., "Kidney_L_a") + +## Getting Started + +```python +from pytheranostics.segmentation import totalseg_segment, convert_masks_to_rtstruct + +# Run TotalSegmentator +result = totalseg_segment( + root_dir="./data/ct_series", + base_output_dir="./segmentations", + device="mps" +) + +# Convert to RT-STRUCT with your config +convert_masks_to_rtstruct( + segmentation_base_dir="./segmentations", + ct_series_paths=result["ct_paths"], + rtstruct_output_dir="./rtstructs", + config_path="total_seg_config.json" +) +``` + +## Documentation + +- PyTheranostics: https://github.com/pytheranostics/pytheranostics +- TotalSegmentator: https://doi.org/10.1148/ryai.230024 +""" + readme_path.write_text(readme_content) + print("āœ“ Created README.md") + + # Print summary + print("\n" + "=" * 60) + print(f"āœ“ Project initialized: {project_dir}") + print("=" * 60) + + if copied_configs: + print("\nConfiguration files:") + for config in copied_configs: + desc = available_templates.get(config, "") + print(f" āœ“ {config}") + if desc: + print(f" └─ {desc}") + + if skipped_configs: + print("\nSkipped (already exist):") + for config in skipped_configs: + print(f" āŠ— {config} (use overwrite=True to replace)") + + if subdirs_created: + print("\nDirectories created:") + for dirname in subdirs_created: + print(f" āœ“ {dirname}/") + + print("\n" + "=" * 60) + print("Next steps:") + print(" 1. Edit configuration files to match your project needs") + print(" 2. Place DICOM data in data/ directory") + print(" 3. Run segmentation and analysis workflows") + print("=" * 60 + "\n") + + return project_dir + + +def list_templates() -> dict: + """List available project templates. + + Returns + ------- + dict + Dictionary mapping template names to descriptions. + + Examples + -------- + >>> from pytheranostics.project import list_templates + >>> templates = list_templates() + >>> for name, desc in templates.items(): + ... print(f"{name}: {desc}") + """ + return { + "total_seg_config.json": "TotalSegmentator ROI filtering/renaming/combining", + "voi_mappings_config.json": "VOI name mappings for CT/SPECT analysis", + } + + +def get_template_path(template_name: str) -> Path: + """Get the path to a specific configuration template. + + Useful for inspecting template contents before initializing a project. + + Parameters + ---------- + template_name : str + Name of the template file. + + Returns + ------- + Path + Path to the template file within the package. + + Examples + -------- + >>> from pytheranostics.project import get_template_path + >>> template = get_template_path("total_seg_config.json") + >>> import json + >>> config = json.loads(template.read_text()) + """ + template_dir = _get_template_dir() + template_path = template_dir / template_name + + if not template_path.exists(): + available = list_templates() + raise FileNotFoundError( + f"Template '{template_name}' not found. " + f"Available templates: {list(available.keys())}" + ) + + return template_path From 3475705d198ae5497d564595a39b0e399bc0ae1f Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 12:54:45 -0800 Subject: [PATCH 15/21] Documentation on how to get started with a pytheranostics project --- docs/source/index.rst | 2 +- .../getting_started}/basic_usage.rst | 0 .../project_setup_tutorial.ipynb | 522 ++++++++++++++++++ docs/source/tutorials/index.rst | 5 +- 4 files changed, 526 insertions(+), 3 deletions(-) rename docs/source/{usage => tutorials/getting_started}/basic_usage.rst (100%) create mode 100644 docs/source/tutorials/getting_started/project_setup_tutorial.ipynb diff --git a/docs/source/index.rst b/docs/source/index.rst index c7199d9..7d16d8d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,7 +14,7 @@ PyTheranostics is a comprehensive Python library for nuclear medicine image proc intro/overview intro/installation - usage/basic_usage + tutorials/getting_started/basic_usage .. toctree:: :maxdepth: 1 diff --git a/docs/source/usage/basic_usage.rst b/docs/source/tutorials/getting_started/basic_usage.rst similarity index 100% rename from docs/source/usage/basic_usage.rst rename to docs/source/tutorials/getting_started/basic_usage.rst diff --git a/docs/source/tutorials/getting_started/project_setup_tutorial.ipynb b/docs/source/tutorials/getting_started/project_setup_tutorial.ipynb new file mode 100644 index 0000000..bde2dcd --- /dev/null +++ b/docs/source/tutorials/getting_started/project_setup_tutorial.ipynb @@ -0,0 +1,522 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1dc3882a", + "metadata": {}, + "source": [ + "# Project Setup and Initialization\n", + "\n", + "Learn how to set up standardized PyTheranostics projects with configuration templates and organized directory structures.\n", + "\n", + "## Overview\n", + "\n", + "PyTheranostics provides project initialization tools to help you:\n", + "- Create standardized project structures\n", + "- Get pre-configured template files\n", + "- Organize your data, results, and analysis outputs\n", + "- Start with best-practice workflows\n", + "\n", + "This tutorial covers:\n", + "1. **Quick project initialization** - Get started in seconds\n", + "2. **Configuration templates** - Understanding available templates\n", + "3. **Customization options** - Tailoring projects to your needs\n", + "4. **Practical workflows** - Using initialized projects effectively\n", + "\n", + "---" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa35bf4d", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the project initialization tools\n", + "from pytheranostics import init_project, list_templates\n", + "from pytheranostics.project import get_template_path\n", + "import json\n", + "from pathlib import Path" + ] + }, + { + "cell_type": "markdown", + "id": "dccc4c39", + "metadata": {}, + "source": [ + "## Step 1: List Available Templates\n", + "\n", + "Before creating a project, let's see what configuration templates are available:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55339fc1", + "metadata": {}, + "outputs": [], + "source": [ + "# List all available configuration templates\n", + "templates = list_templates()\n", + "\n", + "print(\"Available Configuration Templates:\\n\" + \"=\"*50)\n", + "for name, description in templates.items():\n", + " print(f\"šŸ“„ {name}\")\n", + " print(f\" └─ {description}\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "82cfab36", + "metadata": {}, + "source": [ + "## Step 2: Inspect a Template\n", + "\n", + "Let's look at what's inside the `total_seg_config.json` template:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85980503", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the template path\n", + "template_path = get_template_path(\"total_seg_config.json\")\n", + "print(f\"Template location: {template_path}\\n\")\n", + "\n", + "# Load and preview the first few entries\n", + "with open(template_path) as f:\n", + " config = json.load(f)\n", + "\n", + "print(\"Template structure:\")\n", + "print(f\" - Number of VOIs defined: {len(config.get('vois', []))}\")\n", + "print(f\" - Has 'combine' section: {'combine' in config}\")\n", + "\n", + "print(\"\\nFirst 5 VOIs:\")\n", + "for voi in config['vois'][:5]:\n", + " included = \"āœ“\" if voi['include'] else \"āœ—\"\n", + " name = voi.get('new_name') or voi['voi_name']\n", + " print(f\" {included} {voi['voi_name']} → {name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "83874e92", + "metadata": {}, + "source": [ + "## Step 3: Create Your First Project\n", + "\n", + "Now let's initialize a complete project with all templates and standard directories:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63944bc6", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a new project\n", + "project_path = init_project(\n", + " \"./my_dosimetry_study\",\n", + " project_name=\"Lu-177 Dosimetry Study\",\n", + " create_subdirs=True,\n", + " overwrite=False # Won't overwrite if already exists\n", + ")\n", + "\n", + "print(f\"\\nāœ“ Project created at: {project_path}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a64baebc", + "metadata": {}, + "source": [ + "### Project Structure Created\n", + "\n", + "The `init_project()` function created:\n", + "\n", + "```\n", + "my_dosimetry_study/\n", + "ā”œā”€ā”€ total_seg_config.json # TotalSegmentator configuration\n", + "ā”œā”€ā”€ voi_mappings_config.json # VOI name mappings\n", + "ā”œā”€ā”€ README.md # Project documentation\n", + "ā”œā”€ā”€ data/ # Raw DICOM data\n", + "ā”œā”€ā”€ results/ # Analysis outputs\n", + "ā”œā”€ā”€ segmentations/ # TotalSegmentator outputs\n", + "ā”œā”€ā”€ rtstructs/ # RT-STRUCT DICOM files\n", + "└── notebooks/ # Jupyter notebooks\n", + "```\n", + "\n", + "Let's verify what was created:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdd0fa00", + "metadata": {}, + "outputs": [], + "source": [ + "# List the created files and directories\n", + "if project_path.exists():\n", + " print(f\"Contents of {project_path}:\\n\")\n", + " \n", + " print(\"Configuration Files:\")\n", + " for item in sorted(project_path.iterdir()):\n", + " if item.is_file():\n", + " size_kb = item.stat().st_size / 1024\n", + " print(f\" šŸ“„ {item.name} ({size_kb:.1f} KB)\")\n", + " \n", + " print(\"\\nDirectories:\")\n", + " for item in sorted(project_path.iterdir()):\n", + " if item.is_dir():\n", + " print(f\" šŸ“ {item.name}/\")" + ] + }, + { + "cell_type": "markdown", + "id": "c963fed3", + "metadata": {}, + "source": [ + "## Step 4: Understanding Configuration Files\n", + "\n", + "### TotalSegmentator Configuration\n", + "\n", + "The `total_seg_config.json` controls which organs to include in RT-STRUCT files:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27e78362", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the project's TotalSegmentator config\n", + "config_path = project_path / \"total_seg_config.json\"\n", + "\n", + "with open(config_path) as f:\n", + " config = json.load(f)\n", + "\n", + "# Show how many organs are included by default\n", + "included_vois = [v for v in config['vois'] if v['include']]\n", + "excluded_vois = [v for v in config['vois'] if not v['include']]\n", + "\n", + "print(f\"TotalSegmentator Configuration:\")\n", + "print(f\" Total structures: {len(config['vois'])}\")\n", + "print(f\" Included by default: {len(included_vois)}\")\n", + "print(f\" Excluded by default: {len(excluded_vois)}\")\n", + "\n", + "print(f\"\\nIncluded structures (showing first 10):\")\n", + "for voi in included_vois[:10]:\n", + " name = voi.get('new_name') or voi['voi_name']\n", + " print(f\" āœ“ {voi['voi_name']} → {name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c47013e9", + "metadata": {}, + "source": [ + "### VOI Mappings Configuration\n", + "\n", + "The `voi_mappings_config.json` helps map organ names between different conventions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fdb867d3", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the VOI mappings config\n", + "mappings_path = project_path / \"voi_mappings_config.json\"\n", + "\n", + "with open(mappings_path) as f:\n", + " mappings = json.load(f)\n", + "\n", + "print(\"VOI Mappings Configuration:\\n\")\n", + "\n", + "print(\"CT Mappings (morphology-based, suffix _m):\")\n", + "for source, target in list(mappings['ct_mappings'].items())[:5]:\n", + " print(f\" {source} → {target}\")\n", + "\n", + "print(\"\\nSPECT Mappings (activity-based, suffix _a):\")\n", + "for source, target in list(mappings['spect_mappings'].items())[:5]:\n", + " print(f\" {source} → {target}\")\n", + "\n", + "print(\"\\nThis helps unify naming across different data sources!\")" + ] + }, + { + "cell_type": "markdown", + "id": "cfd200e6", + "metadata": {}, + "source": [ + "## Step 5: Customizing the Configuration\n", + "\n", + "Let's create a custom configuration for a kidney dosimetry study:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f83a7dc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a kidney-focused configuration\n", + "kidney_config = {\n", + " \"vois\": [\n", + " {\"voi_name\": \"kidney_left\", \"include\": True, \"new_name\": \"Left Kidney\"},\n", + " {\"voi_name\": \"kidney_right\", \"include\": True, \"new_name\": \"Right Kidney\"},\n", + " {\"voi_name\": \"liver\", \"include\": True, \"new_name\": \"Liver\"},\n", + " {\"voi_name\": \"spleen\", \"include\": True, \"new_name\": \"Spleen\"},\n", + " {\"voi_name\": \"urinary_bladder\", \"include\": True, \"new_name\": \"Bladder\"},\n", + " # Include ribs for combining\n", + " {\"voi_name\": \"rib_left_1\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_2\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_3\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_4\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_5\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_6\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_7\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_8\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_9\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_10\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_11\", \"include\": True},\n", + " {\"voi_name\": \"rib_left_12\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_1\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_2\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_3\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_4\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_5\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_6\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_7\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_8\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_9\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_10\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_11\", \"include\": True},\n", + " {\"voi_name\": \"rib_right_12\", \"include\": True},\n", + " {\"voi_name\": \"sternum\", \"include\": True},\n", + " {\"voi_name\": \"scapula_left\", \"include\": True},\n", + " {\"voi_name\": \"scapula_right\", \"include\": True}\n", + " ],\n", + " \"combine\": [\n", + " {\n", + " \"combined_voi_name\": \"Skeleton (Ribs)\",\n", + " \"sources\": [\n", + " \"rib_left_1\", \"rib_left_2\", \"rib_left_3\", \"rib_left_4\",\n", + " \"rib_left_5\", \"rib_left_6\", \"rib_left_7\", \"rib_left_8\",\n", + " \"rib_left_9\", \"rib_left_10\", \"rib_left_11\", \"rib_left_12\",\n", + " \"rib_right_1\", \"rib_right_2\", \"rib_right_3\", \"rib_right_4\",\n", + " \"rib_right_5\", \"rib_right_6\", \"rib_right_7\", \"rib_right_8\",\n", + " \"rib_right_9\", \"rib_right_10\", \"rib_right_11\", \"rib_right_12\",\n", + " \"sternum\", \"scapula_left\", \"scapula_right\"\n", + " ]\n", + " }\n", + " ]\n", + "}\n", + "\n", + "# Save to a new config file\n", + "custom_config_path = project_path / \"kidney_dosimetry_config.json\"\n", + "with open(custom_config_path, 'w') as f:\n", + " json.dump(kidney_config, f, indent=2)\n", + "\n", + "print(f\"āœ“ Created custom config: {custom_config_path}\")\n", + "print(f\"\\nThis config will create RT-STRUCTs with:\")\n", + "print(\" - Left Kidney\")\n", + "print(\" - Right Kidney\")\n", + "print(\" - Liver\")\n", + "print(\" - Spleen\")\n", + "print(\" - Bladder\")\n", + "print(\" - Skeleton (Ribs) - combined from 24 ribs + sternum + scapulae\")" + ] + }, + { + "cell_type": "markdown", + "id": "b2333477", + "metadata": {}, + "source": [ + "## Step 6: Advanced - Selective Initialization\n", + "\n", + "You can also create minimal projects with only specific templates:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b70e6253", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a minimal project with only TotalSegmentator config\n", + "minimal_project = init_project(\n", + " \"./minimal_segmentation_project\",\n", + " templates=['total_seg_config.json'], # Only this template\n", + " create_subdirs=False, # No standard directories\n", + " overwrite=True\n", + ")\n", + "\n", + "print(f\"āœ“ Minimal project created at: {minimal_project}\")\n", + "print(\"\\nContents:\")\n", + "for item in sorted(minimal_project.iterdir()):\n", + " icon = \"šŸ“„\" if item.is_file() else \"šŸ“\"\n", + " print(f\" {icon} {item.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a7c556b2", + "metadata": {}, + "source": [ + "## Step 7: Using Your Project\n", + "\n", + "Here's how you'd use the initialized project in a typical workflow:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a622b1af", + "metadata": {}, + "outputs": [], + "source": [ + "# Example workflow (code shown, but not executed in this tutorial)\n", + "\n", + "workflow_code = '''\n", + "from pytheranostics.segmentation import totalseg_segment, convert_masks_to_rtstruct\n", + "\n", + "# 1. Place your DICOM CT data in: my_dosimetry_study/data/patient_001/ct/\n", + "\n", + "# 2. Run TotalSegmentator\n", + "result = totalseg_segment(\n", + " root_dir=\"./my_dosimetry_study/data/patient_001/ct\",\n", + " base_output_dir=\"./my_dosimetry_study/segmentations\",\n", + " device=\"mps\"\n", + ")\n", + "\n", + "# 3. Convert to RT-STRUCT using your custom config\n", + "convert_masks_to_rtstruct(\n", + " segmentation_base_dir=\"./my_dosimetry_study/segmentations\",\n", + " ct_series_paths=result[\"ct_paths\"],\n", + " rtstruct_output_dir=\"./my_dosimetry_study/rtstructs\",\n", + " config_path=\"./my_dosimetry_study/kidney_dosimetry_config.json\",\n", + " export_csv=True\n", + ")\n", + "\n", + "# 4. Results will be in:\n", + "# - my_dosimetry_study/rtstructs/Patient_001/rtstruct_*.dcm\n", + "# - my_dosimetry_study/rtstructs/all_rois.csv (summary)\n", + "'''\n", + "\n", + "print(\"Typical Workflow:\")\n", + "print(\"=\"*60)\n", + "print(workflow_code)" + ] + }, + { + "cell_type": "markdown", + "id": "8ada8ef9", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This tutorial demonstrated:\n", + "\n", + "### āœ“ Project Initialization\n", + "- `init_project()` - One command to set up complete project structure\n", + "- `list_templates()` - Discover available configuration templates\n", + "- `get_template_path()` - Inspect template contents\n", + "\n", + "### āœ“ Configuration Templates\n", + "\n", + "1. **total_seg_config.json**\n", + " - Filter TotalSegmentator outputs (104 structures → selected organs)\n", + " - Rename organs to clinical conventions\n", + " - Combine structures (e.g., all ribs → \"Skeleton\")\n", + "\n", + "2. **voi_mappings_config.json**\n", + " - Map between naming conventions\n", + " - Unify CT (morphology) and SPECT (activity) names\n", + "\n", + "### āœ“ Customization Options\n", + "- Selective templates (`templates=['config.json']`)\n", + "- Minimal setup (`create_subdirs=False`)\n", + "- Overwrite control (`overwrite=True/False`)\n", + "\n", + "### āœ“ Best Practices\n", + "- Start with `init_project()` for new studies\n", + "- Customize config files for your specific organs of interest\n", + "- Use standardized directory structure for reproducibility\n", + "- Keep configs in version control (git) alongside analysis code\n", + "\n", + "## Next Steps\n", + "\n", + "- **Tutorial**: Check out the [TotalSegmentator Tutorial](./segmentation/total_segmentator_tutorial.ipynb) to use these configs\n", + "- **Documentation**: See API Reference for detailed function signatures\n", + "- **Examples**: Browse Data Ingestion Examples for real workflows\n", + "\n", + "## API Reference\n", + "\n", + "### init_project()\n", + "\n", + "```python\n", + "init_project(\n", + " project_dir: str | Path,\n", + " project_name: Optional[str] = None,\n", + " templates: Optional[List[str]] = None,\n", + " create_subdirs: bool = True,\n", + " overwrite: bool = False,\n", + ") -> Path\n", + "```\n", + "\n", + "**Parameters**:\n", + "- `project_dir`: Path where project should be created\n", + "- `project_name`: Project name (defaults to directory name)\n", + "- `templates`: List of template names to copy (defaults to all)\n", + "- `create_subdirs`: Create standard directories (default: True)\n", + "- `overwrite`: Overwrite existing config files (default: False)\n", + "\n", + "**Returns**: Path to created project directory\n", + "\n", + "### list_templates()\n", + "\n", + "```python\n", + "list_templates() -> dict\n", + "```\n", + "\n", + "**Returns**: Dictionary mapping template names to descriptions\n", + "\n", + "### get_template_path()\n", + "\n", + "```python\n", + "get_template_path(template_name: str) -> Path\n", + "```\n", + "\n", + "**Parameters**:\n", + "- `template_name`: Name of template file\n", + "\n", + "**Returns**: Path to template file within package\n", + "\n", + "**Raises**: FileNotFoundError if template doesn't exist\n", + "\n", + "---\n", + "\n", + "**Remember**: Always edit the configuration files to match your specific study needs before running analysis workflows!" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 58ef2a4..7d02989 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -1,12 +1,13 @@ Tutorials ========= -Hands-on walkthroughs that demonstrate common PyTheranostics workflows. The -notebooks are rendered directly in the documentation via nbsphinx. +Hands-on walkthroughs that demonstrate common PyTheranostics workflows. .. toctree:: :maxdepth: 1 + getting_started/project_setup_tutorial + segmentation/total_segmentator_tutorial SPECT2SUV/SPECT2SUV ROI_Mapping_Tutorial/ROI_Mapping_Tutorial Data_Ingestion_Examples/Data_Ingestion_Examples From acbd55f8cd665e855649f7980ca3484ea81667f6 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:00:03 -0800 Subject: [PATCH 16/21] Clean total segmentator configuration --- .../total_seg_config.json | 65 +------------------ 1 file changed, 1 insertion(+), 64 deletions(-) rename pytheranostics/data/{project_templates => configuration_templates}/total_seg_config.json (89%) diff --git a/pytheranostics/data/project_templates/total_seg_config.json b/pytheranostics/data/configuration_templates/total_seg_config.json similarity index 89% rename from pytheranostics/data/project_templates/total_seg_config.json rename to pytheranostics/data/configuration_templates/total_seg_config.json index fb7db0b..80d744b 100644 --- a/pytheranostics/data/project_templates/total_seg_config.json +++ b/pytheranostics/data/configuration_templates/total_seg_config.json @@ -584,70 +584,7 @@ "voi_name": "vertebrae_T9", "include": true, "new_name": null - }, - { - "voi_name": "COMBINE", - "include": false, - "new_name": null - }, - { - "voi_name": "Rib ROI + sternum + scapula", - "include": false, - "new_name": null - }, - { - "voi_name": "Thoracic spine", - "include": false, - "new_name": null - }, - { - "voi_name": "Lumbar spine", - "include": false, - "new_name": null - }, - { - "voi_name": "Sacrum + Pelvis", - "include": false, - "new_name": null - }, - { - "voi_name": "Cervical spine", - "include": false, - "new_name": null } ], - "combine": [ - { - "combined_voi_name": "ribs", - "sources": [ - "rib_left_1", - "rib_left_10", - "rib_left_11", - "rib_left_12", - "rib_left_2", - "rib_left_3", - "rib_left_4", - "rib_left_5", - "rib_left_6", - "rib_left_7", - "rib_left_8", - "rib_left_9", - "rib_right_1", - "rib_right_10", - "rib_right_11", - "rib_right_12", - "rib_right_2", - "rib_right_3", - "rib_right_4", - "rib_right_5", - "rib_right_6", - "rib_right_7", - "rib_right_8", - "rib_right_9", - "scapula_left", - "scapula_right", - "sternum" - ] - } - ] + "combine": [] } From b25b3da11a92b1975936e62cd688752c93930eba Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:01:52 -0800 Subject: [PATCH 17/21] Clean voi_mapping config template --- .../configuration_templates/voi_mappings_config.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 pytheranostics/data/configuration_templates/voi_mappings_config.json diff --git a/pytheranostics/data/configuration_templates/voi_mappings_config.json b/pytheranostics/data/configuration_templates/voi_mappings_config.json new file mode 100644 index 0000000..2e40588 --- /dev/null +++ b/pytheranostics/data/configuration_templates/voi_mappings_config.json @@ -0,0 +1,11 @@ +{ + "_instructions": "Map VOI names between different naming conventions.", + + "ct_mappings": { + "organ_name_in_ct": "standardized_name" + }, + + "spect_mappings": { + "organ_name_in_spect": "standardized_name" + } +} From 90496a1c1c4e74265906607bba7d02424f183eb9 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:02:32 -0800 Subject: [PATCH 18/21] Rename data_dir to avoid confusion with home directory --- pytheranostics/data_fetchers/__init__.py | 4 +-- pytheranostics/data_fetchers/fetchers.py | 45 ++++++++++++------------ 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/pytheranostics/data_fetchers/__init__.py b/pytheranostics/data_fetchers/__init__.py index cadb9a5..75df77a 100644 --- a/pytheranostics/data_fetchers/__init__.py +++ b/pytheranostics/data_fetchers/__init__.py @@ -7,14 +7,14 @@ from .fetchers import ( clear_data_cache, fetch_snmmi_dosimetry_challenge, - get_data_home, + get_data_dir, get_example_data_citation, list_cached_data, ) __all__ = [ "fetch_snmmi_dosimetry_challenge", - "get_data_home", + "get_data_dir", "clear_data_cache", "list_cached_data", "get_example_data_citation", diff --git a/pytheranostics/data_fetchers/fetchers.py b/pytheranostics/data_fetchers/fetchers.py index 1755625..465dc72 100644 --- a/pytheranostics/data_fetchers/fetchers.py +++ b/pytheranostics/data_fetchers/fetchers.py @@ -8,29 +8,29 @@ from urllib.request import Request, urlopen -def get_data_home(data_home: Optional[str] = None) -> Path: +def get_data_dir(data_dir: Optional[str] = None) -> Path: """Return the path to the pytheranostics example data directory. By default, data is stored in the user's cache directory. Parameters ---------- - data_home : str, optional + data_dir : str, optional The path to the pytheranostics example data directory. If None, the default path is used: `~/.pytheranostics_example_data`. Returns ------- Path - The path to the data home directory. + The path to the data directory. """ - if data_home is None: - data_home = Path.home() / ".pytheranostics_example_data" + if data_dir is None: + data_dir = Path.home() / ".pytheranostics_example_data" else: - data_home = Path(data_home) + data_dir = Path(data_dir) - data_home.mkdir(parents=True, exist_ok=True) - return data_home + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir def _verify_checksum(filepath: Path, expected_md5: str) -> bool: @@ -55,12 +55,12 @@ def _verify_checksum(filepath: Path, expected_md5: str) -> bool: return md5_hash.hexdigest() == expected_md5 -def clear_data_cache(data_home: Optional[str] = None): +def clear_data_cache(data_dir: Optional[str] = None): """Remove all cached example data. Parameters ---------- - data_home : str, optional + data_dir : str, optional The path to the pytheranostics data directory. If None, uses default. Examples @@ -68,20 +68,20 @@ def clear_data_cache(data_home: Optional[str] = None): >>> from pytheranostics.data import clear_data_cache >>> clear_data_cache() """ - data_home = get_data_home(data_home) - if data_home.exists(): - shutil.rmtree(data_home) - print(f"Cleared data cache at: {data_home}") + data_dir = get_data_dir(data_dir) + if data_dir.exists(): + shutil.rmtree(data_dir) + print(f"Cleared data cache at: {data_dir}") else: print("No cached data to clear") -def list_cached_data(data_home: Optional[str] = None): +def list_cached_data(data_dir: Optional[str] = None): """List all cached example datasets. Parameters ---------- - data_home : str, optional + data_dir : str, optional The path to the pytheranostics data directory. If None, uses default. Examples @@ -89,13 +89,13 @@ def list_cached_data(data_home: Optional[str] = None): >>> from pytheranostics.data import list_cached_data >>> list_cached_data() """ - data_home = get_data_home(data_home) - if not data_home.exists(): + data_dir = get_data_dir(data_dir) + if not data_dir.exists(): print("No cached data found") return - print(f"Cached data in {data_home}:") - for item in data_home.iterdir(): + print(f"Cached data in {data_dir}:") + for item in data_dir.iterdir(): if item.is_dir(): size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file()) size_mb = size / (1024 * 1024) @@ -183,7 +183,7 @@ def fetch_snmmi_dosimetry_challenge( Dataset DOI: https://doi.org/10.7302/864r-tb45 Repository: https://deepblue.lib.umich.edu/ """ - home = get_data_home(str(data_home) if data_home else None) + home = get_data_dir(str(data_home) if data_home else None) dataset_base = "snmmi_dose_challenge" patient_dir = home / dataset_base / "Patient_004" @@ -246,7 +246,8 @@ def fetch_snmmi_dosimetry_challenge( print(f"\nData ready at: {patient_dir.parent}") print("\nDataset citation:") - print(". SNMMI Lu-177 Dosimetry Challenge Dataset") + print(" SNMMI Lu-177 Dosimetry Challenge Dataset") + print(" Creators: Dewaraja, Yuni K and Van, Benjamin J") print(" DOI: https://doi.org/10.7302/864r-tb45") print(" Repository: University of Michigan Deep Blue") else: From e89bf565ee4241e9f79066958b6ea62cb7075bcf Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:03:29 -0800 Subject: [PATCH 19/21] Remove run of full pipeline after modularity has been implemented --- pytheranostics/segmentation/__init__.py | 7 +-- .../segmentation/total_segmentator.py | 61 ------------------- 2 files changed, 1 insertion(+), 67 deletions(-) diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index cf43ffa..87f0a6d 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -10,11 +10,7 @@ get_rtstruct_roi_names, print_rtstruct_info, ) -from .total_segmentator import ( - convert_masks_to_rtstruct, - run_full_pipeline, - totalseg_segment, -) +from .total_segmentator import convert_masks_to_rtstruct, totalseg_segment __all__ = [ "RTStructConverter", @@ -22,7 +18,6 @@ "print_rtstruct_info", "export_rtstruct_rois_to_csv", "export_multiple_rtstructs_to_csv", - "run_full_pipeline", "totalseg_segment", "convert_masks_to_rtstruct", ] diff --git a/pytheranostics/segmentation/total_segmentator.py b/pytheranostics/segmentation/total_segmentator.py index 5865cba..1f08a72 100644 --- a/pytheranostics/segmentation/total_segmentator.py +++ b/pytheranostics/segmentation/total_segmentator.py @@ -316,64 +316,3 @@ def convert_masks_to_rtstruct( ) return patient_map - - -def run_full_pipeline( - input_folders: Optional[List[str]] = None, - base_output_dir: str | Path = ".", - rtstruct_output_dir: str | Path = "./RTStructs", - *, - root_dir: Optional[str | Path] = None, - device: str = "mps", - parallel: bool = False, - max_workers: int = 2, - export_csv: bool = True, -) -> Dict[str, Dict[str, Path]]: - """Run the complete workflow (segmentation + RT-STRUCT conversion). - - Convenience function that runs both segmentation and RT-STRUCT conversion. - For more control, use totalseg_segment() and convert_masks_to_rtstruct() - separately. - - Parameters - ---------- - input_folders : List[str], optional - Explicit list of CT DICOM series folders (overrides discovery if given). - base_output_dir : str | Path, optional - Base directory where TotalSegmentator results are written, by default '.'. - rtstruct_output_dir : str | Path, optional - Directory where RT-STRUCT files will be saved, by default './RTStructs'. - root_dir : str | Path, optional - Root folder to discover CT series (if input_folders is None). - device : str, optional - 'mps' (Apple), 'cuda', or 'cpu', by default "mps". - parallel : bool, optional - If True, run segmentation in parallel, by default False. - max_workers : int, optional - Number of workers for parallel processing, by default 2. - export_csv : bool, optional - If True, writes all_rois.csv in rtstruct_output_dir (recursive), by default True. - - Returns - ------- - Dict[str, Dict[str, Path]] - Mapping {patient_id -> {timepoint -> rtstruct_path}}. - """ - # Step 1-2: Run segmentation - result = totalseg_segment( - input_folders=input_folders, - base_output_dir=base_output_dir, - root_dir=root_dir, - device=device, - parallel=parallel, - max_workers=max_workers, - ) - - # Step 3-4: Convert to RT-STRUCT and export CSV - return convert_masks_to_rtstruct( - segmentation_base_dir=base_output_dir, - ct_series_paths=result["ct_paths"], - rtstruct_output_dir=rtstruct_output_dir, - config_path=None, # Will auto-detect in cwd - export_csv=export_csv, - ) From 214cc1af3d741f5dfd5e300a8987c4bc949de45c Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:03:50 -0800 Subject: [PATCH 20/21] add tutorial for total_segmentator module use --- .../total_segmentator_tutorial.ipynb | 940 ++++++++++++++++++ 1 file changed, 940 insertions(+) create mode 100644 docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb diff --git a/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb b/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb new file mode 100644 index 0000000..d502c8e --- /dev/null +++ b/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb @@ -0,0 +1,940 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e0d1afc2", + "metadata": {}, + "source": [ + "# TotalSegmentator Tutorial for PyTheranostics\n", + "\n", + "This tutorial demonstrates how to use TotalSegmentator within PyTheranostics for automated CT segmentation and RT-STRUCT creation.\n", + "\n", + "## About TotalSegmentator\n", + "\n", + "TotalSegmentator is a powerful deep learning tool for automatic segmentation of 104 anatomical structures in CT images. PyTheranostics provides a streamlined interface to use TotalSegmentator for theranostics workflows.\n", + "\n", + "**Citation**: \n", + "\n", + "> Wasserthal J, Breit HC, Meyer MT, et al. TotalSegmentator: Robust Segmentation of 104 Anatomic Structures in CT Images. *Radiology: Artificial Intelligence*. 2023;5(5):e230024. [https://doi.org/10.1148/ryai.230024](https://pubs.rsna.org/doi/10.1148/ryai.230024)\n", + "\n", + "## Overview\n", + "\n", + "This tutorial covers:\n", + "1. **Downloading example data** from the SNMMI Dosimetry Challenge\n", + "2. **Running TotalSegmentator** to create segmentation masks (`.nii.gz` files)\n", + "3. **Converting masks to RT-STRUCT** (DICOM format) with optional filtering/grouping\n", + "4. **Using configuration files** to customize VOIs within the RT-STRUCT.\n", + "\n", + "---" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e62f315b", + "metadata": {}, + "outputs": [], + "source": [ + "from pytheranostics.data_fetchers import fetch_snmmi_dosimetry_challenge,get_data_dir\n", + "from pytheranostics.segmentation import totalseg_segment, convert_masks_to_rtstruct\n", + "from pathlib import Path\n", + "\n", + "%reload_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "15bb3b1e", + "metadata": {}, + "source": [ + "## Quick Start: Initialize a New Project\n", + "\n", + "Before starting, you can use PyTheranostics' project initialization tool to set up a standardized project structure with configuration templates:\n", + "\n", + "```python\n", + "from pytheranostics import init_project\n", + "\n", + "# Create a new project with all templates and standard directories\n", + "init_project(\"./my_dosimetry_study\")\n", + "```\n", + "\n", + "This creates:\n", + "- `total_seg_config.json` - TotalSegmentator configuration template\n", + "- `voi_mappings_config.json` - VOI name mapping template \n", + "- Standard directories: `data/`, `results/`, `segmentations/`, `rtstructs/`, `notebooks/`\n", + "- `README.md` with project documentation\n", + "\n", + "You can then customize the config files for your specific needs!\n", + "\n", + "---" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8249b4b6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Initializing PyTheranostics project: /Users/curibe/Documents/pytheranostics/my_dosimetry_project\n", + "āœ“ Created README.md\n", + "\n", + "============================================================\n", + "āœ“ Project initialized: /Users/curibe/Documents/pytheranostics/my_dosimetry_project\n", + "============================================================\n", + "\n", + "Configuration files:\n", + " āœ“ total_seg_config.json\n", + " └─ TotalSegmentator ROI filtering/renaming/combining\n", + " āœ“ voi_mappings_config.json\n", + " └─ VOI name mappings for CT/SPECT analysis\n", + "\n", + "Directories created:\n", + " āœ“ data/\n", + " āœ“ results/\n", + " āœ“ segmentations/\n", + " āœ“ rtstructs/\n", + " āœ“ notebooks/\n", + "\n", + "============================================================\n", + "Next steps:\n", + " 1. Edit configuration files to match your project needs\n", + " 2. Place DICOM data in data/ directory\n", + " 3. Run segmentation and analysis workflows\n", + "============================================================\n", + "\n" + ] + }, + { + "data": { + "text/plain": [ + "PosixPath('/Users/curibe/Documents/pytheranostics/my_dosimetry_project')" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pytheranostics import init_project\n", + "\n", + "# Create a new project with all templates and standard directories\n", + "init_project(\"./my_dosimetry_project\")" + ] + }, + { + "cell_type": "markdown", + "id": "6bd78b9f", + "metadata": {}, + "source": [ + "## Step 1: Download Example Data\n", + "\n", + "We'll use the SNMMI Dosimetry Challenge dataset from University of Michigan Deep Blue. This contains multi-timepoint SPECT/CT data and we will focus on Patient_004." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e7b50012", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloading multi-timepoint Lu-177 SPECT/CT data...\n", + "Extracting...\n", + "Extraction complete āœ“\n", + "\n", + "Data ready at: /Users/curibe/.pytheranostics_example_data/snmmi_dose_challenge\n", + "\n", + "Dataset citation:\n", + " SNMMI Lu-177 Dosimetry Challenge Dataset\n", + " Creators: Dewaraja, Yuni K and Van, Benjamin J\n", + " DOI: https://doi.org/10.7302/864r-tb45\n", + " Repository: University of Michigan Deep Blue\n" + ] + } + ], + "source": [ + "fetch_snmmi_dosimetry_challenge()" + ] + }, + { + "cell_type": "markdown", + "id": "8cdc5053", + "metadata": {}, + "source": [ + "## Step 2: Define Output Directories\n", + "\n", + "Set up folders for where TotalSegmentator will write segmentation masks and where RT-STRUCT files will be saved." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "350d86f5", + "metadata": {}, + "outputs": [], + "source": [ + "example_data_dir = get_data_dir()\n", + "\n", + "# Provide a ROOT folder where all CT series under it will be discovered automatically.\n", + "# In this case, as an example, we provide the path to a single CT series at the first scan of Patient_004 from the downloaded dataset.\n", + "project_data_dir = example_data_dir / 'snmmi_dose_challenge/Patient_004/SPECT_Cts/scan1/ct'\n", + "\n", + "# Specify paths of Where to write segmentations and RT-STRUCT outputs (will be grouped by PatientID)\n", + "totalsegmentator_output_dir = Path('./my_dosimetry_project/Segmentations')\n", + "rtstruct_output_dir = Path('./my_dosimetry_project/rtstructs')" + ] + }, + { + "cell_type": "markdown", + "id": "aef05291", + "metadata": {}, + "source": [ + "## Step 3: Run TotalSegmentator\n", + "\n", + "The `totalseg_segment()` function:\n", + "- **Discovers all CT series** under the `root_dir` automatically\n", + "- **Extracts patient ID** from DICOM metadata\n", + "- **Identifies timepoints** from folder structure (e.g., `scan1`, `scan2`)\n", + "- **Runs TotalSegmentator** to generate 104 segmentation masks per CT series\n", + "- **Returns both** segmentation paths and CT paths for later use\n", + "\n", + "**Note**: This step can take several minutes per CT scan depending on your hardware." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "fce08eb7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using device: MPS\n", + "Segmentation: patient=ANON54121 timepoint=scan1\n", + " CT=/Users/curibe/.pytheranostics_example_data/snmmi_dose_challenge/Patient_004/SPECT_Cts/scan1/ct\n", + " OUT=my_dosimetry_project/Segmentations/ANON54121/scan1\n", + "\n", + "If you use this tool please cite: https://pubs.rsna.org/doi/10.1148/ryai.230024\n", + "\n", + "Converting dicom to nifti...\n", + " found image with shape (512, 512, 130)\n", + "Resampling...\n", + " Resampled in 4.65s\n", + "Predicting part 1 of 5 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 48/48 [00:18<00:00, 2.55it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting part 2 of 5 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 48/48 [00:18<00:00, 2.55it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting part 3 of 5 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 48/48 [00:18<00:00, 2.62it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting part 4 of 5 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 48/48 [00:18<00:00, 2.58it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting part 5 of 5 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 48/48 [00:18<00:00, 2.56it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Predicted in 149.24s\n", + "Resampling...\n", + "Saving segmentations...\n", + " Saved in 6.63s\n" + ] + } + ], + "source": [ + "# Run segmentation \n", + "seg_result = totalseg_segment(\n", + " root_dir=str(project_data_dir),\n", + " base_output_dir=str(totalsegmentator_output_dir),\n", + " device=\"mps\", # Change to \"cuda\" if using an NVIDIA GPU, or \"cpu\" to run on CPU\n", + " parallel=True,\n", + " max_workers=4, # Number of parallel workers to use if parallel=True\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "02d13e70", + "metadata": {}, + "source": [ + "### Segmentation Output Structure\n", + "\n", + "TotalSegmentator creates one `.nii.gz` file per anatomical structure (104 total). These are organized by patient ID and timepoint:\n", + "\n", + "```\n", + "my_dosimetry_project/Segmentations/\n", + "└── ANON54121/\n", + " └── scan1/\n", + " ā”œā”€ā”€ adrenal_gland_left.nii.gz\n", + " ā”œā”€ā”€ adrenal_gland_right.nii.gz\n", + " ā”œā”€ā”€ aorta.nii.gz\n", + " ā”œā”€ā”€ brain.nii.gz\n", + " ā”œā”€ā”€ heart.nii.gz\n", + " ā”œā”€ā”€ kidney_left.nii.gz\n", + " ā”œā”€ā”€ kidney_right.nii.gz\n", + " ā”œā”€ā”€ liver.nii.gz\n", + " ā”œā”€ā”€ lung_lower_lobe_left.nii.gz\n", + " ā”œā”€ā”€ lung_upper_lobe_left.nii.gz\n", + " ā”œā”€ā”€ rib_left_1.nii.gz\n", + " ā”œā”€ā”€ rib_left_2.nii.gz\n", + " ... (104 structures total)\n", + " └── vertebrae_T9.nii.gz\n", + "```\n", + "\n", + "Each `.nii.gz` file is a 3D binary mask aligned with the original CT scan." + ] + }, + { + "cell_type": "markdown", + "id": "1ba0f852", + "metadata": {}, + "source": [ + "## Step 4: Convert to RT-STRUCT Format\n", + "\n", + "### What is RT-STRUCT?\n", + "\n", + "RT-STRUCT (Radiotherapy Structure Set) is a DICOM format for storing organ contours and regions of interest. It's the standard format used by:\n", + "- Treatment planning systems (Eclipse, RayStation, Monaco, etc.)\n", + "- DICOM viewers (MIM, 3D Slicer, etc.)\n", + "- Dosimetry calculation tools\n", + "\n", + "Converting TotalSegmentator masks to RT-STRUCT allows you to:\n", + "- **Visualize** segmentations in clinical DICOM viewers\n", + "- **Import** into treatment planning systems\n", + "- **Use** for absorbed dose calculations and analysis\n", + "- **Share** with collaborators using standard DICOM format\n", + "\n", + "### Using Configuration Files\n", + "\n", + "By default, converting all 104 structures would create a very large RT-STRUCT file. Configuration files let you:\n", + "- **Filter**: Include only the organs you need (e.g., kidneys, liver, spleen)\n", + "- **Rename**: Change organ names to match your workflow conventions\n", + "- **Combine**: Merge multiple structures into one (e.g., all ribs → \"ribs\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "8bcec69c", + "metadata": {}, + "source": [ + "### Configuration File Format Explained\n", + "\n", + "The JSON configuration has two main sections:\n", + "\n", + "**1. `vois` (Volumes of Interest)**\n", + "- Lists individual TotalSegmentator structures\n", + "- `voi_name`: Must match the `.nii.gz` filename (without extension)\n", + "- `include`: Set to `true` to include this structure\n", + "- `new_name`: (Optional) Rename the structure in the RT-STRUCT\n", + "\n", + "**2. `combine` (Optional)**\n", + "- Merges multiple structures into a single ROI\n", + "- `combined_voi_name`: Name for the merged ROI\n", + "- `sources`: List of `voi_name` values to combine (uses logical OR)\n", + "\n", + "**Important**: When using `combine`, the individual source structures will NOT appear as separate ROIs - only the combined version will be included.\n" + ] + }, + { + "cell_type": "markdown", + "id": "bed1aa65", + "metadata": {}, + "source": [ + "### Modifying the Configuration File\n", + "\n", + "The `init_project()` method creates a default `total_seg_config.json` template in your project directory. You can open this file in VS Code (or any text editor) and customize which structures to include and how to process them.\n", + "\n", + "**Open the file:** `ribs_kidneys_liver_project/total_seg_config.json`\n", + "\n", + "**Common modifications:**\n", + "- Change `\"include\": true` to `\"include\": false` to exclude structures\n", + "- Add a `\"new_name\"` field to rename structures in the RT-STRUCT\n", + "- Define `\"combine\"` rules to merge related structures (e.g., all ribs into one ROI)\n", + "\n", + "### Example 1: Filter to Kidneys and Liver Only\n", + "\n", + "Simply edit your `total_seg_config.json` in VS Code to keep only these organs:\n", + "\n", + "```json\n", + "{\n", + " \"vois\": [\n", + " {\"voi_name\": \"kidney_left\", \"include\": true, \"new_name\": \"Left Kidney\"},\n", + " {\"voi_name\": \"kidney_right\", \"include\": true, \"new_name\": \"Right Kidney\"},\n", + " {\"voi_name\": \"liver\", \"include\": true, \"new_name\": \"Liver\"}\n", + " ],\n", + " \"combine\": []\n", + "}\n", + "```\n", + "\n", + "This config will create an RT-STRUCT with only three ROIs: Left Kidney, Right Kidney, and Liver." + ] + }, + { + "cell_type": "markdown", + "id": "96287700", + "metadata": {}, + "source": [ + "### Example 2: Combine Ribs and Select Other Organs\n", + "\n", + "For a bone marrow dosimetry study, you might want to combine all ribs into a single ROI. Open `total_seg_config.json` in VS Code and modify it like this:\n", + "\n", + "```json\n", + "{\n", + " \"vois\": [\n", + " {\"voi_name\": \"rib_left_1\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_2\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_3\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_4\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_5\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_6\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_7\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_8\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_9\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_10\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_11\", \"include\": true},\n", + " {\"voi_name\": \"rib_left_12\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_1\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_2\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_3\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_4\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_5\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_6\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_7\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_8\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_9\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_10\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_11\", \"include\": true},\n", + " {\"voi_name\": \"rib_right_12\", \"include\": true},\n", + " {\"voi_name\": \"scapula_left\", \"include\": true},\n", + " {\"voi_name\": \"scapula_right\", \"include\": true},\n", + " {\"voi_name\": \"sternum\", \"include\": true},\n", + " {\"voi_name\": \"kidney_left\", \"include\": true, \"new_name\": \"L Kidney\"},\n", + " {\"voi_name\": \"kidney_right\", \"include\": true, \"new_name\": \"R Kidney\"},\n", + " {\"voi_name\": \"liver\", \"include\": true, \"new_name\": \"Liver\"},\n", + " {\"voi_name\": \"spleen\", \"include\": true, \"new_name\": \"Spleen\"}\n", + " ],\n", + " \"combine\": [\n", + " {\n", + " \"combined_voi_name\": \"Ribs + Sternum + Scapulae\",\n", + " \"sources\": [\n", + " \"rib_left_1\", \"rib_left_2\", \"rib_left_3\", \"rib_left_4\",\n", + " \"rib_left_5\", \"rib_left_6\", \"rib_left_7\", \"rib_left_8\",\n", + " \"rib_left_9\", \"rib_left_10\", \"rib_left_11\", \"rib_left_12\",\n", + " \"rib_right_1\", \"rib_right_2\", \"rib_right_3\", \"rib_right_4\",\n", + " \"rib_right_5\", \"rib_right_6\", \"rib_right_7\", \"rib_right_8\",\n", + " \"rib_right_9\", \"rib_right_10\", \"rib_right_11\", \"rib_right_12\",\n", + " \"scapula_left\", \"scapula_right\", \"sternum\"\n", + " ]\n", + " }\n", + " ]\n", + "}\n", + "```\n", + "\n", + "This configuration creates an RT-STRUCT with:\n", + "- **Ribs + Sternum + Scapulae** (combined into a single ROI)\n", + "- **L Kidney**\n", + "- **R Kidney**\n", + "- **Liver**\n", + "- **Spleen**" + ] + }, + { + "cell_type": "markdown", + "id": "76e477e8", + "metadata": {}, + "source": [ + "### Now convert the masks to RT-STRUCT" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d5192b60", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No config found, adding all masks\n", + "RT-STRUCT: patient=ANON54121 timepoint=scan1 -> my_dosimetry_project/rtstructs/ANON54121/rtstruct_scan1.dcm\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: prostate\n", + "Added ROI: gluteus_maximus_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C5\n", + "Added ROI: iliopsoas_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_T1\n", + "Added ROI: rib_right_7\n", + "Added ROI: vertebrae_T12\n", + "Added ROI: atrial_appendage_left\n", + "Added ROI: gluteus_medius_right\n", + "Added ROI: lung_upper_lobe_right\n", + "Added ROI: autochthon_right\n", + "Added ROI: iliac_vena_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_left_1\n", + "Added ROI: scapula_right\n", + "Added ROI: rib_left_10\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: humerus_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: skull\n", + "Added ROI: lung_lower_lobe_left\n", + "Added ROI: rib_left_12\n", + "Added ROI: hip_right\n", + "Added ROI: portal_vein_and_splenic_vein\n", + "Added ROI: aorta\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_left_3\n", + "Added ROI: vertebrae_L4\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: brachiocephalic_trunk\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: femur_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: femur_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C7\n", + "Added ROI: vertebrae_T10\n", + "Added ROI: rib_right_5\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: subclavian_artery_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_T3\n", + "Added ROI: rib_right_9\n", + "Added ROI: stomach\n", + "Added ROI: sternum\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: common_carotid_artery_left\n", + "Added ROI: gallbladder\n", + "Added ROI: rib_left_7\n", + "Added ROI: duodenum\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: brachiocephalic_vein_left\n", + "Added ROI: vertebrae_T7\n", + "Added ROI: lung_lower_lobe_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: thyroid_gland\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_right_1\n", + "Added ROI: rib_right_12\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: clavicula_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C3\n", + "Added ROI: inferior_vena_cava\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_right_3\n", + "Added ROI: vertebrae_T9\n", + "Added ROI: gluteus_minimus_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: clavicula_right\n", + "Added ROI: costal_cartilages\n", + "Added ROI: vertebrae_T5\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C1\n", + "Added ROI: small_bowel\n", + "Added ROI: iliac_artery_left\n", + "Added ROI: superior_vena_cava\n", + "Added ROI: rib_right_10\n", + "Added ROI: lung_upper_lobe_left\n", + "Added ROI: colon\n", + "Added ROI: rib_left_9\n", + "Added ROI: vertebrae_L2\n", + "Added ROI: rib_left_5\n", + "Added ROI: pulmonary_vein\n", + "Added ROI: kidney_left\n", + "Added ROI: iliac_vena_left\n", + "Added ROI: rib_left_11\n", + "Added ROI: sacrum\n", + "Added ROI: spleen\n", + "Added ROI: rib_right_6\n", + "Added ROI: vertebrae_S1\n", + "Added ROI: autochthon_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C4\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: subclavian_artery_right\n", + "Added ROI: rib_right_4\n", + "Added ROI: vertebrae_T11\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: brachiocephalic_vein_right\n", + "Added ROI: rib_right_8\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_T2\n", + "Added ROI: trachea\n", + "Added ROI: esophagus\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C6\n", + "Added ROI: heart\n", + "Added ROI: gluteus_minimus_right\n", + "Added ROI: adrenal_gland_left\n", + "Added ROI: adrenal_gland_right\n", + "Added ROI: iliac_artery_right\n", + "Added ROI: scapula_left\n", + "Added ROI: lung_middle_lobe_right\n", + "Added ROI: vertebrae_L5\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_left_2\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: kidney_cyst_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_C2\n", + "Added ROI: pancreas\n", + "Added ROI: hip_left\n", + "Added ROI: vertebrae_T6\n", + "Added ROI: liver\n", + "Added ROI: vertebrae_L1\n", + "Added ROI: rib_left_6\n", + "Added ROI: iliopsoas_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: humerus_left\n", + "Added ROI: spinal_cord\n", + "Added ROI: gluteus_maximus_right\n", + "Added ROI: rib_left_8\n", + "Added ROI: rib_left_4\n", + "Added ROI: vertebrae_L3\n", + "Added ROI: kidney_cyst_left\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: brain\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: common_carotid_artery_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: urinary_bladder\n", + "Added ROI: rib_right_11\n", + "Added ROI: gluteus_medius_left\n", + "Added ROI: vertebrae_T8\n", + "Added ROI: kidney_right\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: rib_right_2\n", + "[INFO]: ROI mask is empty\n", + "Added ROI: vertebrae_T4\n", + "Writing file to my_dosimetry_project/rtstructs/ANON54121/rtstruct_scan1.dcm\n", + "RT-STRUCT saved to: my_dosimetry_project/rtstructs/ANON54121/rtstruct_scan1.dcm\n", + "āœ“ Exported 117 ROIs from 1 patient-timepoints to: my_dosimetry_project/rtstructs/all_rois.csv\n" + ] + } + ], + "source": [ + "# Convert with the simple config (kidneys + liver only)\n", + "rtstruct_result = convert_masks_to_rtstruct(\n", + " segmentation_base_dir=str(totalsegmentator_output_dir),\n", + " ct_series_paths=seg_result[\"ct_paths\"],\n", + " rtstruct_output_dir=str(rtstruct_output_dir ),\n", + " config_path='./total_seg_config.json', # Config file specifying which structures to include\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "81f65bbd", + "metadata": {}, + "source": [ + "# Step 5: Inspect the RT-STRUCT Files\n", + "\n", + "PyTheranostics includes utilities to inspect RT-STRUCT contents:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ef995958", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Inspecting: my_dosimetry_project/rtstructs/ANON54121/rtstruct_scan1.dcm\n", + "\n", + "\n", + "============================================================\n", + "RT-STRUCT: rtstruct_scan1.dcm\n", + "============================================================\n", + "Patient Name: DOE^JOHN\n", + "Patient ID: ANON54121\n", + "Study Date: 20181115\n", + "Structure Set Label: RTstruct\n", + "\n", + "Number of ROIs: 117\n", + "\n", + "ROI List:\n", + " 1. [1] prostate\n", + " 2. [2] gluteus_maximus_left\n", + " 3. [3] vertebrae_C5\n", + " 4. [4] iliopsoas_left\n", + " 5. [5] vertebrae_T1\n", + " 6. [6] rib_right_7\n", + " 7. [7] vertebrae_T12\n", + " 8. [8] atrial_appendage_left\n", + " 9. [9] gluteus_medius_right\n", + " 10. [10] lung_upper_lobe_right\n", + " 11. [11] autochthon_right\n", + " 12. [12] iliac_vena_right\n", + " 13. [13] rib_left_1\n", + " 14. [14] scapula_right\n", + " 15. [15] rib_left_10\n", + " 16. [16] humerus_right\n", + " 17. [17] skull\n", + " 18. [18] lung_lower_lobe_left\n", + " 19. [19] rib_left_12\n", + " 20. [20] hip_right\n", + " 21. [21] portal_vein_and_splenic_vein\n", + " 22. [22] aorta\n", + " 23. [23] rib_left_3\n", + " 24. [24] vertebrae_L4\n", + " 25. [25] brachiocephalic_trunk\n", + " 26. [26] femur_right\n", + " 27. [27] femur_left\n", + " 28. [28] vertebrae_C7\n", + " 29. [29] vertebrae_T10\n", + " 30. [30] rib_right_5\n", + " 31. [31] subclavian_artery_left\n", + " 32. [32] vertebrae_T3\n", + " 33. [33] rib_right_9\n", + " 34. [34] stomach\n", + " 35. [35] sternum\n", + " 36. [36] common_carotid_artery_left\n", + " 37. [37] gallbladder\n", + " 38. [38] rib_left_7\n", + " 39. [39] duodenum\n", + " 40. [40] brachiocephalic_vein_left\n", + " 41. [41] vertebrae_T7\n", + " 42. [42] lung_lower_lobe_right\n", + " 43. [43] thyroid_gland\n", + " 44. [44] rib_right_1\n", + " 45. [45] rib_right_12\n", + " 46. [46] clavicula_left\n", + " 47. [47] vertebrae_C3\n", + " 48. [48] inferior_vena_cava\n", + " 49. [49] rib_right_3\n", + " 50. [50] vertebrae_T9\n", + " 51. [51] gluteus_minimus_left\n", + " 52. [52] clavicula_right\n", + " 53. [53] costal_cartilages\n", + " 54. [54] vertebrae_T5\n", + " 55. [55] vertebrae_C1\n", + " 56. [56] small_bowel\n", + " 57. [57] iliac_artery_left\n", + " 58. [58] superior_vena_cava\n", + " 59. [59] rib_right_10\n", + " 60. [60] lung_upper_lobe_left\n", + " 61. [61] colon\n", + " 62. [62] rib_left_9\n", + " 63. [63] vertebrae_L2\n", + " 64. [64] rib_left_5\n", + " 65. [65] pulmonary_vein\n", + " 66. [66] kidney_left\n", + " 67. [67] iliac_vena_left\n", + " 68. [68] rib_left_11\n", + " 69. [69] sacrum\n", + " 70. [70] spleen\n", + " 71. [71] rib_right_6\n", + " 72. [72] vertebrae_S1\n", + " 73. [73] autochthon_left\n", + " 74. [74] vertebrae_C4\n", + " 75. [75] subclavian_artery_right\n", + " 76. [76] rib_right_4\n", + " 77. [77] vertebrae_T11\n", + " 78. [78] brachiocephalic_vein_right\n", + " 79. [79] rib_right_8\n", + " 80. [80] vertebrae_T2\n", + " 81. [81] trachea\n", + " 82. [82] esophagus\n", + " 83. [83] vertebrae_C6\n", + " 84. [84] heart\n", + " 85. [85] gluteus_minimus_right\n", + " 86. [86] adrenal_gland_left\n", + " 87. [87] adrenal_gland_right\n", + " 88. [88] iliac_artery_right\n", + " 89. [89] scapula_left\n", + " 90. [90] lung_middle_lobe_right\n", + " 91. [91] vertebrae_L5\n", + " 92. [92] rib_left_2\n", + " 93. [93] kidney_cyst_right\n", + " 94. [94] vertebrae_C2\n", + " 95. [95] pancreas\n", + " 96. [96] hip_left\n", + " 97. [97] vertebrae_T6\n", + " 98. [98] liver\n", + " 99. [99] vertebrae_L1\n", + " 100. [100] rib_left_6\n", + " 101. [101] iliopsoas_right\n", + " 102. [102] humerus_left\n", + " 103. [103] spinal_cord\n", + " 104. [104] gluteus_maximus_right\n", + " 105. [105] rib_left_8\n", + " 106. [106] rib_left_4\n", + " 107. [107] vertebrae_L3\n", + " 108. [108] kidney_cyst_left\n", + " 109. [109] brain\n", + " 110. [110] common_carotid_artery_right\n", + " 111. [111] urinary_bladder\n", + " 112. [112] rib_right_11\n", + " 113. [113] gluteus_medius_left\n", + " 114. [114] vertebrae_T8\n", + " 115. [115] kidney_right\n", + " 116. [116] rib_right_2\n", + " 117. [117] vertebrae_T4\n", + "============================================================\n", + "\n", + "\n", + "ROI names: ['prostate', 'gluteus_maximus_left', 'vertebrae_C5', 'iliopsoas_left', 'vertebrae_T1', 'rib_right_7', 'vertebrae_T12', 'atrial_appendage_left', 'gluteus_medius_right', 'lung_upper_lobe_right', 'autochthon_right', 'iliac_vena_right', 'rib_left_1', 'scapula_right', 'rib_left_10', 'humerus_right', 'skull', 'lung_lower_lobe_left', 'rib_left_12', 'hip_right', 'portal_vein_and_splenic_vein', 'aorta', 'rib_left_3', 'vertebrae_L4', 'brachiocephalic_trunk', 'femur_right', 'femur_left', 'vertebrae_C7', 'vertebrae_T10', 'rib_right_5', 'subclavian_artery_left', 'vertebrae_T3', 'rib_right_9', 'stomach', 'sternum', 'common_carotid_artery_left', 'gallbladder', 'rib_left_7', 'duodenum', 'brachiocephalic_vein_left', 'vertebrae_T7', 'lung_lower_lobe_right', 'thyroid_gland', 'rib_right_1', 'rib_right_12', 'clavicula_left', 'vertebrae_C3', 'inferior_vena_cava', 'rib_right_3', 'vertebrae_T9', 'gluteus_minimus_left', 'clavicula_right', 'costal_cartilages', 'vertebrae_T5', 'vertebrae_C1', 'small_bowel', 'iliac_artery_left', 'superior_vena_cava', 'rib_right_10', 'lung_upper_lobe_left', 'colon', 'rib_left_9', 'vertebrae_L2', 'rib_left_5', 'pulmonary_vein', 'kidney_left', 'iliac_vena_left', 'rib_left_11', 'sacrum', 'spleen', 'rib_right_6', 'vertebrae_S1', 'autochthon_left', 'vertebrae_C4', 'subclavian_artery_right', 'rib_right_4', 'vertebrae_T11', 'brachiocephalic_vein_right', 'rib_right_8', 'vertebrae_T2', 'trachea', 'esophagus', 'vertebrae_C6', 'heart', 'gluteus_minimus_right', 'adrenal_gland_left', 'adrenal_gland_right', 'iliac_artery_right', 'scapula_left', 'lung_middle_lobe_right', 'vertebrae_L5', 'rib_left_2', 'kidney_cyst_right', 'vertebrae_C2', 'pancreas', 'hip_left', 'vertebrae_T6', 'liver', 'vertebrae_L1', 'rib_left_6', 'iliopsoas_right', 'humerus_left', 'spinal_cord', 'gluteus_maximus_right', 'rib_left_8', 'rib_left_4', 'vertebrae_L3', 'kidney_cyst_left', 'brain', 'common_carotid_artery_right', 'urinary_bladder', 'rib_right_11', 'gluteus_medius_left', 'vertebrae_T8', 'kidney_right', 'rib_right_2', 'vertebrae_T4']\n" + ] + } + ], + "source": [ + "from pytheranostics.segmentation import get_rtstruct_roi_names, print_rtstruct_info\n", + "\n", + "# Get the first RT-STRUCT file path\n", + "patient_id = list(rtstruct_result.keys())[0]\n", + "timepoint = list(rtstruct_result[patient_id].keys())[0]\n", + "rtstruct_path = rtstruct_result[patient_id][timepoint]\n", + "\n", + "print(f\"Inspecting: {rtstruct_path}\\n\")\n", + "print_rtstruct_info(str(rtstruct_path))\n", + "\n", + "# Get just the ROI names\n", + "roi_names = get_rtstruct_roi_names(str(rtstruct_path))\n", + "print(f\"\\nROI names: {roi_names}\")" + ] + }, + { + "cell_type": "markdown", + "id": "580bb7c0", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This tutorial demonstrated:\n", + "\n", + "1. **TotalSegmentator Integration**: PyTheranostics provides a streamlined interface to TotalSegmentator for automatic CT segmentation of 104 anatomical structures\n", + " \n", + "2. **Two-Step Workflow** (Recommended):\n", + " - `totalseg_segment()`: Run segmentation once (slow)\n", + " - `convert_masks_to_rtstruct()`: Convert to RT-STRUCT multiple times with different configs (fast)\n", + " \n", + "3. **Configuration Files**: Control which organs to include, rename them, and combine related structures\n", + " \n", + "4. **RT-STRUCT Output**: Standard DICOM format compatible with treatment planning systems and DICOM viewers\n", + "\n", + "### Key Advantages\n", + "\n", + "- **Efficiency**: Segment once, create multiple RT-STRUCTs with different organ selections\n", + "- **Flexibility**: Easy to customize organ names and groupings for different clinical workflows\n", + "- **Automation**: Automatic CT discovery, patient ID extraction, and timepoint handling\n", + "- **Standardization**: DICOM RT-STRUCT output works with existing clinical tools\n", + "\n", + "### Next Steps\n", + "\n", + "- **Dosimetry**: Use these RT-STRUCTs for organ dose calculations\n", + "- **Visualization**: Load RT-STRUCTs in MIM, 3D Slicer, or treatment planning systems\n", + "- **Analysis**: Extract organ volumes, statistics, or use for dose-volume histogram analysis\n", + "\n", + "### Citations\n", + "\n", + "**TotalSegmentator**:\n", + "> Wasserthal J, Breit HC, Meyer MT, et al. TotalSegmentator: Robust Segmentation of 104 Anatomic Structures in CT Images. *Radiology: Artificial Intelligence*. 2023;5(5):e230024. https://doi.org/10.1148/ryai.230024\n", + "\n", + "**Example Dataset**:\n", + "> Dewaraja Yuni and Van, Benjamin. University of Michigan Deep Blue Repository. SNMMI Dosimetry Challenge Dataset. https://doi.org/10.7302/864r-tb45" + ] + }, + { + "cell_type": "markdown", + "id": "d301c96c", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pytheranostics", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From b0d85ed07dc52bd154116d4f60757a173baf5304 Mon Sep 17 00:00:00 2001 From: Carlos Uribe Date: Thu, 22 Jan 2026 13:14:09 -0800 Subject: [PATCH 21/21] Fix level for step 5 --- .../tutorials/segmentation/total_segmentator_tutorial.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb b/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb index d502c8e..71e46e2 100644 --- a/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb +++ b/docs/source/tutorials/segmentation/total_segmentator_tutorial.ipynb @@ -699,7 +699,7 @@ "id": "81f65bbd", "metadata": {}, "source": [ - "# Step 5: Inspect the RT-STRUCT Files\n", + "## Step 5: Inspect the RT-STRUCT Files\n", "\n", "PyTheranostics includes utilities to inspect RT-STRUCT contents:" ]