diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59be7d1..964a736 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 run_full_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/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/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 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..71e46e2 --- /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 +} diff --git a/pyproject.toml b/pyproject.toml index 5cbc512..ec73121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,12 @@ dependencies = [ "Bug Tracker" = "https://github.com/qurit/PyTheranostics/issues" [project.optional-dependencies] +totalsegmentator = [ + "torch", + "torchvision", + "TotalSegmentator", + "nibabel", +] test = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -75,7 +81,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/pytheranostics/__init__.py b/pytheranostics/__init__.py index 878bd5d..bfe43a7 100644 --- a/pytheranostics/__init__.py +++ b/pytheranostics/__init__.py @@ -9,10 +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 -from pytheranostics.segmentation.tools import rtst_to_mask # Image processing 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 @@ -27,9 +29,11 @@ "shared": "pytheranostics.shared", "plots": "pytheranostics.plots", "qc": "pytheranostics.qc", + "data_fetchers": "pytheranostics.data_fetchers", "dicomtools": "pytheranostics.dicomtools", "fits": "pytheranostics.fits", "calibrations": "pytheranostics.calibrations", + "project": "pytheranostics.project", } @@ -70,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", @@ -83,6 +89,7 @@ def __getattr__(name): "dicomtools", "fits", "calibrations", + "project", # Legacy aliases "MiscTools", "ImagingTools", diff --git a/pytheranostics/data/configuration_templates/total_seg_config.json b/pytheranostics/data/configuration_templates/total_seg_config.json new file mode 100644 index 0000000..80d744b --- /dev/null +++ b/pytheranostics/data/configuration_templates/total_seg_config.json @@ -0,0 +1,590 @@ +{ + "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 + } + ], + "combine": [] +} 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" + } +} diff --git a/pytheranostics/data_fetchers/__init__.py b/pytheranostics/data_fetchers/__init__.py new file mode 100644 index 0000000..75df77a --- /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_dir, + get_example_data_citation, + list_cached_data, +) + +__all__ = [ + "fetch_snmmi_dosimetry_challenge", + "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 new file mode 100644 index 0000000..465dc72 --- /dev/null +++ b/pytheranostics/data_fetchers/fetchers.py @@ -0,0 +1,259 @@ +"""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_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_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 directory. + """ + if data_dir is None: + data_dir = Path.home() / ".pytheranostics_example_data" + else: + data_dir = Path(data_dir) + + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + +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_dir: Optional[str] = None): + """Remove all cached example data. + + Parameters + ---------- + data_dir : 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_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_dir: Optional[str] = None): + """List all cached example datasets. + + Parameters + ---------- + data_dir : 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_dir = get_data_dir(data_dir) + if not data_dir.exists(): + print("No cached data found") + return + + 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) + 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_dir(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(" 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: + 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" + ) 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 diff --git a/pytheranostics/segmentation/__init__.py b/pytheranostics/segmentation/__init__.py index 3731650..87f0a6d 100644 --- a/pytheranostics/segmentation/__init__.py +++ b/pytheranostics/segmentation/__init__.py @@ -1 +1,23 @@ -"""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_segmentator import convert_masks_to_rtstruct, totalseg_segment + +__all__ = [ + "RTStructConverter", + "get_rtstruct_roi_names", + "print_rtstruct_info", + "export_rtstruct_rois_to_csv", + "export_multiple_rtstructs_to_csv", + "totalseg_segment", + "convert_masks_to_rtstruct", +] diff --git a/pytheranostics/segmentation/rtst_utilities.py b/pytheranostics/segmentation/rtst_utilities.py new file mode 100644 index 0000000..c67aeb7 --- /dev/null +++ b/pytheranostics/segmentation/rtst_utilities.py @@ -0,0 +1,407 @@ +"""Helpers for working with RT structure sets.""" + +import csv +import json +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_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] + 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) + + # 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() + + # 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] + 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 + source_lower = source.lower() + for fname in os.listdir(nifti_folder): + stem = fname.replace(".nii.gz", "").replace(".nii", "") + if stem.lower() == source_lower: + 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 {len(source_list)} sources)" + ) + + 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_segmentator.py b/pytheranostics/segmentation/total_segmentator.py new file mode 100644 index 0000000..1f08a72 --- /dev/null +++ b/pytheranostics/segmentation/total_segmentator.py @@ -0,0 +1,318 @@ +"""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 + + +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]): + """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] = [] + + # 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_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 + + +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 totalseg_segment( + input_folders: Optional[List[str]] = None, + base_output_dir: str | Path = ".", + *, + root_dir: Optional[str | Path] = None, + device: str = "mps", + parallel: bool = False, + max_workers: int = 2, +) -> Dict[str, Dict[str, Path]]: + """Run TotalSegmentator segmentation (steps 1-2 only). + + Discovers CT series and runs TotalSegmentator. Outputs can be reused for + multiple RT-STRUCT conversions with different configs. + + 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 '.'. + 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. + + Returns + ------- + 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) + + # Determine and announce effective device + resolved_device = device + try: + import torch + + 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 + tasks: List[Tuple[Path, str, str, Path]] = [] + + for ct_path in input_paths: + tp = _extract_timepoint(ct_path) + if tp == "unknown": + 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 + if parallel: + from concurrent.futures import ProcessPoolExecutor + + 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) + ) + + # 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) + + 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"Using config: {config_path}") + else: + print("No config found, adding all masks") + + # 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 = RTStructConverter(ct_dicom_folder=str(ct_path)) + + 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 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