Spaces:
Sleeping
Sleeping
Commit ·
5d2df83
1
Parent(s): 873a11f
Deploy dosemetrics app - 2025-12-28 17:28:50
Browse files- README.md +7 -6
- pyproject.toml +29 -5
- setup_repo.sh +32 -9
- src/dosemetrics/__init__.py +94 -108
- src/dosemetrics/dose.py +225 -0
- src/dosemetrics/io/__init__.py +31 -0
- src/dosemetrics/io/data_io.py +288 -0
- src/dosemetrics/io/dicom_io.py +486 -0
- src/dosemetrics/io/nifti_io.py +506 -0
- src/dosemetrics/metrics/__init__.py +98 -34
- src/dosemetrics/metrics/advanced_dvh.py +495 -0
- src/dosemetrics/metrics/conformity.py +224 -0
- src/dosemetrics/metrics/dose_comparison.py +618 -0
- src/dosemetrics/metrics/dvh.py +486 -101
- src/dosemetrics/metrics/gamma.py +362 -0
- src/dosemetrics/metrics/geometric.py +384 -0
- src/dosemetrics/metrics/homogeneity.py +195 -0
- src/dosemetrics/structure_set.py +308 -0
- src/dosemetrics/structures.py +249 -0
- src/dosemetrics/utils/__init__.py +56 -37
- src/dosemetrics/utils/analysis.py +467 -0
- src/dosemetrics/utils/batch.py +424 -299
- src/dosemetrics/utils/compliance.py +87 -268
- src/dosemetrics/utils/plot.py +640 -686
- src/dosemetrics_app/app.py +25 -14
- src/dosemetrics_app/tabs/__init__.py +8 -17
- src/dosemetrics_app/tabs/calculate_dvh.py +63 -8
- src/dosemetrics_app/tabs/compliance_tab.py +299 -0
- src/dosemetrics_app/tabs/comprehensive_analysis.py +592 -0
- src/dosemetrics_app/tabs/conformity_tab.py +241 -0
- src/dosemetrics_app/tabs/gamma_tab.py +314 -0
- src/dosemetrics_app/tabs/geometric_tab.py +321 -0
- src/dosemetrics_app/tabs/homogeneity_tab.py +257 -0
- src/dosemetrics_app/tabs/instructions.py +50 -4
- src/dosemetrics_app/tabs/statistics_tab.py +178 -0
- src/dosemetrics_app/tabs/visualize_dose.py +59 -7
- src/dosemetrics_app/utils.py +257 -0
- src/dosemetrics_cli/__main__.py +470 -53
README.md
CHANGED
|
@@ -14,10 +14,11 @@ A Streamlit application for analyzing radiotherapy dose distributions and creati
|
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
| 17 |
-
- **
|
| 18 |
-
- **
|
| 19 |
-
- **
|
| 20 |
-
- **
|
|
|
|
| 21 |
|
| 22 |
## Usage
|
| 23 |
|
|
@@ -43,7 +44,7 @@ DoseMetrics provides tools for medical physicists and radiation oncologists to a
|
|
| 43 |
- Geometric analysis of structures
|
| 44 |
- Interactive visualizations
|
| 45 |
|
| 46 |
-
For more information, visit the [GitHub repository](https://github.com/
|
| 47 |
|
| 48 |
## Citation
|
| 49 |
|
|
@@ -54,7 +55,7 @@ If you use DoseMetrics in your research, please cite:
|
|
| 54 |
author = {Kamath, Amith},
|
| 55 |
title = {DoseMetrics: Tools for Radiotherapy Dose Analysis},
|
| 56 |
year = {2024},
|
| 57 |
-
url = {https://github.com/
|
| 58 |
}
|
| 59 |
```
|
| 60 |
|
|
|
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
| 17 |
+
- **Instructions**: Get started with comprehensive usage guidelines and examples
|
| 18 |
+
- **Dosimetric Analysis**: Comprehensive dose analysis with DVH calculations, dose statistics, and interactive visualizations
|
| 19 |
+
- **Geometric Comparison**: Analyze geometric differences between structure sets including dice coefficient, Hausdorff distance, and volume overlaps
|
| 20 |
+
- **Gamma Analysis**: Perform gamma analysis to compare dose distributions between reference and evaluated plans
|
| 21 |
+
- **Compliance Checking**: Evaluate treatment plans against clinical constraints and dose limits
|
| 22 |
|
| 23 |
## Usage
|
| 24 |
|
|
|
|
| 44 |
- Geometric analysis of structures
|
| 45 |
- Interactive visualizations
|
| 46 |
|
| 47 |
+
For more information, visit the [GitHub repository](https://github.com/contouraid/dosemetrics).
|
| 48 |
|
| 49 |
## Citation
|
| 50 |
|
|
|
|
| 55 |
author = {Kamath, Amith},
|
| 56 |
title = {DoseMetrics: Tools for Radiotherapy Dose Analysis},
|
| 57 |
year = {2024},
|
| 58 |
+
url = {https://github.com/contouraid/dosemetrics}
|
| 59 |
}
|
| 60 |
```
|
| 61 |
|
pyproject.toml
CHANGED
|
@@ -7,7 +7,7 @@ name = "dosemetrics"
|
|
| 7 |
version = "0.2.0"
|
| 8 |
description = "Measuring radiotherapy doses - plotting visualizations"
|
| 9 |
readme = "README.md"
|
| 10 |
-
requires-python = ">=3.
|
| 11 |
license = {file = "LICENSE"}
|
| 12 |
authors = [
|
| 13 |
{name = "Amith Kamath", email = "amith.kamath@unibe.ch"}
|
|
@@ -38,23 +38,47 @@ classifiers = [
|
|
| 38 |
dependencies = [
|
| 39 |
"altair~=5.4.0",
|
| 40 |
"gpssi>=0.1.2",
|
|
|
|
|
|
|
| 41 |
"matplotlib~=3.8.2",
|
|
|
|
|
|
|
| 42 |
"nibabel==5.2.1",
|
|
|
|
| 43 |
"numpy~=1.26.2",
|
| 44 |
"pandas>=2.1.4",
|
| 45 |
"pillow==10.4.0",
|
| 46 |
"plotly~=5.23.0",
|
| 47 |
-
"pydicom>=3.0
|
|
|
|
| 48 |
"pymia>=0.3.4",
|
|
|
|
|
|
|
| 49 |
"scipy>=1.11.0",
|
|
|
|
| 50 |
"simpleitk>=2.4",
|
| 51 |
"streamlit~=1.45.0",
|
| 52 |
]
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
[project.urls]
|
| 55 |
-
Homepage = "https://github.com/
|
| 56 |
-
Repository = "https://github.com/
|
| 57 |
-
Issues = "https://github.com/
|
| 58 |
|
| 59 |
[tool.setuptools.packages.find]
|
| 60 |
where = ["src"]
|
|
|
|
| 7 |
version = "0.2.0"
|
| 8 |
description = "Measuring radiotherapy doses - plotting visualizations"
|
| 9 |
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.9"
|
| 11 |
license = {file = "LICENSE"}
|
| 12 |
authors = [
|
| 13 |
{name = "Amith Kamath", email = "amith.kamath@unibe.ch"}
|
|
|
|
| 38 |
dependencies = [
|
| 39 |
"altair~=5.4.0",
|
| 40 |
"gpssi>=0.1.2",
|
| 41 |
+
"huggingface_hub>=0.20.0",
|
| 42 |
+
"ipykernel>=6.0.0",
|
| 43 |
"matplotlib~=3.8.2",
|
| 44 |
+
"nbconvert>=7.16.6",
|
| 45 |
+
"nbformat>=5.10.4",
|
| 46 |
"nibabel==5.2.1",
|
| 47 |
+
"numba>=0.58.0",
|
| 48 |
"numpy~=1.26.2",
|
| 49 |
"pandas>=2.1.4",
|
| 50 |
"pillow==10.4.0",
|
| 51 |
"plotly~=5.23.0",
|
| 52 |
+
"pydicom>=2.3.0",
|
| 53 |
+
"pymedphys>=0.39.0",
|
| 54 |
"pymia>=0.3.4",
|
| 55 |
+
"pytest>=8.4.2",
|
| 56 |
+
"scikit-image>=0.21.0",
|
| 57 |
"scipy>=1.11.0",
|
| 58 |
+
"seaborn>=0.13.2",
|
| 59 |
"simpleitk>=2.4",
|
| 60 |
"streamlit~=1.45.0",
|
| 61 |
]
|
| 62 |
|
| 63 |
+
[project.optional-dependencies]
|
| 64 |
+
docs = [
|
| 65 |
+
"mkdocs>=1.5.0",
|
| 66 |
+
"mkdocs-material>=9.5.0",
|
| 67 |
+
"mkdocstrings[python]>=0.24.0",
|
| 68 |
+
"mkdocs-jupyter>=0.24.0",
|
| 69 |
+
"pymdown-extensions>=10.7",
|
| 70 |
+
]
|
| 71 |
+
test = [
|
| 72 |
+
"pytest-cov>=4.0.0",
|
| 73 |
+
"nbformat>=5.0.0",
|
| 74 |
+
"nbconvert>=7.0.0",
|
| 75 |
+
"ipykernel>=6.0.0",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
[project.urls]
|
| 79 |
+
Homepage = "https://github.com/contouraid/dosemetrics"
|
| 80 |
+
Repository = "https://github.com/contouraid/dosemetrics"
|
| 81 |
+
Issues = "https://github.com/contouraid/dosemetrics/issues"
|
| 82 |
|
| 83 |
[tool.setuptools.packages.find]
|
| 84 |
where = ["src"]
|
setup_repo.sh
CHANGED
|
@@ -17,17 +17,40 @@ fi
|
|
| 17 |
# Activate the virtual environment
|
| 18 |
source "$VENV_DIR/bin/activate"
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
if [[ -f "uv.lock" ]]; then
|
| 26 |
-
uv sync
|
| 27 |
else
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
fi
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Print success message
|
| 33 |
echo "Repository setup complete. Virtual environment is ready."
|
|
|
|
| 17 |
# Activate the virtual environment
|
| 18 |
source "$VENV_DIR/bin/activate"
|
| 19 |
|
| 20 |
+
# Prefer using uv (universe) when available, otherwise fall back to pip
|
| 21 |
+
if command -v uv > /dev/null 2>&1 && [[ -f "uv.lock" ]]; then
|
| 22 |
+
echo "Found uv and uv.lock, syncing dependencies with uv..."
|
| 23 |
+
# Use --active to target the currently activated venv, fall back to plain sync
|
| 24 |
+
uv sync --active --no-build-isolation || uv sync --active || uv sync || true
|
|
|
|
|
|
|
| 25 |
else
|
| 26 |
+
if ! command -v uv > /dev/null 2>&1; then
|
| 27 |
+
echo "uv not found; falling back to pip-based install in the venv"
|
| 28 |
+
else
|
| 29 |
+
echo "uv.lock not found; falling back to pip-based install in the venv"
|
| 30 |
+
fi
|
| 31 |
+
|
| 32 |
+
# Upgrade pip and setuptools in the venv to reduce editable install issues
|
| 33 |
+
echo "Upgrading pip, setuptools, and wheel in the virtual environment..."
|
| 34 |
+
python3 -m pip install --upgrade pip setuptools wheel
|
| 35 |
+
|
| 36 |
+
# Try installing the package into the venv (non-fatal if it fails)
|
| 37 |
+
echo "Installing the package into the virtual environment..."
|
| 38 |
+
if python3 -m pip install -e .; then
|
| 39 |
+
echo "Installed package in editable mode."
|
| 40 |
+
else
|
| 41 |
+
echo "Editable install failed, attempting standard install..."
|
| 42 |
+
python3 -m pip install . || true
|
| 43 |
+
fi
|
| 44 |
fi
|
| 45 |
|
| 46 |
+
# Post-install: ensure a couple of common runtime packages are present (SimpleITK, nibabel)
|
| 47 |
+
for PKG in SimpleITK nibabel; do
|
| 48 |
+
# nix module name is the same as the package name for these
|
| 49 |
+
if ! python3 -c "import ${PKG}" >/dev/null 2>&1; then
|
| 50 |
+
echo "${PKG} not found in environment; attempting to install via pip..."
|
| 51 |
+
python3 -m pip install --no-input ${PKG} || echo "Warning: failed to install ${PKG}"
|
| 52 |
+
fi
|
| 53 |
+
done
|
| 54 |
+
|
| 55 |
# Print success message
|
| 56 |
echo "Repository setup complete. Virtual environment is ready."
|
src/dosemetrics/__init__.py
CHANGED
|
@@ -1,88 +1,94 @@
|
|
| 1 |
"""
|
| 2 |
-
Dosemetrics: A library for
|
| 3 |
|
| 4 |
This library provides tools for:
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
|
|
|
|
|
|
| 9 |
- Visualization utilities
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
# Core
|
| 15 |
-
from .
|
|
|
|
| 16 |
Structure,
|
| 17 |
OAR,
|
| 18 |
Target,
|
| 19 |
StructureType,
|
| 20 |
AvoidanceStructure,
|
| 21 |
)
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
#
|
| 24 |
-
from .
|
| 25 |
-
StructureSet,
|
| 26 |
-
create_structure_set_from_folder,
|
| 27 |
-
create_structure_set_from_masks,
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
# Core metrics and calculations
|
| 31 |
-
from .metrics import (
|
| 32 |
-
mean_dose,
|
| 33 |
-
max_dose,
|
| 34 |
-
volume,
|
| 35 |
-
compute_dvh,
|
| 36 |
-
dvh_by_structure,
|
| 37 |
-
dvh_by_dose,
|
| 38 |
-
dose_summary,
|
| 39 |
-
dose_score,
|
| 40 |
-
dvh_score,
|
| 41 |
-
compare_predicted_doses,
|
| 42 |
-
compare_quality_indices,
|
| 43 |
-
compute_geometric_metrics,
|
| 44 |
-
batch_dvh_analysis,
|
| 45 |
-
process_subject_folder,
|
| 46 |
-
)
|
| 47 |
|
| 48 |
# I/O utilities
|
| 49 |
-
from .
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
create_structure_set_from_existing_data,
|
| 59 |
-
get_dose,
|
| 60 |
-
get_structures,
|
| 61 |
)
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
from .
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
# Version information
|
| 88 |
__version__ = "0.2.0"
|
|
@@ -91,57 +97,37 @@ __version__ = "0.2.0"
|
|
| 91 |
__all__ = [
|
| 92 |
# Version
|
| 93 |
"__version__",
|
| 94 |
-
#
|
|
|
|
| 95 |
"Structure",
|
| 96 |
"OAR",
|
| 97 |
"Target",
|
| 98 |
"StructureType",
|
| 99 |
"AvoidanceStructure",
|
| 100 |
-
# Structure set management
|
| 101 |
"StructureSet",
|
| 102 |
-
|
| 103 |
"create_structure_set_from_masks",
|
| 104 |
-
#
|
| 105 |
-
"
|
| 106 |
-
|
| 107 |
-
"
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"
|
| 112 |
-
"
|
| 113 |
-
"
|
| 114 |
-
#
|
| 115 |
-
"
|
| 116 |
-
|
| 117 |
-
"read_from_eclipse",
|
| 118 |
-
"read_dose_and_mask_files",
|
| 119 |
-
"read_from_nifti",
|
| 120 |
-
"read_dose_and_mask_files_as_structure_set",
|
| 121 |
-
"get_dose_and_structures_as_structure_set",
|
| 122 |
-
"create_structure_set_from_existing_data",
|
| 123 |
-
"find_all_files",
|
| 124 |
-
"get_dose",
|
| 125 |
-
"get_structures",
|
| 126 |
-
# Utilities
|
| 127 |
-
"get_default_constraints",
|
| 128 |
"check_compliance",
|
| 129 |
-
"quality_index",
|
| 130 |
"from_dataframe",
|
|
|
|
|
|
|
| 131 |
"compare_dvh",
|
| 132 |
-
"variability",
|
| 133 |
"generate_dvh_variations",
|
| 134 |
-
"plot_dvh_variations",
|
| 135 |
"plot_dvh",
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"generate_dvh_family_plot",
|
| 139 |
-
"interactive_dvh_plotter",
|
| 140 |
-
"get_structures_from_folder",
|
| 141 |
-
"read_dose_and_mask_files_from_folder",
|
| 142 |
-
"create_standard_contents_csv",
|
| 143 |
-
"validate_folder_structure",
|
| 144 |
-
"batch_folder_validation",
|
| 145 |
-
"find_subject_folders",
|
| 146 |
-
"setup_output_structure",
|
| 147 |
]
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Dosemetrics: A library for radiotherapy dose analysis.
|
| 3 |
|
| 4 |
This library provides tools for:
|
| 5 |
+
- Dose distribution analysis
|
| 6 |
+
- Structure set management
|
| 7 |
+
- DVH computation and analysis
|
| 8 |
+
- Quality metrics (conformity, homogeneity)
|
| 9 |
+
- Geometric comparison
|
| 10 |
+
- Compliance checking
|
| 11 |
- Visualization utilities
|
| 12 |
|
| 13 |
+
Architecture:
|
| 14 |
+
- dosemetrics.dose: Dose data container
|
| 15 |
+
- dosemetrics.structures: Structure/OAR/Target classes
|
| 16 |
+
- dosemetrics.structure_set: StructureSet management
|
| 17 |
+
- dosemetrics.metrics: All computational metrics
|
| 18 |
+
- dvh: DVH computation and queries
|
| 19 |
+
- statistics: Dose statistics
|
| 20 |
+
- conformity: Conformity indices
|
| 21 |
+
- homogeneity: Homogeneity indices
|
| 22 |
+
- geometric: Geometric comparisons
|
| 23 |
+
- dosemetrics.io: Data loading/saving
|
| 24 |
+
- dosemetrics.utils: Utilities (plotting, compliance, batch processing)
|
| 25 |
"""
|
| 26 |
|
| 27 |
+
# Core data classes
|
| 28 |
+
from .dose import Dose
|
| 29 |
+
from .structures import (
|
| 30 |
Structure,
|
| 31 |
OAR,
|
| 32 |
Target,
|
| 33 |
StructureType,
|
| 34 |
AvoidanceStructure,
|
| 35 |
)
|
| 36 |
+
from .structure_set import StructureSet
|
| 37 |
+
from .utils.compliance import quality_index, get_default_constraints, check_compliance
|
| 38 |
+
import numpy as np
|
| 39 |
|
| 40 |
+
# Metrics subpackage (use: from dosemetrics.metrics import dvh, conformity, etc.)
|
| 41 |
+
from . import metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# I/O utilities
|
| 44 |
+
from .io import (
|
| 45 |
+
load_from_folder,
|
| 46 |
+
load_structure_set,
|
| 47 |
+
load_volume,
|
| 48 |
+
load_structure,
|
| 49 |
+
detect_folder_format,
|
| 50 |
+
# Format-specific modules
|
| 51 |
+
dicom_io,
|
| 52 |
+
nifti_io,
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
|
| 55 |
+
# Utilities (plotting, compliance, batch)
|
| 56 |
+
from . import utils
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def create_structure_set_from_masks(
|
| 60 |
+
structure_masks: dict,
|
| 61 |
+
spacing: tuple = (1.0, 1.0, 1.0),
|
| 62 |
+
origin: tuple = (0.0, 0.0, 0.0),
|
| 63 |
+
structure_types: dict = None,
|
| 64 |
+
dose_volume: np.ndarray = None,
|
| 65 |
+
name: str = "StructureSet",
|
| 66 |
+
):
|
| 67 |
+
"""Create a StructureSet from mask dictionaries.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
structure_masks: Dict mapping structure names to binary masks
|
| 71 |
+
spacing: Voxel spacing in mm (x, y, z)
|
| 72 |
+
origin: Origin coordinates in mm
|
| 73 |
+
structure_types: Optional dict mapping structure names to StructureType
|
| 74 |
+
dose_volume: Optional dose array to attach
|
| 75 |
+
name: Name for the structure set
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
StructureSet with the structures and optionally dose attached
|
| 79 |
+
"""
|
| 80 |
+
ss = StructureSet(spacing=spacing, origin=origin, name=name)
|
| 81 |
+
for struct_name, mask in structure_masks.items():
|
| 82 |
+
stype = None
|
| 83 |
+
if structure_types:
|
| 84 |
+
stype = structure_types.get(struct_name)
|
| 85 |
+
if isinstance(stype, str):
|
| 86 |
+
stype = StructureType(stype.lower()) if stype.lower() in StructureType._value2member_map_ else StructureType.OAR
|
| 87 |
+
if stype is None:
|
| 88 |
+
stype = StructureType.OAR
|
| 89 |
+
ss.add_structure(struct_name, mask, stype)
|
| 90 |
+
# Note: dose_volume parameter is ignored - use Dose objects for dose analysis
|
| 91 |
+
return ss
|
| 92 |
|
| 93 |
# Version information
|
| 94 |
__version__ = "0.2.0"
|
|
|
|
| 97 |
__all__ = [
|
| 98 |
# Version
|
| 99 |
"__version__",
|
| 100 |
+
# Core data classes
|
| 101 |
+
"Dose",
|
| 102 |
"Structure",
|
| 103 |
"OAR",
|
| 104 |
"Target",
|
| 105 |
"StructureType",
|
| 106 |
"AvoidanceStructure",
|
|
|
|
| 107 |
"StructureSet",
|
| 108 |
+
# Helper functions
|
| 109 |
"create_structure_set_from_masks",
|
| 110 |
+
# Metrics subpackage (access via metrics.dvh, metrics.conformity, etc.)
|
| 111 |
+
"metrics",
|
| 112 |
+
# I/O subpackage
|
| 113 |
+
"load_from_folder",
|
| 114 |
+
"load_structure_set",
|
| 115 |
+
"load_volume",
|
| 116 |
+
"load_structure",
|
| 117 |
+
"detect_folder_format",
|
| 118 |
+
"dicom_io",
|
| 119 |
+
"nifti_io",
|
| 120 |
+
# Utils subpackage (access via utils.compliance, utils.plot, etc.)
|
| 121 |
+
"utils",
|
| 122 |
+
# Convenience exports from utils
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
"check_compliance",
|
|
|
|
| 124 |
"from_dataframe",
|
| 125 |
+
"quality_index",
|
| 126 |
+
"get_default_constraints",
|
| 127 |
"compare_dvh",
|
|
|
|
| 128 |
"generate_dvh_variations",
|
|
|
|
| 129 |
"plot_dvh",
|
| 130 |
+
"plot_dvh_variations",
|
| 131 |
+
"variability",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
]
|
| 133 |
+
|
src/dosemetrics/dose.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dose distribution class for radiotherapy dose data.
|
| 3 |
+
|
| 4 |
+
This module provides the Dose class for representing 3D dose distributions
|
| 5 |
+
from RT-DOSE DICOM files or NIfTI files. The Dose class is a pure data
|
| 6 |
+
container - dose metrics are computed using functions in the metrics subpackage.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Dict, Optional, Tuple, Union, TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from .structures import Structure
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Dose:
|
| 20 |
+
"""
|
| 21 |
+
Represents a 3D dose distribution from RT-DOSE or NIfTI.
|
| 22 |
+
|
| 23 |
+
A Dose object is a pure data container representing dose distributions.
|
| 24 |
+
Dose analysis is performed by combining Dose with Structure objects using
|
| 25 |
+
functions from the metrics subpackage.
|
| 26 |
+
|
| 27 |
+
Attributes:
|
| 28 |
+
dose_array (np.ndarray): 3D array of dose values in Gy
|
| 29 |
+
spacing (Tuple[float, float, float]): Voxel spacing in (x, y, z) mm
|
| 30 |
+
origin (Tuple[float, float, float]): Origin coordinates in mm
|
| 31 |
+
name (str): Identifier for this dose distribution
|
| 32 |
+
metadata (Dict): Additional metadata (DICOM tags, beam info, etc.)
|
| 33 |
+
|
| 34 |
+
Examples:
|
| 35 |
+
>>> from dosemetrics.dose import Dose
|
| 36 |
+
>>> from dosemetrics.metrics import dvh
|
| 37 |
+
>>>
|
| 38 |
+
>>> # Load dose from DICOM
|
| 39 |
+
>>> dose = Dose.from_dicom("path/to/rtdose.dcm", name="Plan_v1")
|
| 40 |
+
>>>
|
| 41 |
+
>>> # Load dose from NIfTI
|
| 42 |
+
>>> dose = Dose.from_nifti("path/to/dose.nii.gz", name="Predicted")
|
| 43 |
+
>>>
|
| 44 |
+
>>> # Compute dose statistics (use metrics module)
|
| 45 |
+
>>> ptv = structure_set.get_structure("PTV")
|
| 46 |
+
>>> stats = statistics.compute_dose_statistics(dose, ptv)
|
| 47 |
+
>>> print(f"Mean dose: {stats['mean_dose']:.2f} Gy")
|
| 48 |
+
>>>
|
| 49 |
+
>>> # Compute DVH (use metrics module)
|
| 50 |
+
>>> dose_bins, volumes = dvh.compute_dvh(dose, ptv)
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
dose_array: np.ndarray,
|
| 56 |
+
spacing: Tuple[float, float, float],
|
| 57 |
+
origin: Tuple[float, float, float],
|
| 58 |
+
name: str = "Dose",
|
| 59 |
+
metadata: Optional[Dict] = None,
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Initialize a Dose distribution.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
dose_array: 3D array of dose values (Gy)
|
| 66 |
+
spacing: Voxel spacing in (x, y, z) mm
|
| 67 |
+
origin: Origin coordinates in mm
|
| 68 |
+
name: Identifier for this dose (e.g., "Plan_v1", "Sum", "Predicted")
|
| 69 |
+
metadata: Additional metadata (DICOM tags, beam info, etc.)
|
| 70 |
+
|
| 71 |
+
Raises:
|
| 72 |
+
ValueError: If dose_array is not 3D
|
| 73 |
+
"""
|
| 74 |
+
self.dose_array = np.asarray(dose_array)
|
| 75 |
+
self.spacing = tuple(spacing)
|
| 76 |
+
self.origin = tuple(origin)
|
| 77 |
+
self.name = name
|
| 78 |
+
self.metadata = metadata or {}
|
| 79 |
+
|
| 80 |
+
# Validate 3D
|
| 81 |
+
if self.dose_array.ndim != 3:
|
| 82 |
+
raise ValueError(
|
| 83 |
+
f"Dose array must be 3D, got {self.dose_array.ndim}D array"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def shape(self) -> Tuple[int, int, int]:
|
| 88 |
+
"""Shape of the dose array."""
|
| 89 |
+
return self.dose_array.shape
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def max_dose(self) -> float:
|
| 93 |
+
"""Maximum dose in the entire distribution (Gy)."""
|
| 94 |
+
return float(np.max(self.dose_array))
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def mean_dose(self) -> float:
|
| 98 |
+
"""Mean dose across the entire volume (Gy)."""
|
| 99 |
+
return float(np.mean(self.dose_array))
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def min_dose(self) -> float:
|
| 103 |
+
"""Minimum dose in the distribution (Gy)."""
|
| 104 |
+
return float(np.min(self.dose_array))
|
| 105 |
+
|
| 106 |
+
def is_compatible_with_structure(self, structure: Structure) -> bool:
|
| 107 |
+
"""
|
| 108 |
+
Check if this dose is spatially compatible with a structure.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
structure: Structure to check compatibility with
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
True if shapes, spacing, and origin match
|
| 115 |
+
"""
|
| 116 |
+
if structure.mask is None:
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
return (
|
| 120 |
+
self.shape == structure.mask.shape
|
| 121 |
+
and np.allclose(self.spacing, structure.spacing, rtol=1e-5)
|
| 122 |
+
and np.allclose(self.origin, structure.origin, rtol=1e-5)
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def get_dose_in_structure(self, structure: Structure) -> np.ndarray:
|
| 126 |
+
"""
|
| 127 |
+
Extract dose values within a structure mask.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
structure: Structure to extract dose from
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
1D array of dose values inside the structure
|
| 134 |
+
|
| 135 |
+
Raises:
|
| 136 |
+
ValueError: If dose and structure are not spatially compatible
|
| 137 |
+
"""
|
| 138 |
+
if not self.is_compatible_with_structure(structure):
|
| 139 |
+
raise ValueError(
|
| 140 |
+
f"Dose '{self.name}' (shape={self.shape}) is not compatible "
|
| 141 |
+
f"with structure '{structure.name}' (shape={structure.mask.shape}). "
|
| 142 |
+
f"Dose spacing: {self.spacing}, Structure spacing: {structure.spacing}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return self.dose_array[structure.mask]
|
| 146 |
+
|
| 147 |
+
@classmethod
|
| 148 |
+
def from_nifti(
|
| 149 |
+
cls,
|
| 150 |
+
file_path: Union[str, Path],
|
| 151 |
+
name: Optional[str] = None
|
| 152 |
+
) -> Dose:
|
| 153 |
+
"""
|
| 154 |
+
Load dose distribution from a NIfTI file.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
file_path: Path to NIfTI file (.nii or .nii.gz)
|
| 158 |
+
name: Name for this dose (uses filename stem if None)
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Dose object
|
| 162 |
+
|
| 163 |
+
Raises:
|
| 164 |
+
FileNotFoundError: If file doesn't exist
|
| 165 |
+
ValueError: If file cannot be loaded
|
| 166 |
+
"""
|
| 167 |
+
from .io.data_io import load_volume
|
| 168 |
+
|
| 169 |
+
file_path = Path(file_path)
|
| 170 |
+
volume, spacing, origin = load_volume(file_path)
|
| 171 |
+
|
| 172 |
+
if name is None:
|
| 173 |
+
name = file_path.stem.replace('.nii', '')
|
| 174 |
+
|
| 175 |
+
return cls(volume, spacing, origin, name=name)
|
| 176 |
+
|
| 177 |
+
@classmethod
|
| 178 |
+
def from_dicom(
|
| 179 |
+
cls,
|
| 180 |
+
file_path: Union[str, Path],
|
| 181 |
+
name: Optional[str] = None
|
| 182 |
+
) -> Dose:
|
| 183 |
+
"""
|
| 184 |
+
Load dose distribution from a DICOM RT-DOSE file.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
file_path: Path to RT-DOSE DICOM file
|
| 188 |
+
name: Name for this dose (uses filename stem if None)
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
Dose object
|
| 192 |
+
|
| 193 |
+
Raises:
|
| 194 |
+
FileNotFoundError: If file doesn't exist
|
| 195 |
+
ValueError: If file is not a valid RT-DOSE
|
| 196 |
+
"""
|
| 197 |
+
from .io.dicom_io import read_dicom_rtdose
|
| 198 |
+
|
| 199 |
+
file_path = Path(file_path)
|
| 200 |
+
dose_array, spacing, origin, scaling = read_dicom_rtdose(file_path)
|
| 201 |
+
|
| 202 |
+
if name is None:
|
| 203 |
+
name = file_path.stem
|
| 204 |
+
|
| 205 |
+
metadata = {'dose_scaling': scaling}
|
| 206 |
+
|
| 207 |
+
return cls(dose_array, spacing, origin, name=name, metadata=metadata)
|
| 208 |
+
|
| 209 |
+
def __repr__(self) -> str:
|
| 210 |
+
"""String representation of the Dose object."""
|
| 211 |
+
return (
|
| 212 |
+
f"Dose(name='{self.name}', shape={self.shape}, "
|
| 213 |
+
f"max={self.max_dose:.2f} Gy, mean={self.mean_dose:.2f} Gy)"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def __str__(self) -> str:
|
| 217 |
+
"""Human-readable string representation."""
|
| 218 |
+
return (
|
| 219 |
+
f"Dose Distribution '{self.name}':\n"
|
| 220 |
+
f" Shape: {self.shape}\n"
|
| 221 |
+
f" Spacing: {self.spacing} mm\n"
|
| 222 |
+
f" Max dose: {self.max_dose:.2f} Gy\n"
|
| 223 |
+
f" Mean dose: {self.mean_dose:.2f} Gy\n"
|
| 224 |
+
f" Min dose: {self.min_dose:.2f} Gy"
|
| 225 |
+
)
|
src/dosemetrics/io/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data management utilities for radiotherapy dose and structure data.
|
| 3 |
+
|
| 4 |
+
This module provides I/O utilities for reading and writing dose and structure data
|
| 5 |
+
in DICOM and NIfTI formats.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
# High-level unified I/O
|
| 9 |
+
from .data_io import (
|
| 10 |
+
load_from_folder,
|
| 11 |
+
load_structure_set,
|
| 12 |
+
load_volume,
|
| 13 |
+
load_structure,
|
| 14 |
+
detect_folder_format,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Format-specific I/O modules
|
| 18 |
+
from . import dicom_io
|
| 19 |
+
from . import nifti_io
|
| 20 |
+
|
| 21 |
+
__all__ = [
|
| 22 |
+
# High-level I/O
|
| 23 |
+
"load_from_folder",
|
| 24 |
+
"load_structure_set",
|
| 25 |
+
"load_volume",
|
| 26 |
+
"load_structure",
|
| 27 |
+
"detect_folder_format",
|
| 28 |
+
# Format-specific modules
|
| 29 |
+
"dicom_io",
|
| 30 |
+
"nifti_io",
|
| 31 |
+
]
|
src/dosemetrics/io/data_io.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified I/O module for radiotherapy data.
|
| 3 |
+
|
| 4 |
+
This module provides high-level functions for loading radiotherapy data from
|
| 5 |
+
various sources (DICOM, NIfTI) with automatic format detection and intelligent
|
| 6 |
+
structure organization.
|
| 7 |
+
|
| 8 |
+
The API is organized in layers:
|
| 9 |
+
1. Low-level: Format-specific readers (dicom_io, nifti_io)
|
| 10 |
+
2. Mid-level: Type-specific loaders (load_volume, load_structure, etc.)
|
| 11 |
+
3. High-level: Auto-detecting folder loaders (load_from_folder, load_structure_set)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import numpy as np
|
| 16 |
+
from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
if TYPE_CHECKING:
|
| 20 |
+
from ..structures import Structure, StructureType
|
| 21 |
+
from ..structure_set import StructureSet
|
| 22 |
+
|
| 23 |
+
from . import dicom_io
|
| 24 |
+
from . import nifti_io
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def detect_folder_format(folder_path: Union[str, Path]) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Detect the data format in a folder (DICOM or NIfTI).
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
folder_path: Path to folder to inspect
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
'dicom' if DICOM files found, 'nifti' if NIfTI files found, 'unknown' otherwise
|
| 36 |
+
"""
|
| 37 |
+
folder_path = Path(folder_path)
|
| 38 |
+
|
| 39 |
+
if not folder_path.exists():
|
| 40 |
+
return 'unknown'
|
| 41 |
+
|
| 42 |
+
# Check for DICOM files directly
|
| 43 |
+
if list(folder_path.rglob('*.dcm')):
|
| 44 |
+
return 'dicom'
|
| 45 |
+
|
| 46 |
+
# Check for NIfTI files
|
| 47 |
+
if list(folder_path.rglob('*.nii.gz')) or list(folder_path.rglob('*.nii')):
|
| 48 |
+
return 'nifti'
|
| 49 |
+
|
| 50 |
+
return 'unknown'
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_from_folder(
|
| 54 |
+
folder_path: Union[str, Path],
|
| 55 |
+
format: Optional[str] = None,
|
| 56 |
+
**kwargs
|
| 57 |
+
) -> Dict[str, Union[np.ndarray, Dict, Tuple]]:
|
| 58 |
+
"""
|
| 59 |
+
Load radiotherapy data from a folder, auto-detecting format.
|
| 60 |
+
|
| 61 |
+
This is the highest-level function for loading data. It automatically detects
|
| 62 |
+
whether the folder contains DICOM or NIfTI data and loads accordingly.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
folder_path: Path to folder containing data
|
| 66 |
+
format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.
|
| 67 |
+
**kwargs: Additional arguments passed to format-specific loaders
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Dictionary with loaded data. Keys depend on format:
|
| 71 |
+
For DICOM: 'ct_volume', 'dose_volumes', 'structures', 'spacing', 'origin'
|
| 72 |
+
For NIfTI: 'dose_volume', 'structure_masks', 'image_volumes', 'spacing', 'origin'
|
| 73 |
+
|
| 74 |
+
Raises:
|
| 75 |
+
FileNotFoundError: If folder doesn't exist
|
| 76 |
+
ValueError: If format cannot be determined
|
| 77 |
+
"""
|
| 78 |
+
folder_path = Path(folder_path)
|
| 79 |
+
|
| 80 |
+
if not folder_path.exists():
|
| 81 |
+
raise FileNotFoundError(f"Folder not found: {folder_path}")
|
| 82 |
+
|
| 83 |
+
# Auto-detect format if not specified
|
| 84 |
+
if format is None:
|
| 85 |
+
format = detect_folder_format(folder_path)
|
| 86 |
+
|
| 87 |
+
if format == 'dicom':
|
| 88 |
+
return dicom_io.load_dicom_folder(folder_path, **kwargs)
|
| 89 |
+
elif format == 'nifti':
|
| 90 |
+
return nifti_io.load_nifti_folder(folder_path, **kwargs)
|
| 91 |
+
else:
|
| 92 |
+
raise ValueError(
|
| 93 |
+
f"Unknown format in {folder_path}. "
|
| 94 |
+
f"Folder should contain either DICOM files or NIfTI files."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def load_structure_set(
|
| 99 |
+
folder_path: Union[str, Path],
|
| 100 |
+
format: Optional[str] = None,
|
| 101 |
+
name: Optional[str] = None,
|
| 102 |
+
structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
|
| 103 |
+
**kwargs
|
| 104 |
+
) -> "StructureSet":
|
| 105 |
+
"""
|
| 106 |
+
Load a complete StructureSet from a folder, auto-detecting format.
|
| 107 |
+
|
| 108 |
+
This is the primary high-level function for loading radiotherapy data as a
|
| 109 |
+
unified StructureSet object. It handles both DICOM and NIfTI formats.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
folder_path: Path to folder containing data
|
| 113 |
+
format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.
|
| 114 |
+
name: Name for the structure set. If None, uses folder name.
|
| 115 |
+
structure_type_mapping: Optional dict mapping structure names to StructureType
|
| 116 |
+
**kwargs: Additional arguments passed to format-specific loaders
|
| 117 |
+
For NIfTI: dose_filename (default: "Dose.nii.gz")
|
| 118 |
+
For DICOM: dose_file_name (specific dose file to use)
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
StructureSet object with loaded structures and dose
|
| 122 |
+
|
| 123 |
+
Raises:
|
| 124 |
+
FileNotFoundError: If folder doesn't exist
|
| 125 |
+
ValueError: If format cannot be determined or no structures found
|
| 126 |
+
|
| 127 |
+
Examples:
|
| 128 |
+
>>> # Load from DICOM folder
|
| 129 |
+
>>> structure_set = load_structure_set('path/to/dicom_folder')
|
| 130 |
+
|
| 131 |
+
>>> # Load from NIfTI folder with custom dose filename
|
| 132 |
+
>>> structure_set = load_structure_set('path/to/nifti_folder',
|
| 133 |
+
... dose_filename='dose_distribution.nii.gz')
|
| 134 |
+
|
| 135 |
+
>>> # Force format and provide structure types
|
| 136 |
+
>>> type_mapping = {'Liver': StructureType.OAR, 'PTV': StructureType.TARGET}
|
| 137 |
+
>>> structure_set = load_structure_set('path/to/folder',
|
| 138 |
+
... format='nifti',
|
| 139 |
+
... structure_type_mapping=type_mapping)
|
| 140 |
+
"""
|
| 141 |
+
from ..structure_set import StructureSet # Import here to avoid circular dependency
|
| 142 |
+
|
| 143 |
+
folder_path = Path(folder_path)
|
| 144 |
+
|
| 145 |
+
if not folder_path.exists():
|
| 146 |
+
raise FileNotFoundError(f"Folder not found: {folder_path}")
|
| 147 |
+
|
| 148 |
+
# Auto-detect format if not specified
|
| 149 |
+
if format is None:
|
| 150 |
+
format = detect_folder_format(folder_path)
|
| 151 |
+
|
| 152 |
+
# Use folder name as default name
|
| 153 |
+
if name is None:
|
| 154 |
+
name = folder_path.name
|
| 155 |
+
|
| 156 |
+
# Load based on format
|
| 157 |
+
if format == 'dicom':
|
| 158 |
+
return dicom_io.create_structure_set_from_dicom(
|
| 159 |
+
folder_path=folder_path,
|
| 160 |
+
name=name,
|
| 161 |
+
structure_type_mapping=structure_type_mapping,
|
| 162 |
+
**kwargs
|
| 163 |
+
)
|
| 164 |
+
elif format == 'nifti':
|
| 165 |
+
return nifti_io.create_structure_set_from_nifti_folder(
|
| 166 |
+
folder_path=folder_path,
|
| 167 |
+
name=name,
|
| 168 |
+
structure_type_mapping=structure_type_mapping,
|
| 169 |
+
**kwargs
|
| 170 |
+
)
|
| 171 |
+
else:
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"Unknown format in {folder_path}. "
|
| 174 |
+
f"Folder should contain either DICOM files or NIfTI files."
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def load_volume(
|
| 179 |
+
file_path: Union[str, Path],
|
| 180 |
+
format: Optional[str] = None,
|
| 181 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
|
| 182 |
+
"""
|
| 183 |
+
Load a single volume file (DICOM RTDOSE or NIfTI).
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
file_path: Path to file or folder (for DICOM CT series)
|
| 187 |
+
format: Force specific format ('dicom' or 'nifti'). If None, auto-detects.
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
Tuple of (volume, spacing, origin)
|
| 191 |
+
|
| 192 |
+
Raises:
|
| 193 |
+
FileNotFoundError: If file doesn't exist
|
| 194 |
+
ValueError: If format cannot be determined
|
| 195 |
+
"""
|
| 196 |
+
file_path = Path(file_path)
|
| 197 |
+
|
| 198 |
+
if not file_path.exists():
|
| 199 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 200 |
+
|
| 201 |
+
# Auto-detect format if not specified
|
| 202 |
+
if format is None:
|
| 203 |
+
if file_path.is_dir():
|
| 204 |
+
format = 'dicom' # Assume directory is DICOM CT series
|
| 205 |
+
elif file_path.suffix in ['.nii', '.gz']:
|
| 206 |
+
format = 'nifti'
|
| 207 |
+
elif file_path.suffix == '.dcm':
|
| 208 |
+
format = 'dicom'
|
| 209 |
+
else:
|
| 210 |
+
raise ValueError(f"Cannot determine format for: {file_path}")
|
| 211 |
+
|
| 212 |
+
# Load based on format
|
| 213 |
+
if format == 'dicom':
|
| 214 |
+
if file_path.is_dir():
|
| 215 |
+
# CT series
|
| 216 |
+
volume, spacing, origin = dicom_io.read_dicom_ct_volume(file_path)
|
| 217 |
+
return volume, spacing, origin
|
| 218 |
+
else:
|
| 219 |
+
# Single RTDOSE file
|
| 220 |
+
volume, spacing, origin, _ = dicom_io.read_dicom_rtdose(file_path)
|
| 221 |
+
return volume, spacing, origin
|
| 222 |
+
elif format == 'nifti':
|
| 223 |
+
return nifti_io.read_nifti_volume(file_path)
|
| 224 |
+
else:
|
| 225 |
+
raise ValueError(f"Unknown format: {format}")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def load_structure(
|
| 229 |
+
file_path: Union[str, Path],
|
| 230 |
+
name: Optional[str] = None,
|
| 231 |
+
structure_type: "StructureType" = None,
|
| 232 |
+
format: Optional[str] = None,
|
| 233 |
+
**kwargs
|
| 234 |
+
) -> "Structure":
|
| 235 |
+
"""
|
| 236 |
+
Load a single structure from a file.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
file_path: Path to NIfTI file containing structure mask
|
| 240 |
+
name: Name for the structure. If None, uses filename.
|
| 241 |
+
structure_type: Type of structure (OAR, TARGET, etc.)
|
| 242 |
+
format: Force specific format ('nifti'). If None, auto-detects.
|
| 243 |
+
**kwargs: Additional arguments (e.g., threshold for binarization)
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Structure object
|
| 247 |
+
|
| 248 |
+
Raises:
|
| 249 |
+
FileNotFoundError: If file doesn't exist
|
| 250 |
+
ValueError: If format is not supported (currently only NIfTI single files)
|
| 251 |
+
|
| 252 |
+
Note:
|
| 253 |
+
For DICOM RTSTRUCT files, use load_structure_set() instead as they
|
| 254 |
+
typically contain multiple structures.
|
| 255 |
+
"""
|
| 256 |
+
from ..structures import StructureType # Import here to avoid circular dependency
|
| 257 |
+
|
| 258 |
+
if structure_type is None:
|
| 259 |
+
structure_type = StructureType.OAR
|
| 260 |
+
|
| 261 |
+
file_path = Path(file_path)
|
| 262 |
+
|
| 263 |
+
if not file_path.exists():
|
| 264 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 265 |
+
|
| 266 |
+
# Auto-detect format if not specified
|
| 267 |
+
if format is None:
|
| 268 |
+
if file_path.suffix in ['.nii', '.gz']:
|
| 269 |
+
format = 'nifti'
|
| 270 |
+
else:
|
| 271 |
+
raise ValueError(
|
| 272 |
+
f"Cannot determine format for: {file_path}. "
|
| 273 |
+
f"For DICOM RTSTRUCT files, use load_structure_set() instead."
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Currently only NIfTI single-file structures supported
|
| 277 |
+
if format == 'nifti':
|
| 278 |
+
return nifti_io.read_nifti_structure(
|
| 279 |
+
file_path,
|
| 280 |
+
name=name,
|
| 281 |
+
structure_type=structure_type,
|
| 282 |
+
**kwargs
|
| 283 |
+
)
|
| 284 |
+
else:
|
| 285 |
+
raise ValueError(
|
| 286 |
+
f"Format '{format}' not supported for single structure loading. "
|
| 287 |
+
f"Use load_structure_set() for DICOM RTSTRUCT files."
|
| 288 |
+
)
|
src/dosemetrics/io/dicom_io.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DICOM I/O utilities for radiotherapy data.
|
| 3 |
+
|
| 4 |
+
This module provides functions to read DICOM radiotherapy data including:
|
| 5 |
+
- CT image volumes
|
| 6 |
+
- RTDOSE (dose distributions)
|
| 7 |
+
- RTSTRUCT (structure sets/contours)
|
| 8 |
+
- RTPLAN (treatment plans - metadata only)
|
| 9 |
+
|
| 10 |
+
Uses pydicom for DICOM parsing and SimpleITK for volume reconstruction.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pydicom
|
| 16 |
+
import SimpleITK as sitk
|
| 17 |
+
from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
from ..structures import Structure, OAR, Target, StructureType
|
| 22 |
+
from ..structure_set import StructureSet
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def read_dicom_ct_volume(
|
| 26 |
+
ct_directory: Union[str, Path],
|
| 27 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
|
| 28 |
+
"""
|
| 29 |
+
Read a CT volume from a directory of DICOM slices.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
ct_directory: Path to directory containing CT DICOM files
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Tuple of (volume, spacing, origin) where:
|
| 36 |
+
- volume: 3D numpy array with shape (slices, rows, cols)
|
| 37 |
+
- spacing: (x, y, z) voxel spacing in mm
|
| 38 |
+
- origin: (x, y, z) origin coordinates in mm
|
| 39 |
+
|
| 40 |
+
Raises:
|
| 41 |
+
FileNotFoundError: If directory doesn't exist or contains no DICOM files
|
| 42 |
+
ValueError: If DICOM files don't form a valid CT series
|
| 43 |
+
"""
|
| 44 |
+
ct_directory = Path(ct_directory)
|
| 45 |
+
|
| 46 |
+
if not ct_directory.exists():
|
| 47 |
+
raise FileNotFoundError(f"CT directory not found: {ct_directory}")
|
| 48 |
+
|
| 49 |
+
# Get all DICOM files in directory
|
| 50 |
+
dicom_files = sorted(ct_directory.glob("*.dcm"))
|
| 51 |
+
|
| 52 |
+
if not dicom_files:
|
| 53 |
+
raise FileNotFoundError(f"No DICOM files found in: {ct_directory}")
|
| 54 |
+
|
| 55 |
+
# Use SimpleITK for robust volume reconstruction
|
| 56 |
+
reader = sitk.ImageSeriesReader()
|
| 57 |
+
dicom_names = reader.GetGDCMSeriesFileNames(str(ct_directory))
|
| 58 |
+
|
| 59 |
+
if not dicom_names:
|
| 60 |
+
raise ValueError(f"No valid DICOM series found in: {ct_directory}")
|
| 61 |
+
|
| 62 |
+
reader.SetFileNames(dicom_names)
|
| 63 |
+
image = reader.Execute()
|
| 64 |
+
|
| 65 |
+
# Get volume as numpy array (SimpleITK uses (z, y, x) ordering)
|
| 66 |
+
volume = sitk.GetArrayFromImage(image)
|
| 67 |
+
|
| 68 |
+
# Get spacing and origin
|
| 69 |
+
spacing = image.GetSpacing() # (x, y, z)
|
| 70 |
+
origin = image.GetOrigin() # (x, y, z)
|
| 71 |
+
|
| 72 |
+
return volume, spacing, origin
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def read_dicom_rtdose(
|
| 76 |
+
rtdose_file: Union[str, Path],
|
| 77 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float], float]:
|
| 78 |
+
"""
|
| 79 |
+
Read an RTDOSE DICOM file.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
rtdose_file: Path to RTDOSE DICOM file
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Tuple of (dose_array, spacing, origin, dose_scaling) where:
|
| 86 |
+
- dose_array: 3D numpy array with dose values in Gy
|
| 87 |
+
- spacing: (x, y, z) voxel spacing in mm
|
| 88 |
+
- origin: (x, y, z) origin coordinates in mm
|
| 89 |
+
- dose_scaling: Dose grid scaling factor
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
FileNotFoundError: If file doesn't exist
|
| 93 |
+
ValueError: If file is not a valid RTDOSE DICOM
|
| 94 |
+
"""
|
| 95 |
+
rtdose_file = Path(rtdose_file)
|
| 96 |
+
|
| 97 |
+
if not rtdose_file.exists():
|
| 98 |
+
raise FileNotFoundError(f"RTDOSE file not found: {rtdose_file}")
|
| 99 |
+
|
| 100 |
+
# Read with pydicom
|
| 101 |
+
ds = pydicom.dcmread(rtdose_file)
|
| 102 |
+
|
| 103 |
+
# Verify it's an RTDOSE file
|
| 104 |
+
if ds.Modality != 'RTDOSE':
|
| 105 |
+
raise ValueError(f"File is not RTDOSE, got modality: {ds.Modality}")
|
| 106 |
+
|
| 107 |
+
# Get dose array
|
| 108 |
+
dose_array = ds.pixel_array.astype(np.float32)
|
| 109 |
+
|
| 110 |
+
# Apply dose grid scaling to get doses in Gy
|
| 111 |
+
dose_scaling = float(ds.DoseGridScaling)
|
| 112 |
+
dose_array = dose_array * dose_scaling
|
| 113 |
+
|
| 114 |
+
# Get geometric information
|
| 115 |
+
# DICOM uses (row, col) for pixel spacing, need to add slice thickness
|
| 116 |
+
pixel_spacing = ds.PixelSpacing # [row_spacing, col_spacing]
|
| 117 |
+
|
| 118 |
+
# Get slice thickness or use grid frame offset vector
|
| 119 |
+
if hasattr(ds, 'SliceThickness') and ds.SliceThickness is not None:
|
| 120 |
+
slice_spacing = float(ds.SliceThickness)
|
| 121 |
+
elif hasattr(ds, 'GridFrameOffsetVector') and len(ds.GridFrameOffsetVector) > 1:
|
| 122 |
+
# Calculate from frame offset vector
|
| 123 |
+
slice_spacing = abs(float(ds.GridFrameOffsetVector[1]) - float(ds.GridFrameOffsetVector[0]))
|
| 124 |
+
else:
|
| 125 |
+
slice_spacing = 1.0 # Default fallback
|
| 126 |
+
|
| 127 |
+
# Spacing in (x, y, z) format
|
| 128 |
+
spacing = (float(pixel_spacing[1]), float(pixel_spacing[0]), slice_spacing)
|
| 129 |
+
|
| 130 |
+
# Get origin (ImagePositionPatient)
|
| 131 |
+
if hasattr(ds, 'ImagePositionPatient'):
|
| 132 |
+
origin = tuple(float(x) for x in ds.ImagePositionPatient)
|
| 133 |
+
else:
|
| 134 |
+
origin = (0.0, 0.0, 0.0)
|
| 135 |
+
|
| 136 |
+
return dose_array, spacing, origin, dose_scaling
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def read_dicom_rtstruct(
|
| 140 |
+
rtstruct_file: Union[str, Path],
|
| 141 |
+
reference_image: Optional[Union[sitk.Image, Tuple[Tuple[int, ...], Tuple[float, ...], Tuple[float, ...]]]] = None,
|
| 142 |
+
) -> Dict[str, Dict[str, Union[np.ndarray, List]]]:
|
| 143 |
+
"""
|
| 144 |
+
Read an RTSTRUCT DICOM file and extract structure information.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
rtstruct_file: Path to RTSTRUCT DICOM file
|
| 148 |
+
reference_image: Optional reference image or (shape, spacing, origin) tuple for mask generation.
|
| 149 |
+
If None, only contour points are returned without generating binary masks.
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dictionary mapping structure names to dictionaries containing:
|
| 153 |
+
- 'contours': List of contour point arrays (each is Nx3 array of (x, y, z) points)
|
| 154 |
+
- 'mask': Binary mask array (only if reference_image provided)
|
| 155 |
+
- 'roi_number': ROI number from DICOM
|
| 156 |
+
- 'color': RGB color tuple
|
| 157 |
+
|
| 158 |
+
Raises:
|
| 159 |
+
FileNotFoundError: If file doesn't exist
|
| 160 |
+
ValueError: If file is not a valid RTSTRUCT DICOM
|
| 161 |
+
"""
|
| 162 |
+
rtstruct_file = Path(rtstruct_file)
|
| 163 |
+
|
| 164 |
+
if not rtstruct_file.exists():
|
| 165 |
+
raise FileNotFoundError(f"RTSTRUCT file not found: {rtstruct_file}")
|
| 166 |
+
|
| 167 |
+
# Read with pydicom
|
| 168 |
+
ds = pydicom.dcmread(rtstruct_file)
|
| 169 |
+
|
| 170 |
+
# Verify it's an RTSTRUCT file
|
| 171 |
+
if ds.Modality != 'RTSTRUCT':
|
| 172 |
+
raise ValueError(f"File is not RTSTRUCT, got modality: {ds.Modality}")
|
| 173 |
+
|
| 174 |
+
structures = {}
|
| 175 |
+
|
| 176 |
+
# Build ROI number to name mapping
|
| 177 |
+
roi_dict = {}
|
| 178 |
+
if hasattr(ds, 'StructureSetROISequence'):
|
| 179 |
+
for roi in ds.StructureSetROISequence:
|
| 180 |
+
roi_number = roi.ROINumber
|
| 181 |
+
roi_name = roi.ROIName
|
| 182 |
+
roi_dict[roi_number] = roi_name
|
| 183 |
+
|
| 184 |
+
# Extract contours for each ROI
|
| 185 |
+
if hasattr(ds, 'ROIContourSequence'):
|
| 186 |
+
for roi_contour in ds.ROIContourSequence:
|
| 187 |
+
roi_number = roi_contour.ReferencedROINumber
|
| 188 |
+
|
| 189 |
+
if roi_number not in roi_dict:
|
| 190 |
+
continue
|
| 191 |
+
|
| 192 |
+
roi_name = roi_dict[roi_number]
|
| 193 |
+
|
| 194 |
+
# Get color if available
|
| 195 |
+
if hasattr(roi_contour, 'ROIDisplayColor'):
|
| 196 |
+
color = tuple(int(c) for c in roi_contour.ROIDisplayColor)
|
| 197 |
+
else:
|
| 198 |
+
color = (255, 0, 0) # Default red
|
| 199 |
+
|
| 200 |
+
# Extract contour points
|
| 201 |
+
contours = []
|
| 202 |
+
if hasattr(roi_contour, 'ContourSequence'):
|
| 203 |
+
for contour in roi_contour.ContourSequence:
|
| 204 |
+
if hasattr(contour, 'ContourData'):
|
| 205 |
+
# ContourData is a flat list of [x1, y1, z1, x2, y2, z2, ...]
|
| 206 |
+
points = np.array(contour.ContourData).reshape(-1, 3)
|
| 207 |
+
contours.append(points)
|
| 208 |
+
|
| 209 |
+
structures[roi_name] = {
|
| 210 |
+
'contours': contours,
|
| 211 |
+
'roi_number': roi_number,
|
| 212 |
+
'color': color,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
# Generate binary masks if reference image provided
|
| 216 |
+
if reference_image is not None:
|
| 217 |
+
if isinstance(reference_image, sitk.Image):
|
| 218 |
+
shape = reference_image.GetSize()[::-1] # SimpleITK uses (x,y,z), numpy uses (z,y,x)
|
| 219 |
+
spacing = reference_image.GetSpacing()
|
| 220 |
+
origin = reference_image.GetOrigin()
|
| 221 |
+
else:
|
| 222 |
+
shape, spacing, origin = reference_image
|
| 223 |
+
|
| 224 |
+
# Generate masks for each structure
|
| 225 |
+
for roi_name, roi_data in structures.items():
|
| 226 |
+
mask = _generate_mask_from_contours(
|
| 227 |
+
roi_data['contours'],
|
| 228 |
+
shape,
|
| 229 |
+
spacing,
|
| 230 |
+
origin
|
| 231 |
+
)
|
| 232 |
+
roi_data['mask'] = mask
|
| 233 |
+
|
| 234 |
+
return structures
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _generate_mask_from_contours(
|
| 238 |
+
contours: List[np.ndarray],
|
| 239 |
+
shape: Tuple[int, ...],
|
| 240 |
+
spacing: Tuple[float, float, float],
|
| 241 |
+
origin: Tuple[float, float, float],
|
| 242 |
+
) -> np.ndarray:
|
| 243 |
+
"""
|
| 244 |
+
Generate a binary mask from contour points.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
contours: List of contour arrays (each Nx3 with (x, y, z) points)
|
| 248 |
+
shape: (depth, height, width) of output mask
|
| 249 |
+
spacing: (x, y, z) voxel spacing in mm
|
| 250 |
+
origin: (x, y, z) origin in mm
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
Binary mask as boolean numpy array with given shape
|
| 254 |
+
"""
|
| 255 |
+
from skimage.draw import polygon
|
| 256 |
+
|
| 257 |
+
mask = np.zeros(shape, dtype=bool)
|
| 258 |
+
|
| 259 |
+
# Group contours by z-coordinate (slice)
|
| 260 |
+
slice_contours = {}
|
| 261 |
+
for contour in contours:
|
| 262 |
+
if len(contour) < 3: # Need at least 3 points for a polygon
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
# Get z-coordinate (should be constant for a single contour)
|
| 266 |
+
z_coord = contour[0, 2]
|
| 267 |
+
|
| 268 |
+
# Convert z coordinate to slice index
|
| 269 |
+
slice_idx = int(round((z_coord - origin[2]) / spacing[2]))
|
| 270 |
+
|
| 271 |
+
if 0 <= slice_idx < shape[0]:
|
| 272 |
+
if slice_idx not in slice_contours:
|
| 273 |
+
slice_contours[slice_idx] = []
|
| 274 |
+
slice_contours[slice_idx].append(contour)
|
| 275 |
+
|
| 276 |
+
# Fill each slice
|
| 277 |
+
for slice_idx, contour_list in slice_contours.items():
|
| 278 |
+
for contour in contour_list:
|
| 279 |
+
# Convert physical coordinates to pixel coordinates
|
| 280 |
+
cols = (contour[:, 0] - origin[0]) / spacing[0]
|
| 281 |
+
rows = (contour[:, 1] - origin[1]) / spacing[1]
|
| 282 |
+
|
| 283 |
+
# Fill polygon
|
| 284 |
+
try:
|
| 285 |
+
rr, cc = polygon(rows, cols, shape[1:])
|
| 286 |
+
# Ensure indices are within bounds
|
| 287 |
+
valid_idx = (rr >= 0) & (rr < shape[1]) & (cc >= 0) & (cc < shape[2])
|
| 288 |
+
mask[slice_idx, rr[valid_idx], cc[valid_idx]] = True
|
| 289 |
+
except Exception:
|
| 290 |
+
# Skip invalid contours
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
return mask
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def load_dicom_folder(
|
| 297 |
+
folder_path: Union[str, Path],
|
| 298 |
+
load_ct: bool = True,
|
| 299 |
+
load_rtdose: bool = True,
|
| 300 |
+
load_rtstruct: bool = True,
|
| 301 |
+
return_as_structureset: bool = True,
|
| 302 |
+
dose_file_name: Optional[str] = None,
|
| 303 |
+
structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
|
| 304 |
+
) -> Union["StructureSet", Dict[str, Union[np.ndarray, Dict, Tuple]]]:
|
| 305 |
+
"""
|
| 306 |
+
Load all DICOM data from a folder containing CT, RTDOSE, and RTSTRUCT files.
|
| 307 |
+
|
| 308 |
+
This is a high-level function that automatically detects and loads all DICOM
|
| 309 |
+
modalities present in the folder.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
folder_path: Path to folder containing DICOM files organized in subfolders
|
| 313 |
+
(e.g., CT/, RTDOSE/, RTSTRUCT/)
|
| 314 |
+
load_ct: Whether to load CT volume
|
| 315 |
+
load_rtdose: Whether to load dose distributions
|
| 316 |
+
load_rtstruct: Whether to load structure sets
|
| 317 |
+
return_as_structureset: If True (default), returns a StructureSet object.
|
| 318 |
+
If False, returns raw dictionary.
|
| 319 |
+
dose_file_name: Specific dose file to use (only if return_as_structureset=True)
|
| 320 |
+
structure_type_mapping: Optional dict mapping structure names to StructureType
|
| 321 |
+
(only used if return_as_structureset=True)
|
| 322 |
+
|
| 323 |
+
Returns:
|
| 324 |
+
If return_as_structureset=True: StructureSet object with loaded data
|
| 325 |
+
If return_as_structureset=False: Dictionary with keys:
|
| 326 |
+
- 'ct_volume': CT volume array (if loaded)
|
| 327 |
+
- 'ct_spacing': CT spacing tuple (if loaded)
|
| 328 |
+
- 'ct_origin': CT origin tuple (if loaded)
|
| 329 |
+
- 'dose_volumes': Dict of dose volumes {filename: (array, spacing, origin, scaling)}
|
| 330 |
+
- 'structures': Dict of structures from RTSTRUCT
|
| 331 |
+
- 'spacing': Common spacing for all data
|
| 332 |
+
- 'origin': Common origin for all data
|
| 333 |
+
|
| 334 |
+
Raises:
|
| 335 |
+
FileNotFoundError: If folder doesn't exist
|
| 336 |
+
"""
|
| 337 |
+
folder_path = Path(folder_path)
|
| 338 |
+
|
| 339 |
+
if not folder_path.exists():
|
| 340 |
+
raise FileNotFoundError(f"Folder not found: {folder_path}")
|
| 341 |
+
|
| 342 |
+
result = {}
|
| 343 |
+
|
| 344 |
+
# Load CT volume
|
| 345 |
+
if load_ct:
|
| 346 |
+
ct_dir = folder_path / 'CT'
|
| 347 |
+
if ct_dir.exists():
|
| 348 |
+
try:
|
| 349 |
+
ct_volume, ct_spacing, ct_origin = read_dicom_ct_volume(ct_dir)
|
| 350 |
+
result['ct_volume'] = ct_volume
|
| 351 |
+
result['ct_spacing'] = ct_spacing
|
| 352 |
+
result['ct_origin'] = ct_origin
|
| 353 |
+
result['spacing'] = ct_spacing
|
| 354 |
+
result['origin'] = ct_origin
|
| 355 |
+
except Exception as e:
|
| 356 |
+
print(f"Warning: Could not load CT volume: {e}")
|
| 357 |
+
|
| 358 |
+
# Load RTDOSE files
|
| 359 |
+
if load_rtdose:
|
| 360 |
+
rtdose_dir = folder_path / 'RTDOSE'
|
| 361 |
+
if rtdose_dir.exists():
|
| 362 |
+
dose_volumes = {}
|
| 363 |
+
for dose_file in rtdose_dir.glob('*.dcm'):
|
| 364 |
+
try:
|
| 365 |
+
dose_array, spacing, origin, scaling = read_dicom_rtdose(dose_file)
|
| 366 |
+
dose_volumes[dose_file.stem] = {
|
| 367 |
+
'array': dose_array,
|
| 368 |
+
'spacing': spacing,
|
| 369 |
+
'origin': origin,
|
| 370 |
+
'scaling': scaling,
|
| 371 |
+
}
|
| 372 |
+
# Use first dose for common spacing/origin if CT not available
|
| 373 |
+
if 'spacing' not in result:
|
| 374 |
+
result['spacing'] = spacing
|
| 375 |
+
result['origin'] = origin
|
| 376 |
+
except Exception as e:
|
| 377 |
+
print(f"Warning: Could not load RTDOSE {dose_file.name}: {e}")
|
| 378 |
+
|
| 379 |
+
if dose_volumes:
|
| 380 |
+
result['dose_volumes'] = dose_volumes
|
| 381 |
+
|
| 382 |
+
# Load RTSTRUCT files
|
| 383 |
+
if load_rtstruct:
|
| 384 |
+
rtstruct_dir = folder_path / 'RTSTRUCT'
|
| 385 |
+
if rtstruct_dir.exists():
|
| 386 |
+
# Use CT or dose as reference for mask generation
|
| 387 |
+
reference = None
|
| 388 |
+
if 'ct_volume' in result:
|
| 389 |
+
reference = (
|
| 390 |
+
result['ct_volume'].shape,
|
| 391 |
+
result['ct_spacing'],
|
| 392 |
+
result['ct_origin']
|
| 393 |
+
)
|
| 394 |
+
elif 'dose_volumes' in result:
|
| 395 |
+
first_dose = next(iter(result['dose_volumes'].values()))
|
| 396 |
+
reference = (
|
| 397 |
+
first_dose['array'].shape,
|
| 398 |
+
first_dose['spacing'],
|
| 399 |
+
first_dose['origin']
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
for rtstruct_file in rtstruct_dir.glob('*.dcm'):
|
| 403 |
+
try:
|
| 404 |
+
structures = read_dicom_rtstruct(rtstruct_file, reference)
|
| 405 |
+
result['structures'] = structures
|
| 406 |
+
break # Usually only one RTSTRUCT file
|
| 407 |
+
except Exception as e:
|
| 408 |
+
print(f"Warning: Could not load RTSTRUCT {rtstruct_file.name}: {e}")
|
| 409 |
+
|
| 410 |
+
# Return as StructureSet if requested
|
| 411 |
+
if return_as_structureset:
|
| 412 |
+
if 'structures' not in result or not result['structures']:
|
| 413 |
+
raise ValueError(f"No structures found in: {folder_path}")
|
| 414 |
+
return create_structure_set_from_dicom(
|
| 415 |
+
folder_path,
|
| 416 |
+
dose_file_name=dose_file_name,
|
| 417 |
+
structure_type_mapping=structure_type_mapping,
|
| 418 |
+
name=f"DICOM - {folder_path.name if isinstance(folder_path, Path) else Path(folder_path).name}",
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
return result
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def create_structure_set_from_dicom(
|
| 425 |
+
folder_path: Union[str, Path],
|
| 426 |
+
dose_file_name: Optional[str] = None,
|
| 427 |
+
structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
|
| 428 |
+
name: str = "DICOM StructureSet",
|
| 429 |
+
) -> "StructureSet":
|
| 430 |
+
"""
|
| 431 |
+
Create a StructureSet object from DICOM data in a folder.
|
| 432 |
+
|
| 433 |
+
This is a high-level convenience function that loads DICOM data and creates
|
| 434 |
+
a complete StructureSet object.
|
| 435 |
+
|
| 436 |
+
Args:
|
| 437 |
+
folder_path: Path to folder containing DICOM subfolders
|
| 438 |
+
dose_file_name: Specific dose file to use (e.g., 'RD_1'). If None, uses first found.
|
| 439 |
+
structure_type_mapping: Optional dict mapping structure names to StructureType
|
| 440 |
+
name: Name for the structure set
|
| 441 |
+
|
| 442 |
+
Returns:
|
| 443 |
+
StructureSet object with loaded structures and dose data
|
| 444 |
+
|
| 445 |
+
Raises:
|
| 446 |
+
FileNotFoundError: If folder doesn't exist
|
| 447 |
+
ValueError: If no structures found
|
| 448 |
+
"""
|
| 449 |
+
from ..structures import StructureType
|
| 450 |
+
from ..structure_set import StructureSet
|
| 451 |
+
|
| 452 |
+
# Load all DICOM data (get raw dict to avoid recursion)
|
| 453 |
+
data = load_dicom_folder(folder_path, return_as_structureset=False)
|
| 454 |
+
|
| 455 |
+
if 'structures' not in data or not data['structures']:
|
| 456 |
+
raise ValueError(f"No structures found in: {folder_path}")
|
| 457 |
+
|
| 458 |
+
# Get spacing and origin
|
| 459 |
+
spacing = data.get('spacing', (1.0, 1.0, 1.0))
|
| 460 |
+
origin = data.get('origin', (0.0, 0.0, 0.0))
|
| 461 |
+
|
| 462 |
+
# Create structure set
|
| 463 |
+
structure_set = StructureSet(spacing=spacing, origin=origin, name=name)
|
| 464 |
+
|
| 465 |
+
# Add structures
|
| 466 |
+
for struct_name, struct_data in data['structures'].items():
|
| 467 |
+
if 'mask' not in struct_data:
|
| 468 |
+
continue
|
| 469 |
+
|
| 470 |
+
# Determine structure type
|
| 471 |
+
struct_type = StructureType.OAR # Default
|
| 472 |
+
if structure_type_mapping and struct_name in structure_type_mapping:
|
| 473 |
+
struct_type = structure_type_mapping[struct_name]
|
| 474 |
+
elif 'PTV' in struct_name.upper() or 'GTV' in struct_name.upper() or 'CTV' in struct_name.upper():
|
| 475 |
+
struct_type = StructureType.TARGET
|
| 476 |
+
|
| 477 |
+
structure_set.add_structure(
|
| 478 |
+
name=struct_name,
|
| 479 |
+
mask=struct_data['mask'],
|
| 480 |
+
structure_type=struct_type,
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
# Note: In the new architecture, dose is loaded separately using Dose.from_dicom()
|
| 484 |
+
# and not attached to the StructureSet. Multiple dose files can be loaded independently.
|
| 485 |
+
|
| 486 |
+
return structure_set
|
src/dosemetrics/io/nifti_io.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NIfTI I/O utilities for radiotherapy data.
|
| 3 |
+
|
| 4 |
+
This module provides functions to read NIfTI files for:
|
| 5 |
+
- CT/MR image volumes (real-valued)
|
| 6 |
+
- Dose distributions (real-valued)
|
| 7 |
+
- Structure masks (binary)
|
| 8 |
+
|
| 9 |
+
Uses SimpleITK for robust NIfTI reading with proper handling of spacing and origin.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import numpy as np
|
| 14 |
+
import SimpleITK as sitk
|
| 15 |
+
from typing import Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from ..structures import Structure, OAR, Target, StructureType
|
| 20 |
+
from ..structure_set import StructureSet
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def read_nifti_volume(
|
| 24 |
+
nifti_file: Union[str, Path],
|
| 25 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
|
| 26 |
+
"""
|
| 27 |
+
Read a NIfTI file and return volume with geometric information.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
nifti_file: Path to NIfTI file (.nii or .nii.gz)
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Tuple of (volume, spacing, origin) where:
|
| 34 |
+
- volume: 3D numpy array
|
| 35 |
+
- spacing: (x, y, z) voxel spacing in mm
|
| 36 |
+
- origin: (x, y, z) origin coordinates in mm
|
| 37 |
+
|
| 38 |
+
Raises:
|
| 39 |
+
FileNotFoundError: If file doesn't exist
|
| 40 |
+
RuntimeError: If file cannot be read
|
| 41 |
+
"""
|
| 42 |
+
nifti_file = Path(nifti_file)
|
| 43 |
+
|
| 44 |
+
if not nifti_file.exists():
|
| 45 |
+
raise FileNotFoundError(f"NIfTI file not found: {nifti_file}")
|
| 46 |
+
|
| 47 |
+
# Read with SimpleITK
|
| 48 |
+
image = sitk.ReadImage(str(nifti_file))
|
| 49 |
+
|
| 50 |
+
# Get volume as numpy array
|
| 51 |
+
volume = sitk.GetArrayFromImage(image)
|
| 52 |
+
|
| 53 |
+
# Get spacing and origin
|
| 54 |
+
spacing = image.GetSpacing() # (x, y, z)
|
| 55 |
+
origin = image.GetOrigin() # (x, y, z)
|
| 56 |
+
|
| 57 |
+
return volume, spacing, origin
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def read_nifti_mask(
|
| 61 |
+
nifti_file: Union[str, Path],
|
| 62 |
+
threshold: float = 0.5,
|
| 63 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
|
| 64 |
+
"""
|
| 65 |
+
Read a NIfTI file as a binary mask.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
nifti_file: Path to NIfTI file containing binary or probability mask
|
| 69 |
+
threshold: Threshold for binarization (values > threshold become True)
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Tuple of (mask, spacing, origin) where:
|
| 73 |
+
- mask: 3D boolean numpy array
|
| 74 |
+
- spacing: (x, y, z) voxel spacing in mm
|
| 75 |
+
- origin: (x, y, z) origin coordinates in mm
|
| 76 |
+
|
| 77 |
+
Raises:
|
| 78 |
+
FileNotFoundError: If file doesn't exist
|
| 79 |
+
"""
|
| 80 |
+
volume, spacing, origin = read_nifti_volume(nifti_file)
|
| 81 |
+
|
| 82 |
+
# Binarize
|
| 83 |
+
mask = volume > threshold
|
| 84 |
+
|
| 85 |
+
return mask, spacing, origin
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def read_nifti_dose(
|
| 89 |
+
nifti_file: Union[str, Path],
|
| 90 |
+
) -> Tuple[np.ndarray, Tuple[float, float, float], Tuple[float, float, float]]:
|
| 91 |
+
"""
|
| 92 |
+
Read a NIfTI file containing dose distribution.
|
| 93 |
+
|
| 94 |
+
This is an alias for read_nifti_volume with a more semantic name.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
nifti_file: Path to NIfTI file containing dose data
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Tuple of (dose_array, spacing, origin) where:
|
| 101 |
+
- dose_array: 3D numpy array with dose values (typically in Gy)
|
| 102 |
+
- spacing: (x, y, z) voxel spacing in mm
|
| 103 |
+
- origin: (x, y, z) origin coordinates in mm
|
| 104 |
+
|
| 105 |
+
Raises:
|
| 106 |
+
FileNotFoundError: If file doesn't exist
|
| 107 |
+
"""
|
| 108 |
+
return read_nifti_volume(nifti_file)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def read_from_nifti(nifti_file: Union[str, Path]) -> np.ndarray:
|
| 112 |
+
"""
|
| 113 |
+
Read a NIfTI file and return only the volume array (backward compatibility).
|
| 114 |
+
|
| 115 |
+
This function provides backward compatibility with older code that expects
|
| 116 |
+
only the numpy array. For new code, use read_nifti_volume() to also get
|
| 117 |
+
spacing and origin information.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
nifti_file: Path to NIfTI file
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
3D numpy array
|
| 124 |
+
|
| 125 |
+
Raises:
|
| 126 |
+
FileNotFoundError: If file doesn't exist
|
| 127 |
+
|
| 128 |
+
Note:
|
| 129 |
+
Deprecated. Use read_nifti_volume() for new code to get spacing and origin.
|
| 130 |
+
"""
|
| 131 |
+
volume, _, _ = read_nifti_volume(nifti_file)
|
| 132 |
+
return volume
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def is_binary_volume(volume: np.ndarray, tolerance: float = 1e-6) -> bool:
|
| 136 |
+
"""
|
| 137 |
+
Check if a volume contains only binary values (0 and 1).
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
volume: Numpy array to check
|
| 141 |
+
tolerance: Tolerance for checking if values are 0 or 1
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
True if volume is binary, False otherwise
|
| 145 |
+
"""
|
| 146 |
+
unique_values = np.unique(volume)
|
| 147 |
+
|
| 148 |
+
# Check if all unique values are close to 0 or 1
|
| 149 |
+
for val in unique_values:
|
| 150 |
+
if not (np.abs(val) < tolerance or np.abs(val - 1) < tolerance):
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def load_nifti_folder(
|
| 157 |
+
folder_path: Union[str, Path],
|
| 158 |
+
dose_filename: str = "Dose.nii.gz",
|
| 159 |
+
auto_detect_masks: bool = True,
|
| 160 |
+
return_as_structureset: bool = True,
|
| 161 |
+
structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
|
| 162 |
+
) -> Union["StructureSet", Dict[str, Union[np.ndarray, Dict, Tuple]]]:
|
| 163 |
+
"""
|
| 164 |
+
Load all NIfTI files from a folder.
|
| 165 |
+
|
| 166 |
+
This function automatically:
|
| 167 |
+
1. Detects and loads the dose file
|
| 168 |
+
2. Auto-detects whether each file is a binary mask (structure) or real-valued volume (image)
|
| 169 |
+
3. Returns a StructureSet (default) or dictionary with organized data
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
folder_path: Path to folder containing NIfTI files
|
| 173 |
+
dose_filename: Name of the dose file (default: "Dose.nii.gz")
|
| 174 |
+
auto_detect_masks: Whether to auto-detect binary masks vs real-valued volumes
|
| 175 |
+
return_as_structureset: If True (default), returns a StructureSet object.
|
| 176 |
+
If False, returns raw dictionary.
|
| 177 |
+
structure_type_mapping: Optional dict mapping structure names to StructureType
|
| 178 |
+
(only used if return_as_structureset=True)
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
If return_as_structureset=True: StructureSet object with loaded data
|
| 182 |
+
If return_as_structureset=False: Dictionary with keys:
|
| 183 |
+
- 'dose_volume': Dose distribution array (if found)
|
| 184 |
+
- 'dose_spacing': Dose spacing tuple (if found)
|
| 185 |
+
- 'dose_origin': Dose origin tuple (if found)
|
| 186 |
+
- 'image_volumes': Dict of real-valued volumes {name: {'volume', 'spacing', 'origin'}}
|
| 187 |
+
- 'structure_masks': Dict of binary masks {name: {'mask', 'spacing', 'origin'}}
|
| 188 |
+
- 'spacing': Common spacing for all data
|
| 189 |
+
- 'origin': Common origin for all data
|
| 190 |
+
|
| 191 |
+
Raises:
|
| 192 |
+
FileNotFoundError: If folder doesn't exist
|
| 193 |
+
"""
|
| 194 |
+
folder_path = Path(folder_path)
|
| 195 |
+
|
| 196 |
+
if not folder_path.exists():
|
| 197 |
+
raise FileNotFoundError(f"Folder not found: {folder_path}")
|
| 198 |
+
|
| 199 |
+
result = {
|
| 200 |
+
'image_volumes': {},
|
| 201 |
+
'structure_masks': {},
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
# Get all NIfTI files
|
| 205 |
+
nifti_files = list(folder_path.glob("*.nii.gz")) + list(folder_path.glob("*.nii"))
|
| 206 |
+
|
| 207 |
+
if not nifti_files:
|
| 208 |
+
return result
|
| 209 |
+
|
| 210 |
+
# Try to load dose file first
|
| 211 |
+
dose_file = folder_path / dose_filename
|
| 212 |
+
if dose_file.exists():
|
| 213 |
+
try:
|
| 214 |
+
dose_volume, dose_spacing, dose_origin = read_nifti_dose(dose_file)
|
| 215 |
+
result['dose_volume'] = dose_volume
|
| 216 |
+
result['dose_spacing'] = dose_spacing
|
| 217 |
+
result['dose_origin'] = dose_origin
|
| 218 |
+
result['spacing'] = dose_spacing
|
| 219 |
+
result['origin'] = dose_origin
|
| 220 |
+
except Exception as e:
|
| 221 |
+
print(f"Warning: Could not load dose file {dose_filename}: {e}")
|
| 222 |
+
|
| 223 |
+
# Process other NIfTI files
|
| 224 |
+
for nifti_file in nifti_files:
|
| 225 |
+
# Skip dose file
|
| 226 |
+
if nifti_file.name == dose_filename:
|
| 227 |
+
continue
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
volume, spacing, origin = read_nifti_volume(nifti_file)
|
| 231 |
+
|
| 232 |
+
# Set common spacing/origin from first file if not set
|
| 233 |
+
if 'spacing' not in result:
|
| 234 |
+
result['spacing'] = spacing
|
| 235 |
+
result['origin'] = origin
|
| 236 |
+
|
| 237 |
+
# Extract name from filename (remove .nii.gz or .nii)
|
| 238 |
+
if nifti_file.name.endswith('.nii.gz'):
|
| 239 |
+
name = nifti_file.name[:-7]
|
| 240 |
+
else:
|
| 241 |
+
name = nifti_file.stem
|
| 242 |
+
|
| 243 |
+
# Auto-detect if this is a binary mask
|
| 244 |
+
if auto_detect_masks and is_binary_volume(volume):
|
| 245 |
+
# It's a binary mask (structure)
|
| 246 |
+
mask = volume.astype(bool)
|
| 247 |
+
result['structure_masks'][name] = {
|
| 248 |
+
'mask': mask,
|
| 249 |
+
'spacing': spacing,
|
| 250 |
+
'origin': origin,
|
| 251 |
+
}
|
| 252 |
+
else:
|
| 253 |
+
# It's a real-valued volume (image)
|
| 254 |
+
result['image_volumes'][name] = {
|
| 255 |
+
'volume': volume,
|
| 256 |
+
'spacing': spacing,
|
| 257 |
+
'origin': origin,
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
except Exception as e:
|
| 261 |
+
print(f"Warning: Could not load {nifti_file.name}: {e}")
|
| 262 |
+
|
| 263 |
+
# Return as StructureSet if requested
|
| 264 |
+
if return_as_structureset:
|
| 265 |
+
if not result['structure_masks']:
|
| 266 |
+
raise ValueError(f"No structure masks found in: {folder_path}")
|
| 267 |
+
return create_structure_set_from_nifti_folder(
|
| 268 |
+
folder_path,
|
| 269 |
+
dose_filename=dose_filename,
|
| 270 |
+
structure_type_mapping=structure_type_mapping,
|
| 271 |
+
name=folder_path.name if isinstance(folder_path, Path) else Path(folder_path).name,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return result
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def create_structure_set_from_nifti_folder(
|
| 278 |
+
folder_path: Union[str, Path],
|
| 279 |
+
dose_filename: str = "Dose.nii.gz",
|
| 280 |
+
structure_type_mapping: Optional[Dict[str, "StructureType"]] = None,
|
| 281 |
+
name: Optional[str] = None,
|
| 282 |
+
) -> "StructureSet":
|
| 283 |
+
"""
|
| 284 |
+
Create a StructureSet from NIfTI files in a folder.
|
| 285 |
+
|
| 286 |
+
This high-level function automatically:
|
| 287 |
+
1. Loads dose distribution
|
| 288 |
+
2. Auto-detects binary mask files as structures
|
| 289 |
+
3. Creates Structure objects with appropriate types
|
| 290 |
+
4. Returns a complete StructureSet
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
folder_path: Path to folder containing NIfTI files
|
| 294 |
+
dose_filename: Name of the dose file (default: "Dose.nii.gz")
|
| 295 |
+
structure_type_mapping: Optional dict mapping structure names to StructureType.
|
| 296 |
+
If not provided, guesses based on naming conventions.
|
| 297 |
+
name: Name for the structure set. If None, uses folder name.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
StructureSet object with loaded structures and dose
|
| 301 |
+
|
| 302 |
+
Raises:
|
| 303 |
+
FileNotFoundError: If folder doesn't exist
|
| 304 |
+
ValueError: If no structures found
|
| 305 |
+
"""
|
| 306 |
+
from ..structures import StructureType
|
| 307 |
+
from ..structure_set import StructureSet
|
| 308 |
+
|
| 309 |
+
folder_path = Path(folder_path)
|
| 310 |
+
|
| 311 |
+
# Load all data from folder (get raw dict to avoid recursion)
|
| 312 |
+
data = load_nifti_folder(folder_path, dose_filename=dose_filename, return_as_structureset=False)
|
| 313 |
+
|
| 314 |
+
if not data['structure_masks']:
|
| 315 |
+
raise ValueError(f"No structure masks found in: {folder_path}")
|
| 316 |
+
|
| 317 |
+
# Get spacing and origin
|
| 318 |
+
spacing = data.get('spacing', (1.0, 1.0, 1.0))
|
| 319 |
+
origin = data.get('origin', (0.0, 0.0, 0.0))
|
| 320 |
+
|
| 321 |
+
# Create structure set name
|
| 322 |
+
if name is None:
|
| 323 |
+
name = folder_path.name
|
| 324 |
+
|
| 325 |
+
# Create structure set
|
| 326 |
+
structure_set = StructureSet(spacing=spacing, origin=origin, name=name)
|
| 327 |
+
|
| 328 |
+
# Add structures
|
| 329 |
+
for struct_name, struct_data in data['structure_masks'].items():
|
| 330 |
+
# Determine structure type
|
| 331 |
+
struct_type = StructureType.OAR # Default
|
| 332 |
+
|
| 333 |
+
if structure_type_mapping and struct_name in structure_type_mapping:
|
| 334 |
+
struct_type = structure_type_mapping[struct_name]
|
| 335 |
+
else:
|
| 336 |
+
# Guess based on name
|
| 337 |
+
name_upper = struct_name.upper()
|
| 338 |
+
if any(keyword in name_upper for keyword in ['PTV', 'GTV', 'CTV', 'TARGET']):
|
| 339 |
+
struct_type = StructureType.TARGET
|
| 340 |
+
elif any(keyword in name_upper for keyword in ['AVOID', 'PRV']):
|
| 341 |
+
struct_type = StructureType.AVOIDANCE
|
| 342 |
+
|
| 343 |
+
structure_set.add_structure(
|
| 344 |
+
name=struct_name,
|
| 345 |
+
mask=struct_data['mask'],
|
| 346 |
+
structure_type=struct_type,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Note: In the new architecture, dose is loaded separately using Dose.from_nifti()
|
| 350 |
+
# and not attached to the StructureSet
|
| 351 |
+
|
| 352 |
+
return structure_set
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def read_nifti_structure(
|
| 356 |
+
nifti_file: Union[str, Path],
|
| 357 |
+
name: Optional[str] = None,
|
| 358 |
+
structure_type: "StructureType" = None,
|
| 359 |
+
threshold: float = 0.5,
|
| 360 |
+
) -> "Structure":
|
| 361 |
+
"""
|
| 362 |
+
Read a single NIfTI file as a Structure object.
|
| 363 |
+
|
| 364 |
+
Args:
|
| 365 |
+
nifti_file: Path to NIfTI file containing binary mask
|
| 366 |
+
name: Name for the structure. If None, uses filename.
|
| 367 |
+
structure_type: Type of structure (OAR, TARGET, etc.)
|
| 368 |
+
threshold: Threshold for binarization
|
| 369 |
+
|
| 370 |
+
Returns:
|
| 371 |
+
Structure object (OAR, Target, or AvoidanceStructure based on type)
|
| 372 |
+
|
| 373 |
+
Raises:
|
| 374 |
+
FileNotFoundError: If file doesn't exist
|
| 375 |
+
"""
|
| 376 |
+
from ..structures import Structure, OAR, Target, StructureType
|
| 377 |
+
|
| 378 |
+
if structure_type is None:
|
| 379 |
+
structure_type = StructureType.OAR
|
| 380 |
+
|
| 381 |
+
nifti_file = Path(nifti_file)
|
| 382 |
+
|
| 383 |
+
# Extract name from filename if not provided
|
| 384 |
+
if name is None:
|
| 385 |
+
if nifti_file.name.endswith('.nii.gz'):
|
| 386 |
+
name = nifti_file.name[:-7]
|
| 387 |
+
else:
|
| 388 |
+
name = nifti_file.stem
|
| 389 |
+
|
| 390 |
+
# Read mask
|
| 391 |
+
mask, spacing, origin = read_nifti_mask(nifti_file, threshold=threshold)
|
| 392 |
+
|
| 393 |
+
# Create appropriate Structure subclass
|
| 394 |
+
if structure_type == StructureType.OAR:
|
| 395 |
+
structure_class = OAR
|
| 396 |
+
elif structure_type == StructureType.TARGET:
|
| 397 |
+
structure_class = Target
|
| 398 |
+
else:
|
| 399 |
+
# For other types, use base class with type override
|
| 400 |
+
structure_class = type(
|
| 401 |
+
f"{structure_type.value.title()}Structure",
|
| 402 |
+
(Structure,),
|
| 403 |
+
{"structure_type": property(lambda self: structure_type)},
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
structure = structure_class(
|
| 407 |
+
name=name,
|
| 408 |
+
mask=mask,
|
| 409 |
+
spacing=spacing,
|
| 410 |
+
origin=origin,
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
return structure
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def write_nifti_volume(
|
| 417 |
+
volume: np.ndarray,
|
| 418 |
+
output_file: Union[str, Path],
|
| 419 |
+
spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
| 420 |
+
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
| 421 |
+
) -> None:
|
| 422 |
+
"""
|
| 423 |
+
Write a numpy array to a NIfTI file.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
volume: 3D numpy array to write
|
| 427 |
+
output_file: Path for output NIfTI file
|
| 428 |
+
spacing: Voxel spacing in (x, y, z) mm
|
| 429 |
+
origin: Origin coordinates in (x, y, z) mm
|
| 430 |
+
|
| 431 |
+
Raises:
|
| 432 |
+
ValueError: If volume is not 3D
|
| 433 |
+
"""
|
| 434 |
+
if volume.ndim != 3:
|
| 435 |
+
raise ValueError(f"Volume must be 3D, got {volume.ndim}D")
|
| 436 |
+
|
| 437 |
+
output_file = Path(output_file)
|
| 438 |
+
|
| 439 |
+
# Create SimpleITK image
|
| 440 |
+
image = sitk.GetImageFromArray(volume)
|
| 441 |
+
image.SetSpacing(spacing)
|
| 442 |
+
image.SetOrigin(origin)
|
| 443 |
+
|
| 444 |
+
# Ensure output directory exists
|
| 445 |
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 446 |
+
|
| 447 |
+
# Write to file
|
| 448 |
+
sitk.WriteImage(image, str(output_file))
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def write_structure_as_nifti(
|
| 452 |
+
structure: "Structure",
|
| 453 |
+
output_file: Union[str, Path],
|
| 454 |
+
) -> None:
|
| 455 |
+
"""
|
| 456 |
+
Write a Structure's mask to a NIfTI file.
|
| 457 |
+
|
| 458 |
+
Args:
|
| 459 |
+
structure: Structure object to write
|
| 460 |
+
output_file: Path for output NIfTI file
|
| 461 |
+
|
| 462 |
+
Raises:
|
| 463 |
+
ValueError: If structure has no mask
|
| 464 |
+
"""
|
| 465 |
+
if structure.mask is None:
|
| 466 |
+
raise ValueError(f"Structure '{structure.name}' has no mask")
|
| 467 |
+
|
| 468 |
+
# Convert boolean mask to uint8 for better compatibility
|
| 469 |
+
mask_uint = structure.mask.astype(np.uint8)
|
| 470 |
+
|
| 471 |
+
write_nifti_volume(
|
| 472 |
+
volume=mask_uint,
|
| 473 |
+
output_file=output_file,
|
| 474 |
+
spacing=structure.spacing,
|
| 475 |
+
origin=structure.origin,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def write_structure_set_as_nifti(
|
| 480 |
+
structure_set: "StructureSet",
|
| 481 |
+
output_folder: Union[str, Path],
|
| 482 |
+
write_dose: bool = True,
|
| 483 |
+
dose_filename: str = "Dose.nii.gz",
|
| 484 |
+
) -> None:
|
| 485 |
+
"""
|
| 486 |
+
Write a StructureSet to NIfTI files in a folder.
|
| 487 |
+
|
| 488 |
+
Args:
|
| 489 |
+
structure_set: StructureSet to write
|
| 490 |
+
output_folder: Path to output folder
|
| 491 |
+
write_dose: Whether to write dose file
|
| 492 |
+
dose_filename: Name for dose file
|
| 493 |
+
|
| 494 |
+
Raises:
|
| 495 |
+
ValueError: If structure_set has no structures
|
| 496 |
+
"""
|
| 497 |
+
output_folder = Path(output_folder)
|
| 498 |
+
output_folder.mkdir(parents=True, exist_ok=True)
|
| 499 |
+
|
| 500 |
+
# Write each structure
|
| 501 |
+
for struct_name, structure in structure_set.structures.items():
|
| 502 |
+
if structure.mask is not None:
|
| 503 |
+
output_file = output_folder / f"{struct_name}.nii.gz"
|
| 504 |
+
write_structure_as_nifti(structure, output_file)
|
| 505 |
+
|
| 506 |
+
# Note: Dose writing removed - use Dose objects separately
|
src/dosemetrics/metrics/__init__.py
CHANGED
|
@@ -1,46 +1,110 @@
|
|
| 1 |
"""
|
| 2 |
Core dose metrics and calculations.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from .dvh import (
|
| 7 |
-
mean_dose,
|
| 8 |
-
max_dose,
|
| 9 |
-
volume,
|
| 10 |
compute_dvh,
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
)
|
| 15 |
-
|
| 16 |
-
from .
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
)
|
| 23 |
|
| 24 |
__all__ = [
|
| 25 |
-
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
|
|
|
| 33 |
"compute_dvh",
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
]
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Core dose metrics and calculations.
|
| 3 |
+
|
| 4 |
+
This package provides metrics for radiotherapy dose analysis, including:
|
| 5 |
+
- DVH computation and analysis
|
| 6 |
+
- Dose statistics
|
| 7 |
+
- Conformity indices
|
| 8 |
+
- Homogeneity indices
|
| 9 |
+
- Geometric metrics for structure comparison
|
| 10 |
+
- Gamma analysis
|
| 11 |
+
- Advanced DVH metrics
|
| 12 |
+
- Dose comparison metrics
|
| 13 |
"""
|
| 14 |
|
| 15 |
+
# Import all metrics modules
|
| 16 |
+
from . import dvh
|
| 17 |
+
from . import conformity
|
| 18 |
+
from . import homogeneity
|
| 19 |
+
from . import geometric
|
| 20 |
+
from . import gamma
|
| 21 |
+
from . import advanced_dvh
|
| 22 |
+
from . import dose_comparison
|
| 23 |
+
|
| 24 |
+
# Import commonly used functions
|
| 25 |
from .dvh import (
|
|
|
|
|
|
|
|
|
|
| 26 |
compute_dvh,
|
| 27 |
+
compute_volume_at_dose,
|
| 28 |
+
compute_dose_at_volume,
|
| 29 |
+
compute_dose_at_volume_cc,
|
| 30 |
+
compute_equivalent_uniform_dose,
|
| 31 |
+
create_dvh_table,
|
| 32 |
+
extract_dvh_metrics,
|
| 33 |
+
# Dose statistics (moved from statistics.py)
|
| 34 |
+
compute_dose_statistics,
|
| 35 |
+
compute_mean_dose,
|
| 36 |
+
compute_max_dose,
|
| 37 |
+
compute_min_dose,
|
| 38 |
+
compute_median_dose,
|
| 39 |
+
compute_dose_percentile,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
from .conformity import (
|
| 43 |
+
compute_conformity_index,
|
| 44 |
+
compute_conformity_number,
|
| 45 |
+
compute_paddick_conformity_index,
|
| 46 |
+
compute_coverage,
|
| 47 |
+
compute_spillage,
|
| 48 |
)
|
| 49 |
+
|
| 50 |
+
from .homogeneity import (
|
| 51 |
+
compute_homogeneity_index,
|
| 52 |
+
compute_gradient_index,
|
| 53 |
+
compute_dose_homogeneity,
|
| 54 |
+
compute_uniformity_index,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
from .geometric import (
|
| 58 |
+
compute_dice_coefficient,
|
| 59 |
+
compute_jaccard_index,
|
| 60 |
+
compute_volume_difference,
|
| 61 |
+
compute_volume_ratio,
|
| 62 |
+
compute_sensitivity,
|
| 63 |
+
compute_specificity,
|
| 64 |
+
compare_structure_sets,
|
| 65 |
)
|
| 66 |
|
| 67 |
__all__ = [
|
| 68 |
+
# Submodules
|
| 69 |
+
"dvh",
|
| 70 |
+
"conformity",
|
| 71 |
+
"homogeneity",
|
| 72 |
+
"geometric",
|
| 73 |
+
"gamma",
|
| 74 |
+
"advanced_dvh",
|
| 75 |
+
"dose_comparison",
|
| 76 |
+
# DVH and statistics functions
|
| 77 |
"compute_dvh",
|
| 78 |
+
"compute_volume_at_dose",
|
| 79 |
+
"compute_dose_at_volume",
|
| 80 |
+
"compute_dose_at_volume_cc",
|
| 81 |
+
"compute_equivalent_uniform_dose",
|
| 82 |
+
"create_dvh_table",
|
| 83 |
+
"extract_dvh_metrics",
|
| 84 |
+
"compute_dose_statistics",
|
| 85 |
+
"compute_mean_dose",
|
| 86 |
+
"compute_max_dose",
|
| 87 |
+
"compute_min_dose",
|
| 88 |
+
"compute_median_dose",
|
| 89 |
+
"compute_dose_percentile",
|
| 90 |
+
# Conformity functions
|
| 91 |
+
"compute_conformity_index",
|
| 92 |
+
"compute_conformity_number",
|
| 93 |
+
"compute_paddick_conformity_index",
|
| 94 |
+
"compute_coverage",
|
| 95 |
+
"compute_spillage",
|
| 96 |
+
# Homogeneity functions
|
| 97 |
+
"compute_homogeneity_index",
|
| 98 |
+
"compute_gradient_index",
|
| 99 |
+
"compute_dose_homogeneity",
|
| 100 |
+
"compute_uniformity_index",
|
| 101 |
+
# Geometric functions
|
| 102 |
+
"compute_dice_coefficient",
|
| 103 |
+
"compute_jaccard_index",
|
| 104 |
+
"compute_volume_difference",
|
| 105 |
+
"compute_volume_ratio",
|
| 106 |
+
"compute_sensitivity",
|
| 107 |
+
"compute_specificity",
|
| 108 |
+
"compare_structure_sets",
|
| 109 |
]
|
| 110 |
+
|
src/dosemetrics/metrics/advanced_dvh.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Advanced DVH metrics and comparison tools.
|
| 3 |
+
|
| 4 |
+
This module provides advanced DVH-based metrics for comparing dose distributions
|
| 5 |
+
including Wasserstein distance, area between curves, and other statistical measures.
|
| 6 |
+
|
| 7 |
+
Future Implementation TODOs:
|
| 8 |
+
- Wasserstein distance (Earth Mover's Distance) between DVHs
|
| 9 |
+
- Area between DVH curves (L1/L2 norms)
|
| 10 |
+
- DVH bandwidth and confidence intervals
|
| 11 |
+
- Chi-square and Kolmogorov-Smirnov tests for DVH comparison
|
| 12 |
+
- DVH-based TCP/NTCP models
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
from typing import Tuple, Dict, List, Optional
|
| 17 |
+
import warnings
|
| 18 |
+
from scipy.stats import wasserstein_distance, kstest, chisquare
|
| 19 |
+
|
| 20 |
+
from ..dose import Dose
|
| 21 |
+
from ..structures import Structure
|
| 22 |
+
from .dvh import compute_dvh
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_dvh_wasserstein_distance(
|
| 26 |
+
dose1: Dose,
|
| 27 |
+
dose2: Dose,
|
| 28 |
+
structure: Structure
|
| 29 |
+
) -> float:
|
| 30 |
+
"""
|
| 31 |
+
Compute Wasserstein distance (Earth Mover's Distance) between two DVHs.
|
| 32 |
+
|
| 33 |
+
The Wasserstein distance quantifies the minimum "work" required to transform
|
| 34 |
+
one DVH into another, providing a meaningful metric for DVH similarity.
|
| 35 |
+
|
| 36 |
+
Parameters
|
| 37 |
+
----------
|
| 38 |
+
dose1 : Dose
|
| 39 |
+
First dose distribution.
|
| 40 |
+
dose2 : Dose
|
| 41 |
+
Second dose distribution.
|
| 42 |
+
structure : Structure
|
| 43 |
+
Structure for which to compute DVHs.
|
| 44 |
+
|
| 45 |
+
Returns
|
| 46 |
+
-------
|
| 47 |
+
distance : float
|
| 48 |
+
Wasserstein distance between the two DVHs.
|
| 49 |
+
|
| 50 |
+
Notes
|
| 51 |
+
-----
|
| 52 |
+
The Wasserstein distance is also known as:
|
| 53 |
+
- Earth Mover's Distance (EMD)
|
| 54 |
+
- Kantorovich-Rubinstein metric
|
| 55 |
+
- Mallows distance
|
| 56 |
+
|
| 57 |
+
It satisfies the triangle inequality and is a true metric, unlike
|
| 58 |
+
simple area-between-curves measures.
|
| 59 |
+
|
| 60 |
+
References
|
| 61 |
+
----------
|
| 62 |
+
- Rubner Y, Tomasi C, Guibas LJ. "The Earth Mover's Distance as a Metric
|
| 63 |
+
for Image Retrieval." Int J Comput Vision. 2000;40(2):99-121.
|
| 64 |
+
|
| 65 |
+
Examples
|
| 66 |
+
--------
|
| 67 |
+
>>> from dosemetrics.metrics import advanced_dvh
|
| 68 |
+
>>> distance = advanced_dvh.compute_dvh_wasserstein_distance(
|
| 69 |
+
... planned_dose, delivered_dose, ptv
|
| 70 |
+
... )
|
| 71 |
+
>>> print(f"DVH Wasserstein distance: {distance:.2f} Gy")
|
| 72 |
+
|
| 73 |
+
Raises
|
| 74 |
+
------
|
| 75 |
+
NotImplementedError
|
| 76 |
+
This function is a stub for future implementation.
|
| 77 |
+
"""
|
| 78 |
+
# Get dose values within structure for both doses
|
| 79 |
+
dose_values1 = dose1.get_dose_in_structure(structure)
|
| 80 |
+
dose_values2 = dose2.get_dose_in_structure(structure)
|
| 81 |
+
|
| 82 |
+
if len(dose_values1) == 0 or len(dose_values2) == 0:
|
| 83 |
+
return 0.0
|
| 84 |
+
|
| 85 |
+
# Compute Wasserstein distance directly on dose values
|
| 86 |
+
distance = wasserstein_distance(dose_values1, dose_values2)
|
| 87 |
+
|
| 88 |
+
return float(distance)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def compute_area_between_dvh_curves(
|
| 92 |
+
dose1: Dose,
|
| 93 |
+
dose2: Dose,
|
| 94 |
+
structure: Structure,
|
| 95 |
+
norm: str = 'L2'
|
| 96 |
+
) -> float:
|
| 97 |
+
"""
|
| 98 |
+
Compute area between two DVH curves.
|
| 99 |
+
|
| 100 |
+
Parameters
|
| 101 |
+
----------
|
| 102 |
+
dose1 : Dose
|
| 103 |
+
First dose distribution.
|
| 104 |
+
dose2 : Dose
|
| 105 |
+
Second dose distribution.
|
| 106 |
+
structure : Structure
|
| 107 |
+
Structure for which to compute DVHs.
|
| 108 |
+
norm : {'L1', 'L2'}, optional
|
| 109 |
+
Norm to use for area calculation:
|
| 110 |
+
- 'L1': Sum of absolute differences
|
| 111 |
+
- 'L2': Sum of squared differences (default)
|
| 112 |
+
|
| 113 |
+
Returns
|
| 114 |
+
-------
|
| 115 |
+
area : float
|
| 116 |
+
Area between the two DVH curves.
|
| 117 |
+
|
| 118 |
+
Notes
|
| 119 |
+
-----
|
| 120 |
+
The L1 norm gives the Manhattan distance, while L2 gives Euclidean distance.
|
| 121 |
+
For DVH comparison, L1 is often more interpretable.
|
| 122 |
+
|
| 123 |
+
Raises
|
| 124 |
+
------
|
| 125 |
+
ValueError
|
| 126 |
+
If norm is not 'L1' or 'L2'.
|
| 127 |
+
"""
|
| 128 |
+
if norm not in ['L1', 'L2']:
|
| 129 |
+
raise ValueError(f"norm must be 'L1' or 'L2', got '{norm}'")
|
| 130 |
+
|
| 131 |
+
# Compute DVHs
|
| 132 |
+
dose_bins1, volumes1 = compute_dvh(dose1, structure)
|
| 133 |
+
dose_bins2, volumes2 = compute_dvh(dose2, structure)
|
| 134 |
+
|
| 135 |
+
# Create common dose bins
|
| 136 |
+
max_dose = max(np.max(dose_bins1), np.max(dose_bins2))
|
| 137 |
+
step_size = min(
|
| 138 |
+
dose_bins1[1] - dose_bins1[0] if len(dose_bins1) > 1 else 0.1,
|
| 139 |
+
dose_bins2[1] - dose_bins2[0] if len(dose_bins2) > 1 else 0.1
|
| 140 |
+
)
|
| 141 |
+
common_bins = np.arange(0, max_dose + step_size, step_size)
|
| 142 |
+
|
| 143 |
+
# Interpolate to common bins
|
| 144 |
+
volumes1_interp = np.interp(common_bins, dose_bins1, volumes1)
|
| 145 |
+
volumes2_interp = np.interp(common_bins, dose_bins2, volumes2)
|
| 146 |
+
|
| 147 |
+
# Compute area based on norm
|
| 148 |
+
if norm == 'L1':
|
| 149 |
+
area = np.sum(np.abs(volumes1_interp - volumes2_interp)) * step_size
|
| 150 |
+
else: # L2
|
| 151 |
+
area = np.sqrt(np.sum((volumes1_interp - volumes2_interp) ** 2)) * step_size
|
| 152 |
+
|
| 153 |
+
return float(area)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def compute_dvh_chi_square(
|
| 157 |
+
dose1: Dose,
|
| 158 |
+
dose2: Dose,
|
| 159 |
+
structure: Structure
|
| 160 |
+
) -> Tuple[float, float]:
|
| 161 |
+
"""
|
| 162 |
+
Perform chi-square test comparing two DVHs.
|
| 163 |
+
|
| 164 |
+
Parameters
|
| 165 |
+
----------
|
| 166 |
+
dose1 : Dose
|
| 167 |
+
First (expected) dose distribution.
|
| 168 |
+
dose2 : Dose
|
| 169 |
+
Second (observed) dose distribution.
|
| 170 |
+
structure : Structure
|
| 171 |
+
Structure for DVH computation.
|
| 172 |
+
|
| 173 |
+
Returns
|
| 174 |
+
-------
|
| 175 |
+
chi2_statistic : float
|
| 176 |
+
Chi-square test statistic.
|
| 177 |
+
p_value : float
|
| 178 |
+
P-value for the test.
|
| 179 |
+
|
| 180 |
+
Notes
|
| 181 |
+
-----
|
| 182 |
+
Tests the null hypothesis that the two DVHs come from the same distribution.
|
| 183 |
+
"""
|
| 184 |
+
# Compute DVHs
|
| 185 |
+
dose_bins1, volumes1 = compute_dvh(dose1, structure)
|
| 186 |
+
dose_bins2, volumes2 = compute_dvh(dose2, structure)
|
| 187 |
+
|
| 188 |
+
# Create common bins
|
| 189 |
+
max_dose = max(np.max(dose_bins1), np.max(dose_bins2))
|
| 190 |
+
step_size = min(
|
| 191 |
+
dose_bins1[1] - dose_bins1[0] if len(dose_bins1) > 1 else 0.1,
|
| 192 |
+
dose_bins2[1] - dose_bins2[0] if len(dose_bins2) > 1 else 0.1
|
| 193 |
+
)
|
| 194 |
+
common_bins = np.arange(0, max_dose + step_size, step_size)
|
| 195 |
+
|
| 196 |
+
# Interpolate
|
| 197 |
+
volumes1_interp = np.interp(common_bins, dose_bins1, volumes1)
|
| 198 |
+
volumes2_interp = np.interp(common_bins, dose_bins2, volumes2)
|
| 199 |
+
|
| 200 |
+
# Convert cumulative DVH to differential (histogram)
|
| 201 |
+
diff_volumes1 = -np.diff(np.append(volumes1_interp, 0))
|
| 202 |
+
diff_volumes2 = -np.diff(np.append(volumes2_interp, 0))
|
| 203 |
+
|
| 204 |
+
# Ensure non-negative
|
| 205 |
+
diff_volumes1 = np.maximum(diff_volumes1, 0)
|
| 206 |
+
diff_volumes2 = np.maximum(diff_volumes2, 0)
|
| 207 |
+
|
| 208 |
+
# Avoid zeros in expected values
|
| 209 |
+
diff_volumes1 = diff_volumes1 + 1e-10
|
| 210 |
+
|
| 211 |
+
# Compute chi-square
|
| 212 |
+
chi2_stat, p_value = chisquare(diff_volumes2, diff_volumes1)
|
| 213 |
+
|
| 214 |
+
return float(chi2_stat), float(p_value)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def compute_dvh_ks_test(
|
| 218 |
+
dose1: Dose,
|
| 219 |
+
dose2: Dose,
|
| 220 |
+
structure: Structure
|
| 221 |
+
) -> Tuple[float, float]:
|
| 222 |
+
"""
|
| 223 |
+
Perform Kolmogorov-Smirnov test comparing two DVHs.
|
| 224 |
+
|
| 225 |
+
Parameters
|
| 226 |
+
----------
|
| 227 |
+
dose1 : Dose
|
| 228 |
+
First dose distribution.
|
| 229 |
+
dose2 : Dose
|
| 230 |
+
Second dose distribution.
|
| 231 |
+
structure : Structure
|
| 232 |
+
Structure for DVH computation.
|
| 233 |
+
|
| 234 |
+
Returns
|
| 235 |
+
-------
|
| 236 |
+
ks_statistic : float
|
| 237 |
+
KS test statistic (maximum difference between CDFs).
|
| 238 |
+
p_value : float
|
| 239 |
+
P-value for the test.
|
| 240 |
+
|
| 241 |
+
Notes
|
| 242 |
+
-----
|
| 243 |
+
The KS test is non-parametric and tests whether two samples come from
|
| 244 |
+
the same distribution.
|
| 245 |
+
"""
|
| 246 |
+
from scipy.stats import ks_2samp
|
| 247 |
+
|
| 248 |
+
# Get dose values in structure for both doses
|
| 249 |
+
dose_values1 = dose1.get_dose_in_structure(structure)
|
| 250 |
+
dose_values2 = dose2.get_dose_in_structure(structure)
|
| 251 |
+
|
| 252 |
+
if len(dose_values1) == 0 or len(dose_values2) == 0:
|
| 253 |
+
return np.nan, np.nan
|
| 254 |
+
|
| 255 |
+
# Perform two-sample KS test
|
| 256 |
+
ks_stat, p_value = ks_2samp(dose_values1, dose_values2)
|
| 257 |
+
|
| 258 |
+
return float(ks_stat), float(p_value)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def compute_dvh_confidence_interval(
|
| 262 |
+
doses: List[Dose],
|
| 263 |
+
structure: Structure,
|
| 264 |
+
confidence: float = 0.95
|
| 265 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 266 |
+
"""
|
| 267 |
+
Compute DVH confidence intervals from multiple dose distributions.
|
| 268 |
+
|
| 269 |
+
Useful for uncertainty quantification from multiple treatment plans
|
| 270 |
+
or Monte Carlo dose simulations.
|
| 271 |
+
|
| 272 |
+
Parameters
|
| 273 |
+
----------
|
| 274 |
+
doses : list of Dose
|
| 275 |
+
Multiple dose distributions (e.g., from robust optimization).
|
| 276 |
+
structure : Structure
|
| 277 |
+
Structure for DVH computation.
|
| 278 |
+
confidence : float, optional
|
| 279 |
+
Confidence level (default: 0.95 for 95% CI).
|
| 280 |
+
|
| 281 |
+
Returns
|
| 282 |
+
-------
|
| 283 |
+
dose_bins : np.ndarray
|
| 284 |
+
Dose bin values.
|
| 285 |
+
mean_dvh : np.ndarray
|
| 286 |
+
Mean DVH curve.
|
| 287 |
+
ci_lower : np.ndarray
|
| 288 |
+
Lower confidence interval.
|
| 289 |
+
ci_upper : np.ndarray
|
| 290 |
+
Upper confidence interval.
|
| 291 |
+
|
| 292 |
+
Examples
|
| 293 |
+
--------
|
| 294 |
+
>>> dose_bins, mean, lower, upper = compute_dvh_confidence_interval(
|
| 295 |
+
... [dose1, dose2, dose3], ptv
|
| 296 |
+
... )
|
| 297 |
+
>>> plt.fill_between(dose_bins, lower, upper, alpha=0.3)
|
| 298 |
+
>>> plt.plot(dose_bins, mean, 'k-', linewidth=2)
|
| 299 |
+
"""
|
| 300 |
+
if len(doses) == 0:
|
| 301 |
+
raise ValueError("At least one dose is required")
|
| 302 |
+
|
| 303 |
+
# Compute DVH for each dose
|
| 304 |
+
all_dvhs = []
|
| 305 |
+
max_dose = 0
|
| 306 |
+
min_step = float('inf')
|
| 307 |
+
|
| 308 |
+
for dose in doses:
|
| 309 |
+
dose_bins, volumes = compute_dvh(dose, structure)
|
| 310 |
+
all_dvhs.append((dose_bins, volumes))
|
| 311 |
+
max_dose = max(max_dose, np.max(dose_bins))
|
| 312 |
+
if len(dose_bins) > 1:
|
| 313 |
+
min_step = min(min_step, dose_bins[1] - dose_bins[0])
|
| 314 |
+
|
| 315 |
+
if min_step == float('inf'):
|
| 316 |
+
min_step = 0.1
|
| 317 |
+
|
| 318 |
+
# Create common dose bins
|
| 319 |
+
common_bins = np.arange(0, max_dose + min_step, min_step)
|
| 320 |
+
|
| 321 |
+
# Interpolate all DVHs to common bins
|
| 322 |
+
interpolated_dvhs = []
|
| 323 |
+
for dose_bins, volumes in all_dvhs:
|
| 324 |
+
volumes_interp = np.interp(common_bins, dose_bins, volumes)
|
| 325 |
+
interpolated_dvhs.append(volumes_interp)
|
| 326 |
+
|
| 327 |
+
# Stack into array (n_doses x n_bins)
|
| 328 |
+
dvh_array = np.array(interpolated_dvhs)
|
| 329 |
+
|
| 330 |
+
# Compute mean and confidence intervals
|
| 331 |
+
mean_dvh = np.mean(dvh_array, axis=0)
|
| 332 |
+
|
| 333 |
+
# Compute percentiles for confidence interval
|
| 334 |
+
alpha = 1 - confidence
|
| 335 |
+
lower_percentile = (alpha / 2) * 100
|
| 336 |
+
upper_percentile = (1 - alpha / 2) * 100
|
| 337 |
+
|
| 338 |
+
ci_lower = np.percentile(dvh_array, lower_percentile, axis=0)
|
| 339 |
+
ci_upper = np.percentile(dvh_array, upper_percentile, axis=0)
|
| 340 |
+
|
| 341 |
+
return common_bins, mean_dvh, ci_lower, ci_upper
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def compute_dvh_bandwidth(
|
| 345 |
+
doses: List[Dose],
|
| 346 |
+
structure: Structure
|
| 347 |
+
) -> np.ndarray:
|
| 348 |
+
"""
|
| 349 |
+
Compute DVH bandwidth (maximum difference at each dose level).
|
| 350 |
+
|
| 351 |
+
Parameters
|
| 352 |
+
----------
|
| 353 |
+
doses : list of Dose
|
| 354 |
+
Multiple dose distributions.
|
| 355 |
+
structure : Structure
|
| 356 |
+
Structure for DVH computation.
|
| 357 |
+
|
| 358 |
+
Returns
|
| 359 |
+
-------
|
| 360 |
+
bandwidth : np.ndarray
|
| 361 |
+
Maximum difference between DVHs at each dose bin.
|
| 362 |
+
|
| 363 |
+
Notes
|
| 364 |
+
-----
|
| 365 |
+
Useful for robust plan evaluation - smaller bandwidth indicates
|
| 366 |
+
more robust plan.
|
| 367 |
+
"""
|
| 368 |
+
if len(doses) == 0:
|
| 369 |
+
raise ValueError("At least one dose is required")
|
| 370 |
+
|
| 371 |
+
# Compute DVH for each dose
|
| 372 |
+
all_dvhs = []
|
| 373 |
+
max_dose = 0
|
| 374 |
+
min_step = float('inf')
|
| 375 |
+
|
| 376 |
+
for dose in doses:
|
| 377 |
+
dose_bins, volumes = compute_dvh(dose, structure)
|
| 378 |
+
all_dvhs.append((dose_bins, volumes))
|
| 379 |
+
max_dose = max(max_dose, np.max(dose_bins))
|
| 380 |
+
if len(dose_bins) > 1:
|
| 381 |
+
min_step = min(min_step, dose_bins[1] - dose_bins[0])
|
| 382 |
+
|
| 383 |
+
if min_step == float('inf'):
|
| 384 |
+
min_step = 0.1
|
| 385 |
+
|
| 386 |
+
# Create common dose bins
|
| 387 |
+
common_bins = np.arange(0, max_dose + min_step, min_step)
|
| 388 |
+
|
| 389 |
+
# Interpolate all DVHs to common bins
|
| 390 |
+
interpolated_dvhs = []
|
| 391 |
+
for dose_bins, volumes in all_dvhs:
|
| 392 |
+
volumes_interp = np.interp(common_bins, dose_bins, volumes)
|
| 393 |
+
interpolated_dvhs.append(volumes_interp)
|
| 394 |
+
|
| 395 |
+
# Stack into array
|
| 396 |
+
dvh_array = np.array(interpolated_dvhs)
|
| 397 |
+
|
| 398 |
+
# Compute bandwidth (max - min at each dose)
|
| 399 |
+
bandwidth = np.max(dvh_array, axis=0) - np.min(dvh_array, axis=0)
|
| 400 |
+
|
| 401 |
+
return bandwidth
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def compute_dvh_similarity_index(
|
| 405 |
+
dose1: Dose,
|
| 406 |
+
dose2: Dose,
|
| 407 |
+
structure: Structure,
|
| 408 |
+
method: str = 'dice'
|
| 409 |
+
) -> float:
|
| 410 |
+
"""
|
| 411 |
+
Compute DVH similarity index using various methods.
|
| 412 |
+
|
| 413 |
+
Parameters
|
| 414 |
+
----------
|
| 415 |
+
dose1 : Dose
|
| 416 |
+
First dose distribution.
|
| 417 |
+
dose2 : Dose
|
| 418 |
+
Second dose distribution.
|
| 419 |
+
structure : Structure
|
| 420 |
+
Structure for DVH computation.
|
| 421 |
+
method : {'dice', 'jaccard', 'correlation', 'cosine'}, optional
|
| 422 |
+
Similarity metric to use (default: 'dice').
|
| 423 |
+
|
| 424 |
+
Returns
|
| 425 |
+
-------
|
| 426 |
+
similarity : float
|
| 427 |
+
Similarity score (0-1, higher is more similar).
|
| 428 |
+
|
| 429 |
+
Raises
|
| 430 |
+
------
|
| 431 |
+
ValueError
|
| 432 |
+
If method is not recognized.
|
| 433 |
+
"""
|
| 434 |
+
if method not in ['dice', 'jaccard', 'correlation', 'cosine']:
|
| 435 |
+
raise ValueError(f"Unknown method: {method}. Use 'dice', 'jaccard', 'correlation', or 'cosine'.")
|
| 436 |
+
|
| 437 |
+
# Compute DVHs
|
| 438 |
+
dose_bins1, volumes1 = compute_dvh(dose1, structure)
|
| 439 |
+
dose_bins2, volumes2 = compute_dvh(dose2, structure)
|
| 440 |
+
|
| 441 |
+
# Create common bins and interpolate
|
| 442 |
+
max_dose = max(np.max(dose_bins1), np.max(dose_bins2))
|
| 443 |
+
step_size = min(
|
| 444 |
+
dose_bins1[1] - dose_bins1[0] if len(dose_bins1) > 1 else 0.1,
|
| 445 |
+
dose_bins2[1] - dose_bins2[0] if len(dose_bins2) > 1 else 0.1
|
| 446 |
+
)
|
| 447 |
+
common_bins = np.arange(0, max_dose + step_size, step_size)
|
| 448 |
+
|
| 449 |
+
volumes1_interp = np.interp(common_bins, dose_bins1, volumes1)
|
| 450 |
+
volumes2_interp = np.interp(common_bins, dose_bins2, volumes2)
|
| 451 |
+
|
| 452 |
+
if method == 'dice':
|
| 453 |
+
# Treat DVH curves as binary masks at each dose level
|
| 454 |
+
intersection = np.minimum(volumes1_interp, volumes2_interp)
|
| 455 |
+
union = volumes1_interp + volumes2_interp
|
| 456 |
+
if np.sum(union) == 0:
|
| 457 |
+
return 0.0
|
| 458 |
+
return float(2.0 * np.sum(intersection) / np.sum(union))
|
| 459 |
+
|
| 460 |
+
elif method == 'jaccard':
|
| 461 |
+
# Jaccard index (IoU)
|
| 462 |
+
intersection = np.minimum(volumes1_interp, volumes2_interp)
|
| 463 |
+
union = np.maximum(volumes1_interp, volumes2_interp)
|
| 464 |
+
if np.sum(union) == 0:
|
| 465 |
+
return 0.0
|
| 466 |
+
return float(np.sum(intersection) / np.sum(union))
|
| 467 |
+
|
| 468 |
+
elif method == 'correlation':
|
| 469 |
+
# Pearson correlation
|
| 470 |
+
if len(volumes1_interp) < 2:
|
| 471 |
+
return 0.0
|
| 472 |
+
corr = np.corrcoef(volumes1_interp, volumes2_interp)[0, 1]
|
| 473 |
+
return float(corr) if not np.isnan(corr) else 0.0
|
| 474 |
+
|
| 475 |
+
elif method == 'cosine':
|
| 476 |
+
# Cosine similarity
|
| 477 |
+
dot_product = np.dot(volumes1_interp, volumes2_interp)
|
| 478 |
+
norm1 = np.linalg.norm(volumes1_interp)
|
| 479 |
+
norm2 = np.linalg.norm(volumes2_interp)
|
| 480 |
+
if norm1 == 0 or norm2 == 0:
|
| 481 |
+
return 0.0
|
| 482 |
+
return float(dot_product / (norm1 * norm2))
|
| 483 |
+
|
| 484 |
+
return 0.0
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
__all__ = [
|
| 488 |
+
'compute_dvh_wasserstein_distance',
|
| 489 |
+
'compute_area_between_dvh_curves',
|
| 490 |
+
'compute_dvh_chi_square',
|
| 491 |
+
'compute_dvh_ks_test',
|
| 492 |
+
'compute_dvh_confidence_interval',
|
| 493 |
+
'compute_dvh_bandwidth',
|
| 494 |
+
'compute_dvh_similarity_index',
|
| 495 |
+
]
|
src/dosemetrics/metrics/conformity.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conformity indices for target coverage evaluation.
|
| 3 |
+
|
| 4 |
+
This module provides various conformity indices used to evaluate how well
|
| 5 |
+
the prescription isodose conforms to the target volume. These metrics are
|
| 6 |
+
critical for assessing treatment plan quality.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from ..dose import Dose
|
| 16 |
+
from ..structures import Structure
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def compute_conformity_index(
|
| 20 |
+
dose: Dose,
|
| 21 |
+
target: Structure,
|
| 22 |
+
prescription_dose: float
|
| 23 |
+
) -> float:
|
| 24 |
+
"""
|
| 25 |
+
Compute Conformity Index (CI).
|
| 26 |
+
|
| 27 |
+
CI = V_target_rx / V_rx
|
| 28 |
+
|
| 29 |
+
Where:
|
| 30 |
+
- V_target_rx = volume of target receiving >= prescription dose
|
| 31 |
+
- V_rx = total volume receiving >= prescription dose
|
| 32 |
+
|
| 33 |
+
Measures how well the prescription isodose conforms to the target.
|
| 34 |
+
Ideal value is 1.0. Values < 1.0 indicate dose spillage outside target.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
dose: Dose distribution object
|
| 38 |
+
target: Target structure (PTV, CTV, etc.)
|
| 39 |
+
prescription_dose: Prescription dose in Gy
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Conformity index (dimensionless, typically 0-1)
|
| 43 |
+
|
| 44 |
+
References:
|
| 45 |
+
ICRU Report 62 (1999)
|
| 46 |
+
|
| 47 |
+
Examples:
|
| 48 |
+
>>> ci = compute_conformity_index(dose, ptv, prescription_dose=60.0)
|
| 49 |
+
>>> print(f"Conformity Index: {ci:.3f}")
|
| 50 |
+
"""
|
| 51 |
+
# Volume of target receiving >= prescription dose
|
| 52 |
+
target_dose_values = dose.get_dose_in_structure(target)
|
| 53 |
+
v_target_rx = np.sum(target_dose_values >= prescription_dose)
|
| 54 |
+
|
| 55 |
+
# Total volume receiving >= prescription dose
|
| 56 |
+
v_rx = np.sum(dose.dose_array >= prescription_dose)
|
| 57 |
+
|
| 58 |
+
if v_rx == 0:
|
| 59 |
+
return 0.0
|
| 60 |
+
|
| 61 |
+
return float(v_target_rx / v_rx)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def compute_conformity_number(
|
| 65 |
+
dose: Dose,
|
| 66 |
+
target: Structure,
|
| 67 |
+
prescription_dose: float
|
| 68 |
+
) -> float:
|
| 69 |
+
"""
|
| 70 |
+
Compute Conformity Number (CN) or Conformation Number.
|
| 71 |
+
|
| 72 |
+
CN = (V_target_rx / V_target) * (V_target_rx / V_rx)
|
| 73 |
+
|
| 74 |
+
Combines target coverage and dose spillage into a single metric.
|
| 75 |
+
Ideal value is 1.0.
|
| 76 |
+
|
| 77 |
+
The first factor (V_target_rx / V_target) represents target coverage.
|
| 78 |
+
The second factor (V_target_rx / V_rx) represents conformity.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
dose: Dose distribution object
|
| 82 |
+
target: Target structure
|
| 83 |
+
prescription_dose: Prescription dose in Gy
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Conformity number (0-1)
|
| 87 |
+
|
| 88 |
+
References:
|
| 89 |
+
van't Riet et al., Int J Radiat Oncol Biol Phys 1997
|
| 90 |
+
|
| 91 |
+
Examples:
|
| 92 |
+
>>> cn = compute_conformity_number(dose, ptv, prescription_dose=60.0)
|
| 93 |
+
>>> print(f"Conformity Number: {cn:.3f}")
|
| 94 |
+
"""
|
| 95 |
+
target_dose_values = dose.get_dose_in_structure(target)
|
| 96 |
+
|
| 97 |
+
v_target = len(target_dose_values)
|
| 98 |
+
if v_target == 0:
|
| 99 |
+
return 0.0
|
| 100 |
+
|
| 101 |
+
v_target_rx = np.sum(target_dose_values >= prescription_dose)
|
| 102 |
+
v_rx = np.sum(dose.dose_array >= prescription_dose)
|
| 103 |
+
|
| 104 |
+
if v_rx == 0:
|
| 105 |
+
return 0.0
|
| 106 |
+
|
| 107 |
+
coverage = v_target_rx / v_target
|
| 108 |
+
conformity = v_target_rx / v_rx
|
| 109 |
+
|
| 110 |
+
return float(coverage * conformity)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def compute_paddick_conformity_index(
|
| 114 |
+
dose: Dose,
|
| 115 |
+
target: Structure,
|
| 116 |
+
prescription_dose: float
|
| 117 |
+
) -> float:
|
| 118 |
+
"""
|
| 119 |
+
Compute Paddick Conformity Index (CI_Paddick).
|
| 120 |
+
|
| 121 |
+
CI_Paddick = (V_target_rx)^2 / (V_target * V_rx)
|
| 122 |
+
|
| 123 |
+
This index is commonly used for radiosurgery and SBRT plans.
|
| 124 |
+
Ideal value is 1.0.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
dose: Dose distribution object
|
| 128 |
+
target: Target structure
|
| 129 |
+
prescription_dose: Prescription dose in Gy
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Paddick conformity index (0-1)
|
| 133 |
+
|
| 134 |
+
References:
|
| 135 |
+
Paddick, J Neurosurg 2000
|
| 136 |
+
|
| 137 |
+
Examples:
|
| 138 |
+
>>> # Often used for stereotactic radiosurgery
|
| 139 |
+
>>> ci_paddick = compute_paddick_conformity_index(dose, gtv, prescription_dose=18.0)
|
| 140 |
+
>>> print(f"Paddick CI: {ci_paddick:.3f}")
|
| 141 |
+
"""
|
| 142 |
+
target_dose_values = dose.get_dose_in_structure(target)
|
| 143 |
+
|
| 144 |
+
v_target = len(target_dose_values)
|
| 145 |
+
if v_target == 0:
|
| 146 |
+
return 0.0
|
| 147 |
+
|
| 148 |
+
v_target_rx = np.sum(target_dose_values >= prescription_dose)
|
| 149 |
+
v_rx = np.sum(dose.dose_array >= prescription_dose)
|
| 150 |
+
|
| 151 |
+
if v_rx == 0 or v_target == 0:
|
| 152 |
+
return 0.0
|
| 153 |
+
|
| 154 |
+
return float((v_target_rx ** 2) / (v_target * v_rx))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def compute_coverage(
|
| 158 |
+
dose: Dose,
|
| 159 |
+
target: Structure,
|
| 160 |
+
prescription_dose: float
|
| 161 |
+
) -> float:
|
| 162 |
+
"""
|
| 163 |
+
Compute target coverage.
|
| 164 |
+
|
| 165 |
+
Coverage = V_target_rx / V_target
|
| 166 |
+
|
| 167 |
+
Percentage of target volume receiving at least the prescription dose.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
dose: Dose distribution object
|
| 171 |
+
target: Target structure
|
| 172 |
+
prescription_dose: Prescription dose in Gy
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
Coverage as fraction (0-1) or percentage if multiplied by 100
|
| 176 |
+
|
| 177 |
+
Examples:
|
| 178 |
+
>>> coverage = compute_coverage(dose, ptv, prescription_dose=60.0)
|
| 179 |
+
>>> print(f"Target coverage: {coverage*100:.1f}%")
|
| 180 |
+
"""
|
| 181 |
+
target_dose_values = dose.get_dose_in_structure(target)
|
| 182 |
+
|
| 183 |
+
v_target = len(target_dose_values)
|
| 184 |
+
if v_target == 0:
|
| 185 |
+
return 0.0
|
| 186 |
+
|
| 187 |
+
v_target_rx = np.sum(target_dose_values >= prescription_dose)
|
| 188 |
+
|
| 189 |
+
return float(v_target_rx / v_target)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def compute_spillage(
|
| 193 |
+
dose: Dose,
|
| 194 |
+
target: Structure,
|
| 195 |
+
prescription_dose: float
|
| 196 |
+
) -> float:
|
| 197 |
+
"""
|
| 198 |
+
Compute dose spillage outside target.
|
| 199 |
+
|
| 200 |
+
Spillage = (V_rx - V_target_rx) / V_rx
|
| 201 |
+
|
| 202 |
+
Fraction of prescription isodose volume that is outside the target.
|
| 203 |
+
Lower values indicate better conformity.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
dose: Dose distribution object
|
| 207 |
+
target: Target structure
|
| 208 |
+
prescription_dose: Prescription dose in Gy
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Spillage as fraction (0-1)
|
| 212 |
+
|
| 213 |
+
Examples:
|
| 214 |
+
>>> spillage = compute_spillage(dose, ptv, prescription_dose=60.0)
|
| 215 |
+
>>> print(f"Dose spillage: {spillage*100:.1f}%")
|
| 216 |
+
"""
|
| 217 |
+
target_dose_values = dose.get_dose_in_structure(target)
|
| 218 |
+
v_target_rx = np.sum(target_dose_values >= prescription_dose)
|
| 219 |
+
v_rx = np.sum(dose.dose_array >= prescription_dose)
|
| 220 |
+
|
| 221 |
+
if v_rx == 0:
|
| 222 |
+
return 0.0
|
| 223 |
+
|
| 224 |
+
return float((v_rx - v_target_rx) / v_rx)
|
src/dosemetrics/metrics/dose_comparison.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dose distribution comparison metrics beyond DVH.
|
| 3 |
+
|
| 4 |
+
This module provides image-based metrics for comparing 3D dose distributions,
|
| 5 |
+
including SSIM, MSE, MAE, and other similarity measures.
|
| 6 |
+
|
| 7 |
+
Future Implementation TODOs:
|
| 8 |
+
- Structural Similarity Index (SSIM) for dose volumes
|
| 9 |
+
- Mean Squared Error (MSE) and variants
|
| 10 |
+
- Peak Signal-to-Noise Ratio (PSNR)
|
| 11 |
+
- Mutual Information
|
| 12 |
+
- Normalized Cross-Correlation
|
| 13 |
+
- Dose-volume histogram difference maps
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
from typing import Tuple, Dict, Optional
|
| 18 |
+
import warnings
|
| 19 |
+
from scipy import ndimage
|
| 20 |
+
from scipy.stats import entropy
|
| 21 |
+
from skimage.metrics import structural_similarity
|
| 22 |
+
|
| 23 |
+
from ..dose import Dose
|
| 24 |
+
from ..structures import Structure
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def compute_ssim(
|
| 28 |
+
dose1: Dose,
|
| 29 |
+
dose2: Dose,
|
| 30 |
+
structure: Optional[Structure] = None,
|
| 31 |
+
window_size: int = 11,
|
| 32 |
+
k1: float = 0.01,
|
| 33 |
+
k2: float = 0.03
|
| 34 |
+
) -> float:
|
| 35 |
+
"""
|
| 36 |
+
Compute Structural Similarity Index (SSIM) between two dose distributions.
|
| 37 |
+
|
| 38 |
+
SSIM is a perceptual metric that quantifies image quality degradation
|
| 39 |
+
based on luminance, contrast, and structure. Originally developed for
|
| 40 |
+
image comparison, it's applicable to dose distributions.
|
| 41 |
+
|
| 42 |
+
Parameters
|
| 43 |
+
----------
|
| 44 |
+
dose1 : Dose
|
| 45 |
+
Reference dose distribution.
|
| 46 |
+
dose2 : Dose
|
| 47 |
+
Comparison dose distribution.
|
| 48 |
+
structure : Structure, optional
|
| 49 |
+
If provided, compute SSIM only within structure volume.
|
| 50 |
+
If None, compute for entire dose grid.
|
| 51 |
+
window_size : int, optional
|
| 52 |
+
Size of sliding window for local SSIM computation (default: 11).
|
| 53 |
+
k1 : float, optional
|
| 54 |
+
Algorithm parameter (default: 0.01).
|
| 55 |
+
k2 : float, optional
|
| 56 |
+
Algorithm parameter (default: 0.03).
|
| 57 |
+
|
| 58 |
+
Returns
|
| 59 |
+
-------
|
| 60 |
+
ssim : float
|
| 61 |
+
Mean SSIM value (0-1, where 1 is perfect similarity).
|
| 62 |
+
|
| 63 |
+
Notes
|
| 64 |
+
-----
|
| 65 |
+
SSIM ranges from -1 to 1:
|
| 66 |
+
- 1: Perfect similarity
|
| 67 |
+
- 0: No structural similarity
|
| 68 |
+
- -1: Perfect anti-correlation
|
| 69 |
+
|
| 70 |
+
SSIM considers three components:
|
| 71 |
+
- Luminance: Compares mean intensities
|
| 72 |
+
- Contrast: Compares standard deviations
|
| 73 |
+
- Structure: Compares correlation
|
| 74 |
+
|
| 75 |
+
References
|
| 76 |
+
----------
|
| 77 |
+
- Wang Z, Bovik AC, Sheikh HR, Simoncelli EP. "Image quality assessment:
|
| 78 |
+
from error visibility to structural similarity." IEEE Trans Image Process.
|
| 79 |
+
2004;13(4):600-12.
|
| 80 |
+
|
| 81 |
+
Examples
|
| 82 |
+
--------
|
| 83 |
+
>>> ssim = compute_ssim(planned_dose, delivered_dose, ptv)
|
| 84 |
+
>>> print(f"Dose SSIM: {ssim:.3f}")
|
| 85 |
+
>>> if ssim > 0.95:
|
| 86 |
+
... print("Excellent agreement")
|
| 87 |
+
|
| 88 |
+
Raises
|
| 89 |
+
------
|
| 90 |
+
NotImplementedError
|
| 91 |
+
This function is a stub for future implementation.
|
| 92 |
+
ValueError
|
| 93 |
+
If dose distributions have incompatible geometry.
|
| 94 |
+
"""
|
| 95 |
+
# Get dose arrays
|
| 96 |
+
arr1 = dose1.dose_array
|
| 97 |
+
arr2 = dose2.dose_array
|
| 98 |
+
|
| 99 |
+
# Check shapes match
|
| 100 |
+
if arr1.shape != arr2.shape:
|
| 101 |
+
raise ValueError(f"Dose shapes must match: {arr1.shape} vs {arr2.shape}")
|
| 102 |
+
|
| 103 |
+
# Apply structure mask if provided
|
| 104 |
+
if structure is not None:
|
| 105 |
+
mask = structure.mask
|
| 106 |
+
# For 3D SSIM, we need to work with the full volume
|
| 107 |
+
# but we'll compute SSIM and then weight by the mask
|
| 108 |
+
arr1_masked = np.where(mask, arr1, 0)
|
| 109 |
+
arr2_masked = np.where(mask, arr2, 0)
|
| 110 |
+
else:
|
| 111 |
+
arr1_masked = arr1
|
| 112 |
+
arr2_masked = arr2
|
| 113 |
+
|
| 114 |
+
# Compute SSIM for 3D volume
|
| 115 |
+
# Use smaller window for medical images
|
| 116 |
+
win_size = min(window_size, min(arr1.shape) - 1)
|
| 117 |
+
if win_size % 2 == 0:
|
| 118 |
+
win_size -= 1 # Must be odd
|
| 119 |
+
win_size = max(3, win_size) # At least 3
|
| 120 |
+
|
| 121 |
+
data_range = max(np.max(arr1), np.max(arr2))
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
ssim_value = structural_similarity(
|
| 125 |
+
arr1_masked,
|
| 126 |
+
arr2_masked,
|
| 127 |
+
data_range=data_range,
|
| 128 |
+
win_size=win_size,
|
| 129 |
+
K1=k1,
|
| 130 |
+
K2=k2
|
| 131 |
+
)
|
| 132 |
+
except ValueError:
|
| 133 |
+
# If window size is too large, reduce it
|
| 134 |
+
win_size = 3
|
| 135 |
+
ssim_value = structural_similarity(
|
| 136 |
+
arr1_masked,
|
| 137 |
+
arr2_masked,
|
| 138 |
+
data_range=data_range,
|
| 139 |
+
win_size=win_size,
|
| 140 |
+
K1=k1,
|
| 141 |
+
K2=k2
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return float(ssim_value)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def compute_mse(
|
| 148 |
+
dose1: Dose,
|
| 149 |
+
dose2: Dose,
|
| 150 |
+
structure: Optional[Structure] = None
|
| 151 |
+
) -> float:
|
| 152 |
+
"""
|
| 153 |
+
Compute Mean Squared Error between two dose distributions.
|
| 154 |
+
|
| 155 |
+
Parameters
|
| 156 |
+
----------
|
| 157 |
+
dose1 : Dose
|
| 158 |
+
Reference dose.
|
| 159 |
+
dose2 : Dose
|
| 160 |
+
Comparison dose.
|
| 161 |
+
structure : Structure, optional
|
| 162 |
+
If provided, compute MSE only within structure.
|
| 163 |
+
|
| 164 |
+
Returns
|
| 165 |
+
-------
|
| 166 |
+
mse : float
|
| 167 |
+
Mean squared error in Gy^2.
|
| 168 |
+
|
| 169 |
+
Raises
|
| 170 |
+
------
|
| 171 |
+
ValueError
|
| 172 |
+
If dose distributions have incompatible shapes.
|
| 173 |
+
"""
|
| 174 |
+
# Get dose arrays
|
| 175 |
+
arr1 = dose1.dose_array
|
| 176 |
+
arr2 = dose2.dose_array
|
| 177 |
+
|
| 178 |
+
# Check shapes match
|
| 179 |
+
if arr1.shape != arr2.shape:
|
| 180 |
+
raise ValueError(f"Dose shapes must match: {arr1.shape} vs {arr2.shape}")
|
| 181 |
+
|
| 182 |
+
# Apply structure mask if provided
|
| 183 |
+
if structure is not None:
|
| 184 |
+
mask = structure.mask
|
| 185 |
+
arr1 = arr1[mask]
|
| 186 |
+
arr2 = arr2[mask]
|
| 187 |
+
|
| 188 |
+
# Compute MSE
|
| 189 |
+
mse = np.mean((arr1 - arr2) ** 2)
|
| 190 |
+
return float(mse)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def compute_mae(
|
| 194 |
+
dose1: Dose,
|
| 195 |
+
dose2: Dose,
|
| 196 |
+
structure: Optional[Structure] = None
|
| 197 |
+
) -> float:
|
| 198 |
+
"""
|
| 199 |
+
Compute Mean Absolute Error between two dose distributions.
|
| 200 |
+
|
| 201 |
+
Parameters
|
| 202 |
+
----------
|
| 203 |
+
dose1 : Dose
|
| 204 |
+
Reference dose.
|
| 205 |
+
dose2 : Dose
|
| 206 |
+
Comparison dose.
|
| 207 |
+
structure : Structure, optional
|
| 208 |
+
If provided, compute MAE only within structure.
|
| 209 |
+
|
| 210 |
+
Returns
|
| 211 |
+
-------
|
| 212 |
+
mae : float
|
| 213 |
+
Mean absolute error in Gy.
|
| 214 |
+
|
| 215 |
+
Notes
|
| 216 |
+
-----
|
| 217 |
+
MAE is often more interpretable than MSE for dose comparison as it's
|
| 218 |
+
in the same units as dose (Gy).
|
| 219 |
+
|
| 220 |
+
Raises
|
| 221 |
+
------
|
| 222 |
+
ValueError
|
| 223 |
+
If dose distributions have incompatible shapes.
|
| 224 |
+
"""
|
| 225 |
+
# Get dose arrays
|
| 226 |
+
arr1 = dose1.dose_array
|
| 227 |
+
arr2 = dose2.dose_array
|
| 228 |
+
|
| 229 |
+
# Check shapes match
|
| 230 |
+
if arr1.shape != arr2.shape:
|
| 231 |
+
raise ValueError(f"Dose shapes must match: {arr1.shape} vs {arr2.shape}")
|
| 232 |
+
|
| 233 |
+
# Apply structure mask if provided
|
| 234 |
+
if structure is not None:
|
| 235 |
+
mask = structure.mask
|
| 236 |
+
arr1 = arr1[mask]
|
| 237 |
+
arr2 = arr2[mask]
|
| 238 |
+
|
| 239 |
+
# Compute MAE
|
| 240 |
+
mae = np.mean(np.abs(arr1 - arr2))
|
| 241 |
+
return float(mae)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def compute_psnr(
|
| 245 |
+
dose1: Dose,
|
| 246 |
+
dose2: Dose,
|
| 247 |
+
structure: Optional[Structure] = None,
|
| 248 |
+
data_range: Optional[float] = None
|
| 249 |
+
) -> float:
|
| 250 |
+
"""
|
| 251 |
+
Compute Peak Signal-to-Noise Ratio between two dose distributions.
|
| 252 |
+
|
| 253 |
+
Parameters
|
| 254 |
+
----------
|
| 255 |
+
dose1 : Dose
|
| 256 |
+
Reference dose.
|
| 257 |
+
dose2 : Dose
|
| 258 |
+
Comparison dose.
|
| 259 |
+
structure : Structure, optional
|
| 260 |
+
If provided, compute PSNR only within structure.
|
| 261 |
+
data_range : float, optional
|
| 262 |
+
Data range (max - min). If None, computed from doses.
|
| 263 |
+
|
| 264 |
+
Returns
|
| 265 |
+
-------
|
| 266 |
+
psnr : float
|
| 267 |
+
Peak signal-to-noise ratio in dB.
|
| 268 |
+
|
| 269 |
+
Notes
|
| 270 |
+
-----
|
| 271 |
+
PSNR is defined as: PSNR = 10 * log10((MAX^2) / MSE)
|
| 272 |
+
Higher values indicate better similarity.
|
| 273 |
+
|
| 274 |
+
Raises
|
| 275 |
+
------
|
| 276 |
+
ValueError
|
| 277 |
+
If dose distributions have incompatible shapes or MSE is zero.
|
| 278 |
+
"""
|
| 279 |
+
# Compute MSE
|
| 280 |
+
mse = compute_mse(dose1, dose2, structure)
|
| 281 |
+
|
| 282 |
+
if mse == 0:
|
| 283 |
+
return float('inf') # Perfect match
|
| 284 |
+
|
| 285 |
+
# Determine data range
|
| 286 |
+
if data_range is None:
|
| 287 |
+
arr1 = dose1.dose_array
|
| 288 |
+
arr2 = dose2.dose_array
|
| 289 |
+
if structure is not None:
|
| 290 |
+
mask = structure.mask
|
| 291 |
+
arr1 = arr1[mask]
|
| 292 |
+
arr2 = arr2[mask]
|
| 293 |
+
data_range = max(np.max(arr1), np.max(arr2))
|
| 294 |
+
|
| 295 |
+
# Compute PSNR
|
| 296 |
+
psnr = 10 * np.log10((data_range ** 2) / mse)
|
| 297 |
+
return float(psnr)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def compute_mutual_information(
|
| 301 |
+
dose1: Dose,
|
| 302 |
+
dose2: Dose,
|
| 303 |
+
structure: Optional[Structure] = None,
|
| 304 |
+
bins: int = 256
|
| 305 |
+
) -> float:
|
| 306 |
+
"""
|
| 307 |
+
Compute Mutual Information between two dose distributions.
|
| 308 |
+
|
| 309 |
+
Parameters
|
| 310 |
+
----------
|
| 311 |
+
dose1 : Dose
|
| 312 |
+
First dose distribution.
|
| 313 |
+
dose2 : Dose
|
| 314 |
+
Second dose distribution.
|
| 315 |
+
structure : Structure, optional
|
| 316 |
+
If provided, compute MI only within structure.
|
| 317 |
+
bins : int, optional
|
| 318 |
+
Number of histogram bins (default: 256).
|
| 319 |
+
|
| 320 |
+
Returns
|
| 321 |
+
-------
|
| 322 |
+
mi : float
|
| 323 |
+
Mutual information value (higher indicates more similarity).
|
| 324 |
+
|
| 325 |
+
Notes
|
| 326 |
+
-----
|
| 327 |
+
Mutual Information quantifies the information shared between two
|
| 328 |
+
distributions. It's particularly useful for multimodal comparison.
|
| 329 |
+
|
| 330 |
+
Raises
|
| 331 |
+
------
|
| 332 |
+
ValueError
|
| 333 |
+
If dose distributions have incompatible shapes.
|
| 334 |
+
"""
|
| 335 |
+
# Get dose arrays
|
| 336 |
+
arr1 = dose1.dose_array.flatten()
|
| 337 |
+
arr2 = dose2.dose_array.flatten()
|
| 338 |
+
|
| 339 |
+
# Check shapes match
|
| 340 |
+
if arr1.shape != arr2.shape:
|
| 341 |
+
raise ValueError(f"Dose shapes must match")
|
| 342 |
+
|
| 343 |
+
# Apply structure mask if provided
|
| 344 |
+
if structure is not None:
|
| 345 |
+
mask = structure.mask.flatten()
|
| 346 |
+
arr1 = arr1[mask]
|
| 347 |
+
arr2 = arr2[mask]
|
| 348 |
+
|
| 349 |
+
# Compute 2D histogram
|
| 350 |
+
hist_2d, x_edges, y_edges = np.histogram2d(arr1, arr2, bins=bins)
|
| 351 |
+
|
| 352 |
+
# Add small epsilon to avoid log(0)
|
| 353 |
+
hist_2d = hist_2d + np.finfo(float).eps
|
| 354 |
+
|
| 355 |
+
# Normalize to get joint probability
|
| 356 |
+
pxy = hist_2d / np.sum(hist_2d)
|
| 357 |
+
|
| 358 |
+
# Compute marginal probabilities
|
| 359 |
+
px = np.sum(pxy, axis=1)
|
| 360 |
+
py = np.sum(pxy, axis=0)
|
| 361 |
+
|
| 362 |
+
# Compute mutual information
|
| 363 |
+
# MI = sum(p(x,y) * log(p(x,y) / (p(x) * p(y))))
|
| 364 |
+
px_py = px[:, None] * py[None, :]
|
| 365 |
+
|
| 366 |
+
# Only compute where both are non-zero
|
| 367 |
+
nonzero = (pxy > 0) & (px_py > 0)
|
| 368 |
+
mi = np.sum(pxy[nonzero] * np.log(pxy[nonzero] / px_py[nonzero]))
|
| 369 |
+
|
| 370 |
+
return float(mi)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def compute_normalized_cross_correlation(
|
| 374 |
+
dose1: Dose,
|
| 375 |
+
dose2: Dose,
|
| 376 |
+
structure: Optional[Structure] = None
|
| 377 |
+
) -> float:
|
| 378 |
+
"""
|
| 379 |
+
Compute Normalized Cross-Correlation between two dose distributions.
|
| 380 |
+
|
| 381 |
+
Parameters
|
| 382 |
+
----------
|
| 383 |
+
dose1 : Dose
|
| 384 |
+
First dose distribution.
|
| 385 |
+
dose2 : Dose
|
| 386 |
+
Second dose distribution.
|
| 387 |
+
structure : Structure, optional
|
| 388 |
+
If provided, compute NCC only within structure.
|
| 389 |
+
|
| 390 |
+
Returns
|
| 391 |
+
-------
|
| 392 |
+
ncc : float
|
| 393 |
+
Normalized cross-correlation (-1 to 1).
|
| 394 |
+
|
| 395 |
+
Notes
|
| 396 |
+
-----
|
| 397 |
+
NCC is Pearson correlation coefficient for images/volumes.
|
| 398 |
+
Values close to 1 indicate high positive correlation.
|
| 399 |
+
|
| 400 |
+
Raises
|
| 401 |
+
------
|
| 402 |
+
ValueError
|
| 403 |
+
If dose distributions have incompatible shapes.
|
| 404 |
+
"""
|
| 405 |
+
# Get dose arrays
|
| 406 |
+
arr1 = dose1.dose_array.flatten()
|
| 407 |
+
arr2 = dose2.dose_array.flatten()
|
| 408 |
+
|
| 409 |
+
# Check shapes match
|
| 410 |
+
if arr1.shape != arr2.shape:
|
| 411 |
+
raise ValueError(f"Dose shapes must match")
|
| 412 |
+
|
| 413 |
+
# Apply structure mask if provided
|
| 414 |
+
if structure is not None:
|
| 415 |
+
mask = structure.mask.flatten()
|
| 416 |
+
arr1 = arr1[mask]
|
| 417 |
+
arr2 = arr2[mask]
|
| 418 |
+
|
| 419 |
+
# Compute NCC (Pearson correlation)
|
| 420 |
+
# NCC = sum((x - mean_x) * (y - mean_y)) / (std_x * std_y * N)
|
| 421 |
+
mean1 = np.mean(arr1)
|
| 422 |
+
mean2 = np.mean(arr2)
|
| 423 |
+
|
| 424 |
+
numerator = np.sum((arr1 - mean1) * (arr2 - mean2))
|
| 425 |
+
denominator = np.sqrt(np.sum((arr1 - mean1) ** 2) * np.sum((arr2 - mean2) ** 2))
|
| 426 |
+
|
| 427 |
+
if denominator == 0:
|
| 428 |
+
return 0.0 # No variation in one or both images
|
| 429 |
+
|
| 430 |
+
ncc = numerator / denominator
|
| 431 |
+
return float(ncc)
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def compute_dose_difference_map(
|
| 435 |
+
dose1: Dose,
|
| 436 |
+
dose2: Dose,
|
| 437 |
+
absolute: bool = False
|
| 438 |
+
) -> Dose:
|
| 439 |
+
"""
|
| 440 |
+
Compute voxel-wise dose difference map.
|
| 441 |
+
|
| 442 |
+
Parameters
|
| 443 |
+
----------
|
| 444 |
+
dose1 : Dose
|
| 445 |
+
Reference dose.
|
| 446 |
+
dose2 : Dose
|
| 447 |
+
Comparison dose.
|
| 448 |
+
absolute : bool, optional
|
| 449 |
+
If True, return absolute differences (default: False).
|
| 450 |
+
|
| 451 |
+
Returns
|
| 452 |
+
-------
|
| 453 |
+
diff_dose : Dose
|
| 454 |
+
Dose object containing difference map.
|
| 455 |
+
|
| 456 |
+
Notes
|
| 457 |
+
-----
|
| 458 |
+
Useful for visualizing spatial dose discrepancies.
|
| 459 |
+
|
| 460 |
+
Raises
|
| 461 |
+
------
|
| 462 |
+
ValueError
|
| 463 |
+
If dose distributions have incompatible shapes.
|
| 464 |
+
"""
|
| 465 |
+
# Check shapes match
|
| 466 |
+
if dose1.dose_array.shape != dose2.dose_array.shape:
|
| 467 |
+
raise ValueError(
|
| 468 |
+
f"Dose shapes must match: {dose1.dose_array.shape} vs {dose2.dose_array.shape}"
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
# Compute difference
|
| 472 |
+
if absolute:
|
| 473 |
+
diff_grid = np.abs(dose1.dose_array - dose2.dose_array)
|
| 474 |
+
else:
|
| 475 |
+
diff_grid = dose1.dose_array - dose2.dose_array
|
| 476 |
+
|
| 477 |
+
# Create new Dose object with difference
|
| 478 |
+
diff_dose = Dose(
|
| 479 |
+
dose_array=diff_grid,
|
| 480 |
+
spacing=dose1.spacing,
|
| 481 |
+
origin=dose1.origin,
|
| 482 |
+
name=f"{dose1.name}_diff"
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
return diff_dose
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def compute_dose_comparison_metrics(
|
| 489 |
+
dose1: Dose,
|
| 490 |
+
dose2: Dose,
|
| 491 |
+
structure: Optional[Structure] = None
|
| 492 |
+
) -> Dict[str, float]:
|
| 493 |
+
"""
|
| 494 |
+
Compute comprehensive set of dose comparison metrics.
|
| 495 |
+
|
| 496 |
+
Parameters
|
| 497 |
+
----------
|
| 498 |
+
dose1 : Dose
|
| 499 |
+
Reference dose.
|
| 500 |
+
dose2 : Dose
|
| 501 |
+
Comparison dose.
|
| 502 |
+
structure : Structure, optional
|
| 503 |
+
If provided, compute metrics only within structure.
|
| 504 |
+
|
| 505 |
+
Returns
|
| 506 |
+
-------
|
| 507 |
+
metrics : dict
|
| 508 |
+
Dictionary containing:
|
| 509 |
+
- 'ssim': Structural similarity index
|
| 510 |
+
- 'mse': Mean squared error
|
| 511 |
+
- 'mae': Mean absolute error
|
| 512 |
+
- 'psnr': Peak signal-to-noise ratio
|
| 513 |
+
- 'ncc': Normalized cross-correlation
|
| 514 |
+
- 'mi': Mutual information
|
| 515 |
+
|
| 516 |
+
Examples
|
| 517 |
+
--------
|
| 518 |
+
>>> metrics = compute_dose_comparison_metrics(dose1, dose2, ptv)
|
| 519 |
+
>>> print(f"SSIM: {metrics['ssim']:.3f}")
|
| 520 |
+
>>> print(f"MAE: {metrics['mae']:.2f} Gy")
|
| 521 |
+
|
| 522 |
+
Raises
|
| 523 |
+
------
|
| 524 |
+
ValueError
|
| 525 |
+
If dose distributions have incompatible shapes.
|
| 526 |
+
"""
|
| 527 |
+
metrics = {}
|
| 528 |
+
|
| 529 |
+
try:
|
| 530 |
+
metrics['mse'] = compute_mse(dose1, dose2, structure)
|
| 531 |
+
except Exception as e:
|
| 532 |
+
warnings.warn(f"MSE computation failed: {e}")
|
| 533 |
+
metrics['mse'] = np.nan
|
| 534 |
+
|
| 535 |
+
try:
|
| 536 |
+
metrics['mae'] = compute_mae(dose1, dose2, structure)
|
| 537 |
+
except Exception as e:
|
| 538 |
+
warnings.warn(f"MAE computation failed: {e}")
|
| 539 |
+
metrics['mae'] = np.nan
|
| 540 |
+
|
| 541 |
+
try:
|
| 542 |
+
metrics['psnr'] = compute_psnr(dose1, dose2, structure)
|
| 543 |
+
except Exception as e:
|
| 544 |
+
warnings.warn(f"PSNR computation failed: {e}")
|
| 545 |
+
metrics['psnr'] = np.nan
|
| 546 |
+
|
| 547 |
+
try:
|
| 548 |
+
metrics['ssim'] = compute_ssim(dose1, dose2, structure)
|
| 549 |
+
except Exception as e:
|
| 550 |
+
warnings.warn(f"SSIM computation failed: {e}")
|
| 551 |
+
metrics['ssim'] = np.nan
|
| 552 |
+
|
| 553 |
+
try:
|
| 554 |
+
metrics['ncc'] = compute_normalized_cross_correlation(dose1, dose2, structure)
|
| 555 |
+
except Exception as e:
|
| 556 |
+
warnings.warn(f"NCC computation failed: {e}")
|
| 557 |
+
metrics['ncc'] = np.nan
|
| 558 |
+
|
| 559 |
+
try:
|
| 560 |
+
metrics['mi'] = compute_mutual_information(dose1, dose2, structure)
|
| 561 |
+
except Exception as e:
|
| 562 |
+
warnings.warn(f"MI computation failed: {e}")
|
| 563 |
+
metrics['mi'] = np.nan
|
| 564 |
+
|
| 565 |
+
return metrics
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def compute_3d_dose_gradient(
|
| 569 |
+
dose: Dose
|
| 570 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 571 |
+
"""
|
| 572 |
+
Compute 3D dose gradient (useful for dose falloff analysis).
|
| 573 |
+
|
| 574 |
+
Parameters
|
| 575 |
+
----------
|
| 576 |
+
dose : Dose
|
| 577 |
+
Dose distribution.
|
| 578 |
+
|
| 579 |
+
Returns
|
| 580 |
+
-------
|
| 581 |
+
grad_x : np.ndarray
|
| 582 |
+
Gradient in x direction.
|
| 583 |
+
grad_y : np.ndarray
|
| 584 |
+
Gradient in y direction.
|
| 585 |
+
grad_z : np.ndarray
|
| 586 |
+
Gradient in z direction.
|
| 587 |
+
|
| 588 |
+
Notes
|
| 589 |
+
-----
|
| 590 |
+
Uses numpy gradient function which computes central differences
|
| 591 |
+
in the interior and first differences at the boundaries.
|
| 592 |
+
|
| 593 |
+
The gradient is useful for analyzing dose falloff regions and
|
| 594 |
+
identifying high-gradient areas.
|
| 595 |
+
"""
|
| 596 |
+
dose_array = dose.dose_array
|
| 597 |
+
|
| 598 |
+
# Get voxel spacing from dose object
|
| 599 |
+
spacing = dose.spacing
|
| 600 |
+
|
| 601 |
+
# Compute gradients in each direction
|
| 602 |
+
# Note: numpy.gradient returns gradients in the order of axes
|
| 603 |
+
grad_z, grad_y, grad_x = np.gradient(dose_array, spacing[2], spacing[1], spacing[0])
|
| 604 |
+
|
| 605 |
+
return grad_x, grad_y, grad_z
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
__all__ = [
|
| 609 |
+
'compute_ssim',
|
| 610 |
+
'compute_mse',
|
| 611 |
+
'compute_mae',
|
| 612 |
+
'compute_psnr',
|
| 613 |
+
'compute_mutual_information',
|
| 614 |
+
'compute_normalized_cross_correlation',
|
| 615 |
+
'compute_dose_difference_map',
|
| 616 |
+
'compute_dose_comparison_metrics',
|
| 617 |
+
'compute_3d_dose_gradient',
|
| 618 |
+
]
|
src/dosemetrics/metrics/dvh.py
CHANGED
|
@@ -1,114 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
import pandas as pd
|
| 3 |
-
from numpy import ndarray
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
def mean_dose(_dose: np.ndarray, _struct_mask: np.ndarray):
|
| 7 |
-
dose_in_struct = _dose[_struct_mask > 0]
|
| 8 |
-
return np.mean(dose_in_struct)
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
def max_dose(_dose: np.ndarray, _struct_mask: np.ndarray):
|
| 12 |
-
dose_in_struct = _dose[_struct_mask > 0]
|
| 13 |
-
return np.max(dose_in_struct)
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
def volume(_struct_mask: np.ndarray, _vox_dims: tuple):
|
| 17 |
-
num_voxels = np.count_nonzero(_struct_mask)
|
| 18 |
-
return num_voxels * np.prod(_vox_dims) / 1000.0 # in centimeter cube.
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
def get_volumes(file_name):
|
| 22 |
-
volumes = {}
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
for
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dose-Volume Histogram (DVH) computation and analysis.
|
| 3 |
+
|
| 4 |
+
This module provides functions for computing DVHs and extracting DVH-based
|
| 5 |
+
metrics such as volume at dose (VX) and dose at volume (DX).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Tuple, Optional, Dict, TYPE_CHECKING
|
| 11 |
import numpy as np
|
| 12 |
import pandas as pd
|
|
|
|
| 13 |
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from ..dose import Dose
|
| 16 |
+
from ..structures import Structure
|
| 17 |
+
from ..structure_set import StructureSet
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
def compute_dvh(
|
| 21 |
+
dose: Dose,
|
| 22 |
+
structure: Structure,
|
| 23 |
+
max_dose: Optional[float] = None,
|
| 24 |
+
step_size: float = 0.1,
|
| 25 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 26 |
+
"""
|
| 27 |
+
Compute dose-volume histogram for a structure.
|
| 28 |
+
|
| 29 |
+
A DVH shows the percentage of structure volume that receives at least
|
| 30 |
+
a given dose level.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
dose: Dose distribution object
|
| 34 |
+
structure: Structure to compute DVH for
|
| 35 |
+
max_dose: Maximum dose for histogram bins (auto-detect if None)
|
| 36 |
+
step_size: Bin width in Gy
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Tuple of (dose_bins, volume_percentages)
|
| 40 |
+
- dose_bins: Array of dose levels (Gy)
|
| 41 |
+
- volume_percentages: Percentage of volume receiving >= each dose (0-100)
|
| 42 |
+
|
| 43 |
+
Examples:
|
| 44 |
+
>>> from dosemetrics.dose import Dose
|
| 45 |
+
>>> from dosemetrics.metrics import dvh
|
| 46 |
+
>>>
|
| 47 |
+
>>> dose = Dose.from_dicom("rtdose.dcm")
|
| 48 |
+
>>> ptv = structures.get_structure("PTV")
|
| 49 |
+
>>>
|
| 50 |
+
>>> dose_bins, volumes = dvh.compute_dvh(dose, ptv)
|
| 51 |
+
>>>
|
| 52 |
+
>>> # Plot DVH
|
| 53 |
+
>>> import matplotlib.pyplot as plt
|
| 54 |
+
>>> plt.plot(dose_bins, volumes)
|
| 55 |
+
>>> plt.xlabel("Dose (Gy)")
|
| 56 |
+
>>> plt.ylabel("Volume (%)")
|
| 57 |
+
"""
|
| 58 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 59 |
+
|
| 60 |
+
if len(dose_values) == 0:
|
| 61 |
+
bins = np.array([0.0])
|
| 62 |
+
volumes = np.array([0.0])
|
| 63 |
+
return bins, volumes
|
| 64 |
+
|
| 65 |
+
if max_dose is None:
|
| 66 |
+
max_dose = float(np.max(dose_values))
|
| 67 |
+
|
| 68 |
+
bins = np.arange(0, max_dose + step_size, step_size)
|
| 69 |
+
volumes = np.array([
|
| 70 |
+
100.0 * np.sum(dose_values >= dose_bin) / len(dose_values)
|
| 71 |
+
for dose_bin in bins
|
| 72 |
+
])
|
| 73 |
+
|
| 74 |
+
return bins, volumes
|
| 75 |
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
def compute_volume_at_dose(
|
| 78 |
+
dose: Dose,
|
| 79 |
+
structure: Structure,
|
| 80 |
+
dose_threshold: float
|
| 81 |
+
) -> float:
|
| 82 |
+
"""
|
| 83 |
+
Compute percentage of structure receiving at least the dose threshold.
|
| 84 |
+
|
| 85 |
+
This computes VX where X is the dose threshold (e.g., V20 = % volume >= 20 Gy).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
dose: Dose distribution object
|
| 89 |
+
structure: Structure to analyze
|
| 90 |
+
dose_threshold: Dose threshold in Gy
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
Percentage of volume (0-100) receiving >= dose_threshold
|
| 94 |
+
|
| 95 |
+
Examples:
|
| 96 |
+
>>> # V20: percentage of lung receiving >= 20 Gy
|
| 97 |
+
>>> v20 = compute_volume_at_dose(dose, lung, 20.0)
|
| 98 |
+
>>> print(f"V20: {v20:.1f}%")
|
| 99 |
+
>>>
|
| 100 |
+
>>> # V5: percentage of heart receiving >= 5 Gy
|
| 101 |
+
>>> v5 = compute_volume_at_dose(dose, heart, 5.0)
|
| 102 |
+
"""
|
| 103 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 104 |
+
|
| 105 |
+
if len(dose_values) == 0:
|
| 106 |
+
return 0.0
|
| 107 |
+
|
| 108 |
+
return float(100.0 * np.sum(dose_values >= dose_threshold) / len(dose_values))
|
| 109 |
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
def compute_dose_at_volume(
|
| 112 |
+
dose: Dose,
|
| 113 |
+
structure: Structure,
|
| 114 |
+
volume_percent: float
|
| 115 |
+
) -> float:
|
| 116 |
+
"""
|
| 117 |
+
Compute dose received by a given percentage of structure volume.
|
| 118 |
+
|
| 119 |
+
This computes DX where X is the volume percentage (e.g., D95 = dose to 95% of volume).
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
dose: Dose distribution object
|
| 123 |
+
structure: Structure to analyze
|
| 124 |
+
volume_percent: Volume percentage (0-100)
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Dose in Gy that the specified volume percentage receives
|
| 128 |
+
|
| 129 |
+
Raises:
|
| 130 |
+
ValueError: If volume_percent is not in range 0-100
|
| 131 |
+
|
| 132 |
+
Examples:
|
| 133 |
+
>>> # D95: dose covering 95% of PTV
|
| 134 |
+
>>> d95 = compute_dose_at_volume(dose, ptv, 95)
|
| 135 |
+
>>> print(f"D95: {d95:.2f} Gy")
|
| 136 |
+
>>>
|
| 137 |
+
>>> # D_0.1cc for OAR (requires volume in cc conversion)
|
| 138 |
+
>>> # For now, use percentile approximation
|
| 139 |
+
>>> d_max = compute_dose_at_volume(dose, brainstem, 0.1)
|
| 140 |
+
"""
|
| 141 |
+
if not 0 <= volume_percent <= 100:
|
| 142 |
+
raise ValueError(f"Volume percent must be 0-100, got {volume_percent}")
|
| 143 |
+
|
| 144 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 145 |
+
|
| 146 |
+
if len(dose_values) == 0:
|
| 147 |
+
return 0.0
|
| 148 |
+
|
| 149 |
+
# DX means X% of volume receives AT LEAST this dose
|
| 150 |
+
# This is the (100-X)th percentile of dose distribution
|
| 151 |
+
percentile = 100 - volume_percent
|
| 152 |
+
return float(np.percentile(dose_values, percentile))
|
| 153 |
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
def compute_dose_at_volume_cc(
|
| 156 |
+
dose: Dose,
|
| 157 |
+
structure: Structure,
|
| 158 |
+
volume_cc: float
|
| 159 |
+
) -> float:
|
| 160 |
+
"""
|
| 161 |
+
Compute dose received by a given absolute volume in cc.
|
| 162 |
+
|
| 163 |
+
This computes D_Xcc (e.g., D_0.1cc = dose to hottest 0.1 cc).
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
dose: Dose distribution object
|
| 167 |
+
structure: Structure to analyze
|
| 168 |
+
volume_cc: Absolute volume in cubic centimeters
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
Dose in Gy received by the specified volume
|
| 172 |
+
|
| 173 |
+
Examples:
|
| 174 |
+
>>> # D_0.1cc: dose to hottest 0.1 cc (common OAR metric)
|
| 175 |
+
>>> d_0_1cc = compute_dose_at_volume_cc(dose, brainstem, 0.1)
|
| 176 |
+
>>> print(f"D_0.1cc: {d_0_1cc:.2f} Gy")
|
| 177 |
+
"""
|
| 178 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 179 |
+
|
| 180 |
+
if len(dose_values) == 0:
|
| 181 |
+
return 0.0
|
| 182 |
+
|
| 183 |
+
# Convert cc to number of voxels
|
| 184 |
+
voxel_volume_cc = np.prod(structure.spacing) / 1000.0 # mm³ to cc
|
| 185 |
+
num_voxels = int(np.round(volume_cc / voxel_volume_cc))
|
| 186 |
+
|
| 187 |
+
if num_voxels >= len(dose_values):
|
| 188 |
+
# Requested volume exceeds structure volume
|
| 189 |
+
return float(np.min(dose_values))
|
| 190 |
+
|
| 191 |
+
if num_voxels <= 0:
|
| 192 |
+
return float(np.max(dose_values))
|
| 193 |
+
|
| 194 |
+
# Sort dose values in descending order and take the dose at num_voxels
|
| 195 |
+
sorted_doses = np.sort(dose_values)[::-1]
|
| 196 |
+
return float(sorted_doses[num_voxels - 1])
|
| 197 |
|
| 198 |
|
| 199 |
+
def compute_equivalent_uniform_dose(
|
| 200 |
+
dose: Dose,
|
| 201 |
+
structure: Structure,
|
| 202 |
+
a_parameter: float
|
| 203 |
+
) -> float:
|
| 204 |
+
"""
|
| 205 |
+
Compute Equivalent Uniform Dose (EUD).
|
| 206 |
+
|
| 207 |
+
EUD = (mean(D_i^a))^(1/a)
|
| 208 |
+
|
| 209 |
+
The a-parameter depends on tissue type:
|
| 210 |
+
- a < 0 for tumors (emphasizes cold spots)
|
| 211 |
+
- a > 0 for normal tissues (emphasizes hot spots)
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
dose: Dose distribution object
|
| 215 |
+
structure: Structure to analyze
|
| 216 |
+
a_parameter: Tissue-specific parameter
|
| 217 |
+
|
| 218 |
+
Returns:
|
| 219 |
+
Equivalent uniform dose in Gy
|
| 220 |
+
|
| 221 |
+
References:
|
| 222 |
+
Niemierko, Med Phys 1997
|
| 223 |
+
|
| 224 |
+
Examples:
|
| 225 |
+
>>> # For tumor (emphasize underdosage)
|
| 226 |
+
>>> eud_tumor = compute_equivalent_uniform_dose(dose, ptv, a_parameter=-10)
|
| 227 |
+
>>>
|
| 228 |
+
>>> # For OAR (emphasize overdosage)
|
| 229 |
+
>>> eud_oar = compute_equivalent_uniform_dose(dose, brainstem, a_parameter=5)
|
| 230 |
+
"""
|
| 231 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 232 |
+
|
| 233 |
+
if len(dose_values) == 0:
|
| 234 |
+
return 0.0
|
| 235 |
+
|
| 236 |
+
if a_parameter == 0:
|
| 237 |
+
# Limit case: geometric mean
|
| 238 |
+
return float(np.exp(np.mean(np.log(dose_values + 1e-10))))
|
| 239 |
+
|
| 240 |
+
powered_doses = np.power(dose_values, a_parameter)
|
| 241 |
+
mean_powered = np.mean(powered_doses)
|
| 242 |
+
eud = np.power(mean_powered, 1.0 / a_parameter)
|
| 243 |
+
|
| 244 |
+
return float(eud)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def create_dvh_table(
|
| 248 |
+
dose: Dose,
|
| 249 |
+
structure_set: StructureSet,
|
| 250 |
+
structure_names: Optional[list] = None,
|
| 251 |
+
max_dose: Optional[float] = None,
|
| 252 |
+
step_size: float = 0.1
|
| 253 |
+
) -> pd.DataFrame:
|
| 254 |
+
"""
|
| 255 |
+
Create DVH table for multiple structures in long format.
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
dose: Dose distribution object
|
| 259 |
+
structure_set: StructureSet containing structures
|
| 260 |
+
structure_names: List of structure names to include (optional)
|
| 261 |
+
max_dose: Maximum dose for bins
|
| 262 |
+
step_size: Dose bin width in Gy
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
DataFrame with columns [Dose, Structure, Volume]
|
| 266 |
+
|
| 267 |
+
Examples:
|
| 268 |
+
>>> dvh_df = create_dvh_table(dose, structures,
|
| 269 |
+
... structure_names=["PTV", "Brainstem", "SpinalCord"])
|
| 270 |
+
>>> dvh_df.to_csv("dvh_data.csv")
|
| 271 |
+
"""
|
| 272 |
+
if structure_names is None:
|
| 273 |
+
structure_names = structure_set.structure_names
|
| 274 |
+
|
| 275 |
+
dvh_data = []
|
| 276 |
+
|
| 277 |
+
for name in structure_names:
|
| 278 |
+
try:
|
| 279 |
+
structure = structure_set.get_structure(name)
|
| 280 |
+
dose_bins, volumes = compute_dvh(dose, structure, max_dose, step_size)
|
| 281 |
+
|
| 282 |
+
for dose_val, vol_val in zip(dose_bins, volumes):
|
| 283 |
+
dvh_data.append({
|
| 284 |
+
'Dose': dose_val,
|
| 285 |
+
'Structure': name,
|
| 286 |
+
'Volume': vol_val
|
| 287 |
+
})
|
| 288 |
+
except ValueError:
|
| 289 |
+
# Structure not found
|
| 290 |
+
continue
|
| 291 |
+
|
| 292 |
+
return pd.DataFrame(dvh_data)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def extract_dvh_metrics(
|
| 296 |
+
dose: Dose,
|
| 297 |
+
structure: Structure,
|
| 298 |
+
dose_thresholds: Optional[list] = None,
|
| 299 |
+
volume_percentages: Optional[list] = None
|
| 300 |
+
) -> Dict[str, float]:
|
| 301 |
+
"""
|
| 302 |
+
Extract common DVH metrics for a structure.
|
| 303 |
+
|
| 304 |
+
Args:
|
| 305 |
+
dose: Dose distribution object
|
| 306 |
+
structure: Structure to analyze
|
| 307 |
+
dose_thresholds: List of dose levels for VX metrics (Gy)
|
| 308 |
+
volume_percentages: List of volume percentages for DX metrics
|
| 309 |
+
|
| 310 |
+
Returns:
|
| 311 |
+
Dictionary with DVH metrics
|
| 312 |
+
|
| 313 |
+
Examples:
|
| 314 |
+
>>> metrics = extract_dvh_metrics(
|
| 315 |
+
... dose, ptv,
|
| 316 |
+
... dose_thresholds=[20, 40, 60],
|
| 317 |
+
... volume_percentages=[2, 50, 95, 98]
|
| 318 |
+
... )
|
| 319 |
+
>>> print(metrics)
|
| 320 |
+
{'V20': 98.5, 'V40': 97.2, 'V60': 95.8, 'D2': 63.5, 'D50': 60.2, ...}
|
| 321 |
+
"""
|
| 322 |
+
metrics = {}
|
| 323 |
+
|
| 324 |
+
# Volume at dose metrics (VX)
|
| 325 |
+
if dose_thresholds:
|
| 326 |
+
for threshold in dose_thresholds:
|
| 327 |
+
v_x = compute_volume_at_dose(dose, structure, threshold)
|
| 328 |
+
metrics[f'V{threshold}'] = v_x
|
| 329 |
+
|
| 330 |
+
# Dose at volume metrics (DX)
|
| 331 |
+
if volume_percentages:
|
| 332 |
+
for vol_pct in volume_percentages:
|
| 333 |
+
d_x = compute_dose_at_volume(dose, structure, vol_pct)
|
| 334 |
+
metrics[f'D{vol_pct}'] = d_x
|
| 335 |
+
|
| 336 |
+
return metrics
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# Dose statistics functions (formerly in statistics.py)
|
| 340 |
+
|
| 341 |
+
def compute_dose_statistics(dose: Dose, structure: Structure) -> Dict[str, float]:
|
| 342 |
+
"""
|
| 343 |
+
Compute comprehensive dose statistics for a structure.
|
| 344 |
+
|
| 345 |
+
Args:
|
| 346 |
+
dose: Dose distribution object
|
| 347 |
+
structure: Structure to analyze
|
| 348 |
+
|
| 349 |
+
Returns:
|
| 350 |
+
Dictionary with statistics including:
|
| 351 |
+
- mean_dose, max_dose, min_dose, median_dose, std_dose
|
| 352 |
+
- D95, D50, D05, D02, D98 (dose percentiles)
|
| 353 |
+
|
| 354 |
+
Examples:
|
| 355 |
+
>>> from dosemetrics.dose import Dose
|
| 356 |
+
>>> from dosemetrics.structure_set import StructureSet
|
| 357 |
+
>>> from dosemetrics.metrics import dvh
|
| 358 |
+
>>>
|
| 359 |
+
>>> dose = Dose.from_dicom("rtdose.dcm")
|
| 360 |
+
>>> structures = StructureSet(...)
|
| 361 |
+
>>> ptv = structures.get_structure("PTV")
|
| 362 |
+
>>>
|
| 363 |
+
>>> stats = dvh.compute_dose_statistics(dose, ptv)
|
| 364 |
+
>>> print(f"Mean dose: {stats['mean_dose']:.2f} Gy")
|
| 365 |
+
>>> print(f"D95: {stats['D95']:.2f} Gy")
|
| 366 |
+
"""
|
| 367 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 368 |
+
|
| 369 |
+
if len(dose_values) == 0:
|
| 370 |
+
return {
|
| 371 |
+
'mean_dose': 0.0,
|
| 372 |
+
'max_dose': 0.0,
|
| 373 |
+
'min_dose': 0.0,
|
| 374 |
+
'median_dose': 0.0,
|
| 375 |
+
'std_dose': 0.0,
|
| 376 |
+
'D95': 0.0,
|
| 377 |
+
'D50': 0.0,
|
| 378 |
+
'D05': 0.0,
|
| 379 |
+
'D02': 0.0,
|
| 380 |
+
'D98': 0.0,
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
return {
|
| 384 |
+
'mean_dose': float(np.mean(dose_values)),
|
| 385 |
+
'max_dose': float(np.max(dose_values)),
|
| 386 |
+
'min_dose': float(np.min(dose_values)),
|
| 387 |
+
'median_dose': float(np.median(dose_values)),
|
| 388 |
+
'std_dose': float(np.std(dose_values)),
|
| 389 |
+
'D95': float(np.percentile(dose_values, 5)), # 95% receives at least this
|
| 390 |
+
'D50': float(np.percentile(dose_values, 50)),
|
| 391 |
+
'D05': float(np.percentile(dose_values, 95)), # 5% receives at least this
|
| 392 |
+
'D02': float(np.percentile(dose_values, 98)), # 2% receives at least this
|
| 393 |
+
'D98': float(np.percentile(dose_values, 2)), # 98% receives at least this
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def compute_mean_dose(dose: Dose, structure: Structure) -> float:
|
| 398 |
+
"""
|
| 399 |
+
Compute mean dose in structure.
|
| 400 |
+
|
| 401 |
+
Args:
|
| 402 |
+
dose: Dose distribution object
|
| 403 |
+
structure: Structure to analyze
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
Mean dose in Gy
|
| 407 |
+
"""
|
| 408 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 409 |
+
return float(np.mean(dose_values)) if len(dose_values) > 0 else 0.0
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def compute_max_dose(dose: Dose, structure: Structure) -> float:
|
| 413 |
+
"""
|
| 414 |
+
Compute maximum dose in structure.
|
| 415 |
+
|
| 416 |
+
Args:
|
| 417 |
+
dose: Dose distribution object
|
| 418 |
+
structure: Structure to analyze
|
| 419 |
+
|
| 420 |
+
Returns:
|
| 421 |
+
Maximum dose in Gy
|
| 422 |
+
"""
|
| 423 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 424 |
+
return float(np.max(dose_values)) if len(dose_values) > 0 else 0.0
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def compute_min_dose(dose: Dose, structure: Structure) -> float:
|
| 428 |
+
"""
|
| 429 |
+
Compute minimum dose in structure.
|
| 430 |
+
|
| 431 |
+
Args:
|
| 432 |
+
dose: Dose distribution object
|
| 433 |
+
structure: Structure to analyze
|
| 434 |
+
|
| 435 |
+
Returns:
|
| 436 |
+
Minimum dose in Gy
|
| 437 |
+
"""
|
| 438 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 439 |
+
return float(np.min(dose_values)) if len(dose_values) > 0 else 0.0
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def compute_median_dose(dose: Dose, structure: Structure) -> float:
|
| 443 |
+
"""
|
| 444 |
+
Compute median dose in structure.
|
| 445 |
+
|
| 446 |
+
Args:
|
| 447 |
+
dose: Dose distribution object
|
| 448 |
+
structure: Structure to analyze
|
| 449 |
+
|
| 450 |
+
Returns:
|
| 451 |
+
Median dose in Gy
|
| 452 |
+
"""
|
| 453 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 454 |
+
return float(np.median(dose_values)) if len(dose_values) > 0 else 0.0
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def compute_dose_percentile(
|
| 458 |
+
dose: Dose,
|
| 459 |
+
structure: Structure,
|
| 460 |
+
percentile: float
|
| 461 |
+
) -> float:
|
| 462 |
+
"""
|
| 463 |
+
Compute dose percentile (DX).
|
| 464 |
+
|
| 465 |
+
D95 means 95% of the volume receives at least this dose.
|
| 466 |
+
This corresponds to the 5th percentile of the dose distribution.
|
| 467 |
+
|
| 468 |
+
Args:
|
| 469 |
+
dose: Dose distribution object
|
| 470 |
+
structure: Structure to analyze
|
| 471 |
+
percentile: Volume percentage (0-100). For D95, use percentile=95
|
| 472 |
+
|
| 473 |
+
Returns:
|
| 474 |
+
Dose in Gy that the specified percentage of volume receives
|
| 475 |
+
|
| 476 |
+
Raises:
|
| 477 |
+
ValueError: If percentile is not in range 0-100
|
| 478 |
+
|
| 479 |
+
Examples:
|
| 480 |
+
>>> # D95: dose received by 95% of volume
|
| 481 |
+
>>> d95 = compute_dose_percentile(dose, ptv, 95)
|
| 482 |
+
>>>
|
| 483 |
+
>>> # D50: median dose
|
| 484 |
+
>>> d50 = compute_dose_percentile(dose, ptv, 50)
|
| 485 |
+
>>>
|
| 486 |
+
>>> # D05: near-maximum dose (hot spot)
|
| 487 |
+
>>> d05 = compute_dose_percentile(dose, ptv, 5)
|
| 488 |
+
"""
|
| 489 |
+
if not 0 <= percentile <= 100:
|
| 490 |
+
raise ValueError(f"Percentile must be 0-100, got {percentile}")
|
| 491 |
+
|
| 492 |
+
dose_values = dose.get_dose_in_structure(structure)
|
| 493 |
+
|
| 494 |
+
if len(dose_values) == 0:
|
| 495 |
+
return 0.0
|
| 496 |
+
|
| 497 |
+
# DX means X% receives AT LEAST this dose
|
| 498 |
+
# This is the (100-X)th percentile of the dose array
|
| 499 |
+
return float(np.percentile(dose_values, 100 - percentile))
|
src/dosemetrics/metrics/gamma.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gamma analysis for dose distribution comparison.
|
| 3 |
+
|
| 4 |
+
This module provides gamma index calculation following the methodology of
|
| 5 |
+
Low et al. (1998) and subsequent refinements.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
- Low DA, Harms WB, Mutic S, Purdy JA. "A technique for the quantitative
|
| 9 |
+
evaluation of dose distributions." Med Phys. 1998;25(5):656-61.
|
| 10 |
+
- Depuydt T, Van Esch A, Huyskens DP. "A quantitative evaluation of IMRT
|
| 11 |
+
dose distributions: refinement and clinical assessment of the gamma
|
| 12 |
+
evaluation." Radiother Oncol. 2002;62(3):309-19.
|
| 13 |
+
|
| 14 |
+
Future Implementation TODOs:
|
| 15 |
+
- Global vs. local gamma normalization
|
| 16 |
+
- 2D and 3D gamma analysis
|
| 17 |
+
- GPU-accelerated computation
|
| 18 |
+
- Passing rate statistics
|
| 19 |
+
- Gamma histograms and visualization
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from typing import Tuple, Optional, Dict, Any
|
| 24 |
+
import warnings
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from pymedphys import gamma as pymedphys_gamma
|
| 28 |
+
|
| 29 |
+
PYMEDPHYS_AVAILABLE = True
|
| 30 |
+
except (ImportError, ModuleNotFoundError, FileNotFoundError) as e:
|
| 31 |
+
PYMEDPHYS_AVAILABLE = False
|
| 32 |
+
# Silently fail - we'll handle this gracefully in the functions that use it
|
| 33 |
+
|
| 34 |
+
from ..dose import Dose
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def compute_gamma_index(
|
| 38 |
+
dose_reference: Dose,
|
| 39 |
+
dose_evaluated: Dose,
|
| 40 |
+
dose_criterion_percent: float = 3.0,
|
| 41 |
+
distance_criterion_mm: float = 3.0,
|
| 42 |
+
dose_threshold_percent: float = 10.0,
|
| 43 |
+
global_normalization: bool = True,
|
| 44 |
+
max_search_distance_mm: Optional[float] = None,
|
| 45 |
+
) -> np.ndarray:
|
| 46 |
+
"""
|
| 47 |
+
Compute 3D gamma index between reference and evaluated dose distributions.
|
| 48 |
+
|
| 49 |
+
The gamma index quantifies the agreement between two dose distributions by
|
| 50 |
+
combining dose difference and distance-to-agreement criteria.
|
| 51 |
+
|
| 52 |
+
Parameters
|
| 53 |
+
----------
|
| 54 |
+
dose_reference : Dose
|
| 55 |
+
Reference (planned) dose distribution.
|
| 56 |
+
dose_evaluated : Dose
|
| 57 |
+
Evaluated (measured/calculated) dose distribution to compare.
|
| 58 |
+
dose_criterion_percent : float, optional
|
| 59 |
+
Dose difference criterion as percentage (default: 3.0 for 3%).
|
| 60 |
+
distance_criterion_mm : float, optional
|
| 61 |
+
Distance-to-agreement criterion in mm (default: 3.0 for 3mm).
|
| 62 |
+
dose_threshold_percent : float, optional
|
| 63 |
+
Low dose threshold below which gamma is not calculated (default: 10%).
|
| 64 |
+
global_normalization : bool, optional
|
| 65 |
+
If True, normalize to global maximum dose. If False, use local dose
|
| 66 |
+
(default: True).
|
| 67 |
+
max_search_distance_mm : float, optional
|
| 68 |
+
Maximum search distance for gamma calculation. If None, uses
|
| 69 |
+
3 * distance_criterion_mm (default: None).
|
| 70 |
+
|
| 71 |
+
Returns
|
| 72 |
+
-------
|
| 73 |
+
gamma : np.ndarray
|
| 74 |
+
3D array of gamma values. Values < 1 indicate passing points,
|
| 75 |
+
values >= 1 indicate failing points. NaN for points below threshold.
|
| 76 |
+
|
| 77 |
+
Notes
|
| 78 |
+
-----
|
| 79 |
+
Common gamma criteria:
|
| 80 |
+
- Clinical QA: 3%/3mm (dose_criterion=3.0, distance_criterion=3.0)
|
| 81 |
+
- Stricter QA: 2%/2mm
|
| 82 |
+
- Research: 1%/1mm
|
| 83 |
+
|
| 84 |
+
The gamma passing rate is typically calculated as the percentage of
|
| 85 |
+
points with gamma <= 1.0.
|
| 86 |
+
|
| 87 |
+
Examples
|
| 88 |
+
--------
|
| 89 |
+
>>> gamma = compute_gamma_index(planned_dose, measured_dose)
|
| 90 |
+
>>> passing_rate = np.sum(gamma <= 1.0) / np.sum(~np.isnan(gamma)) * 100
|
| 91 |
+
>>> print(f"Gamma passing rate: {passing_rate:.1f}%")
|
| 92 |
+
|
| 93 |
+
Raises
|
| 94 |
+
------
|
| 95 |
+
NotImplementedError
|
| 96 |
+
This function is a stub for future implementation.
|
| 97 |
+
ValueError
|
| 98 |
+
If dose distributions have incompatible geometry.
|
| 99 |
+
"""
|
| 100 |
+
if not PYMEDPHYS_AVAILABLE:
|
| 101 |
+
raise ImportError(
|
| 102 |
+
"pymedphys is required for gamma analysis. "
|
| 103 |
+
"Install with: pip install pymedphys"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Validate spatial compatibility
|
| 107 |
+
if dose_reference.dose_array.shape != dose_evaluated.dose_array.shape:
|
| 108 |
+
raise ValueError(
|
| 109 |
+
f"Dose shapes must match: {dose_reference.dose_array.shape} vs "
|
| 110 |
+
f"{dose_evaluated.dose_array.shape}"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Get dose arrays
|
| 114 |
+
ref_dose = dose_reference.dose_array
|
| 115 |
+
eval_dose = dose_evaluated.dose_array
|
| 116 |
+
|
| 117 |
+
# Get coordinate arrays from dose properties
|
| 118 |
+
origin = dose_reference.origin
|
| 119 |
+
spacing = dose_reference.spacing
|
| 120 |
+
shape = dose_reference.shape
|
| 121 |
+
|
| 122 |
+
axes = [origin[i] + np.arange(shape[i]) * spacing[i] for i in range(3)]
|
| 123 |
+
|
| 124 |
+
# Determine normalization
|
| 125 |
+
if global_normalization:
|
| 126 |
+
dose_ref_value = np.max(ref_dose)
|
| 127 |
+
else:
|
| 128 |
+
dose_ref_value = None # pymedphys will use local normalization
|
| 129 |
+
|
| 130 |
+
# Calculate dose threshold
|
| 131 |
+
dose_threshold = dose_threshold_percent / 100.0 * np.max(ref_dose)
|
| 132 |
+
|
| 133 |
+
# Set max search distance
|
| 134 |
+
if max_search_distance_mm is None:
|
| 135 |
+
max_search_distance_mm = 3 * distance_criterion_mm
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
# Use pymedphys gamma function
|
| 139 |
+
# Note: pymedphys expects (axes_reference, dose_reference, axes_evaluation, dose_evaluation, ...)
|
| 140 |
+
# where axes can be a tuple of coordinate arrays
|
| 141 |
+
gamma_result = pymedphys_gamma(
|
| 142 |
+
(axes[0], axes[1], axes[2]), # reference axes (x, y, z)
|
| 143 |
+
ref_dose, # reference dose
|
| 144 |
+
(axes[0], axes[1], axes[2]), # evaluation axes (x, y, z)
|
| 145 |
+
eval_dose, # evaluation dose
|
| 146 |
+
dose_criterion_percent,
|
| 147 |
+
distance_criterion_mm,
|
| 148 |
+
lower_percent_dose_cutoff=dose_threshold_percent,
|
| 149 |
+
interp_fraction=10, # interpolation factor
|
| 150 |
+
max_gamma=2.0, # cap gamma at 2 for performance
|
| 151 |
+
local_gamma=not global_normalization,
|
| 152 |
+
global_normalisation=dose_ref_value if global_normalization else None,
|
| 153 |
+
quiet=True,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
return gamma_result
|
| 157 |
+
|
| 158 |
+
except FileNotFoundError as e:
|
| 159 |
+
# pymedphys has missing dependency files issue - raise with clear message
|
| 160 |
+
raise RuntimeError(
|
| 161 |
+
f"pymedphys has an environment issue: {e}. "
|
| 162 |
+
"This is a known issue with certain Python environments. "
|
| 163 |
+
"Consider using a compatible pymedphys installation."
|
| 164 |
+
) from e
|
| 165 |
+
except Exception as e:
|
| 166 |
+
warnings.warn(f"Gamma calculation failed: {e}")
|
| 167 |
+
raise
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def compute_gamma_passing_rate(gamma: np.ndarray, threshold: float = 1.0) -> float:
|
| 171 |
+
"""
|
| 172 |
+
Compute gamma passing rate from gamma index array.
|
| 173 |
+
|
| 174 |
+
Parameters
|
| 175 |
+
----------
|
| 176 |
+
gamma : np.ndarray
|
| 177 |
+
Gamma index values from compute_gamma_index().
|
| 178 |
+
threshold : float, optional
|
| 179 |
+
Gamma threshold for passing (default: 1.0).
|
| 180 |
+
|
| 181 |
+
Returns
|
| 182 |
+
-------
|
| 183 |
+
passing_rate : float
|
| 184 |
+
Percentage of points with gamma <= threshold (0-100).
|
| 185 |
+
"""
|
| 186 |
+
# Remove NaN values (below threshold points)
|
| 187 |
+
valid_gamma = gamma[~np.isnan(gamma)]
|
| 188 |
+
|
| 189 |
+
if len(valid_gamma) == 0:
|
| 190 |
+
return 0.0
|
| 191 |
+
|
| 192 |
+
# Calculate passing rate
|
| 193 |
+
passing = np.sum(valid_gamma <= threshold)
|
| 194 |
+
total = len(valid_gamma)
|
| 195 |
+
passing_rate = (passing / total) * 100.0
|
| 196 |
+
|
| 197 |
+
return float(passing_rate)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def compute_gamma_statistics(gamma: np.ndarray) -> Dict[str, float]:
|
| 201 |
+
"""
|
| 202 |
+
Compute comprehensive statistics from gamma index array.
|
| 203 |
+
|
| 204 |
+
Parameters
|
| 205 |
+
----------
|
| 206 |
+
gamma : np.ndarray
|
| 207 |
+
Gamma index values.
|
| 208 |
+
|
| 209 |
+
Returns
|
| 210 |
+
-------
|
| 211 |
+
stats : dict
|
| 212 |
+
Dictionary containing:
|
| 213 |
+
- 'passing_rate_1_0': Passing rate at gamma=1.0
|
| 214 |
+
- 'mean_gamma': Mean gamma value
|
| 215 |
+
- 'max_gamma': Maximum gamma value
|
| 216 |
+
- 'gamma_50': Median gamma value
|
| 217 |
+
- 'gamma_95': 95th percentile gamma
|
| 218 |
+
"""
|
| 219 |
+
# Remove NaN values
|
| 220 |
+
valid_gamma = gamma[~np.isnan(gamma)]
|
| 221 |
+
|
| 222 |
+
if len(valid_gamma) == 0:
|
| 223 |
+
return {
|
| 224 |
+
"passing_rate_1_0": 0.0,
|
| 225 |
+
"mean_gamma": np.nan,
|
| 226 |
+
"max_gamma": np.nan,
|
| 227 |
+
"gamma_50": np.nan,
|
| 228 |
+
"gamma_95": np.nan,
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
stats = {
|
| 232 |
+
"passing_rate_1_0": compute_gamma_passing_rate(gamma, threshold=1.0),
|
| 233 |
+
"mean_gamma": float(np.mean(valid_gamma)),
|
| 234 |
+
"max_gamma": float(np.max(valid_gamma)),
|
| 235 |
+
"gamma_50": float(np.percentile(valid_gamma, 50)),
|
| 236 |
+
"gamma_95": float(np.percentile(valid_gamma, 95)),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
return stats
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def compute_2d_gamma(
|
| 243 |
+
dose_reference_slice: np.ndarray,
|
| 244 |
+
dose_evaluated_slice: np.ndarray,
|
| 245 |
+
dose_criterion_percent: float = 3.0,
|
| 246 |
+
distance_criterion_mm: float = 3.0,
|
| 247 |
+
pixel_spacing: Tuple[float, float] = (1.0, 1.0),
|
| 248 |
+
) -> np.ndarray:
|
| 249 |
+
"""
|
| 250 |
+
Compute 2D gamma index for a single slice (faster than 3D).
|
| 251 |
+
|
| 252 |
+
Parameters
|
| 253 |
+
----------
|
| 254 |
+
dose_reference_slice : np.ndarray
|
| 255 |
+
2D reference dose slice.
|
| 256 |
+
dose_evaluated_slice : np.ndarray
|
| 257 |
+
2D evaluated dose slice.
|
| 258 |
+
dose_criterion_percent : float
|
| 259 |
+
Dose criterion (%).
|
| 260 |
+
distance_criterion_mm : float
|
| 261 |
+
Distance criterion (mm).
|
| 262 |
+
pixel_spacing : tuple of float
|
| 263 |
+
Pixel spacing in mm (row_spacing, col_spacing).
|
| 264 |
+
|
| 265 |
+
Returns
|
| 266 |
+
-------
|
| 267 |
+
gamma : np.ndarray
|
| 268 |
+
2D gamma index array.
|
| 269 |
+
|
| 270 |
+
Raises
|
| 271 |
+
------
|
| 272 |
+
ImportError
|
| 273 |
+
If pymedphys is not available.
|
| 274 |
+
"""
|
| 275 |
+
if not PYMEDPHYS_AVAILABLE:
|
| 276 |
+
raise ImportError(
|
| 277 |
+
"pymedphys is required for gamma analysis. "
|
| 278 |
+
"Install with: pip install pymedphys"
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
# Create coordinate arrays
|
| 282 |
+
rows = np.arange(dose_reference_slice.shape[0]) * pixel_spacing[0]
|
| 283 |
+
cols = np.arange(dose_reference_slice.shape[1]) * pixel_spacing[1]
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
# pymedphys expects (axes_reference, dose_reference, axes_evaluation, dose_evaluation, ...)
|
| 287 |
+
# For 2D, pass as tuple of 2D coordinate arrays
|
| 288 |
+
gamma_result = pymedphys_gamma(
|
| 289 |
+
(rows, cols), # reference axes
|
| 290 |
+
dose_reference_slice, # reference dose
|
| 291 |
+
(rows, cols), # evaluation axes
|
| 292 |
+
dose_evaluated_slice, # evaluation dose
|
| 293 |
+
dose_criterion_percent,
|
| 294 |
+
distance_criterion_mm,
|
| 295 |
+
quiet=True,
|
| 296 |
+
)
|
| 297 |
+
return gamma_result
|
| 298 |
+
except FileNotFoundError as e:
|
| 299 |
+
# pymedphys has missing dependency files issue - raise with clear message
|
| 300 |
+
raise RuntimeError(
|
| 301 |
+
f"pymedphys has an environment issue: {e}. "
|
| 302 |
+
"This is a known issue with certain Python environments. "
|
| 303 |
+
"Consider using a compatible pymedphys installation."
|
| 304 |
+
) from e
|
| 305 |
+
except Exception as e:
|
| 306 |
+
warnings.warn(f"2D Gamma calculation failed: {e}")
|
| 307 |
+
raise
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# Placeholder for GPU-accelerated gamma
|
| 311 |
+
def compute_gamma_index_gpu(
|
| 312 |
+
dose_reference: Dose,
|
| 313 |
+
dose_evaluated: Dose,
|
| 314 |
+
dose_criterion_percent: float = 3.0,
|
| 315 |
+
distance_criterion_mm: float = 3.0,
|
| 316 |
+
) -> np.ndarray:
|
| 317 |
+
"""
|
| 318 |
+
GPU-accelerated gamma index calculation (requires CuPy or similar).
|
| 319 |
+
|
| 320 |
+
Note: This is a placeholder. For GPU acceleration, use pymedphys
|
| 321 |
+
with GPU backend or implement using CuPy.
|
| 322 |
+
|
| 323 |
+
Parameters
|
| 324 |
+
----------
|
| 325 |
+
dose_reference : Dose
|
| 326 |
+
Reference dose.
|
| 327 |
+
dose_evaluated : Dose
|
| 328 |
+
Evaluated dose.
|
| 329 |
+
dose_criterion_percent : float
|
| 330 |
+
Dose criterion (%).
|
| 331 |
+
distance_criterion_mm : float
|
| 332 |
+
Distance criterion (mm).
|
| 333 |
+
|
| 334 |
+
Returns
|
| 335 |
+
-------
|
| 336 |
+
gamma : np.ndarray
|
| 337 |
+
Gamma index array.
|
| 338 |
+
|
| 339 |
+
Raises
|
| 340 |
+
------
|
| 341 |
+
NotImplementedError
|
| 342 |
+
GPU acceleration not implemented. Use pymedphys with GPU backend.
|
| 343 |
+
"""
|
| 344 |
+
warnings.warn(
|
| 345 |
+
"GPU-accelerated gamma is not implemented. "
|
| 346 |
+
"Use compute_gamma_index() which leverages pymedphys, "
|
| 347 |
+
"or configure pymedphys with GPU backend for acceleration.",
|
| 348 |
+
FutureWarning,
|
| 349 |
+
)
|
| 350 |
+
raise NotImplementedError(
|
| 351 |
+
"GPU-accelerated gamma not implemented. "
|
| 352 |
+
"Use compute_gamma_index() or configure pymedphys with GPU backend."
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
__all__ = [
|
| 357 |
+
"compute_gamma_index",
|
| 358 |
+
"compute_gamma_passing_rate",
|
| 359 |
+
"compute_gamma_statistics",
|
| 360 |
+
"compute_2d_gamma",
|
| 361 |
+
"compute_gamma_index_gpu",
|
| 362 |
+
]
|
src/dosemetrics/metrics/geometric.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Geometric similarity and overlap metrics for structure comparison.
|
| 3 |
+
|
| 4 |
+
This module provides metrics to compare two structure sets, typically used
|
| 5 |
+
for evaluating auto-segmentation algorithms or inter-observer variability.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from typing import Dict, Optional, TYPE_CHECKING
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from scipy.spatial.distance import directed_hausdorff
|
| 14 |
+
from scipy import ndimage
|
| 15 |
+
|
| 16 |
+
if TYPE_CHECKING:
|
| 17 |
+
from ..structures import Structure
|
| 18 |
+
from ..structure_set import StructureSet
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compute_dice_coefficient(structure1: Structure, structure2: Structure) -> float:
|
| 22 |
+
"""
|
| 23 |
+
Compute Dice coefficient (Sørensen-Dice index).
|
| 24 |
+
|
| 25 |
+
Dice = 2 * |A ∩ B| / (|A| + |B|)
|
| 26 |
+
|
| 27 |
+
Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
structure1: First structure
|
| 31 |
+
structure2: Second structure
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Dice coefficient (0-1)
|
| 35 |
+
|
| 36 |
+
References:
|
| 37 |
+
Dice, Ecology 1945; Sørensen, Biologiske Skrifter 1948
|
| 38 |
+
|
| 39 |
+
Examples:
|
| 40 |
+
>>> auto_ptv = structures_auto.get_structure("PTV")
|
| 41 |
+
>>> manual_ptv = structures_manual.get_structure("PTV")
|
| 42 |
+
>>> dice = compute_dice_coefficient(auto_ptv, manual_ptv)
|
| 43 |
+
>>> print(f"Dice: {dice:.3f}")
|
| 44 |
+
"""
|
| 45 |
+
if structure1.mask is None or structure2.mask is None:
|
| 46 |
+
return 0.0
|
| 47 |
+
|
| 48 |
+
intersection = np.logical_and(structure1.mask, structure2.mask)
|
| 49 |
+
sum_volumes = structure1.volume_voxels() + structure2.volume_voxels()
|
| 50 |
+
|
| 51 |
+
if sum_volumes == 0:
|
| 52 |
+
return 0.0
|
| 53 |
+
|
| 54 |
+
return float(2.0 * np.sum(intersection) / sum_volumes)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def compute_jaccard_index(structure1: Structure, structure2: Structure) -> float:
|
| 58 |
+
"""
|
| 59 |
+
Compute Jaccard index (Intersection over Union, IoU).
|
| 60 |
+
|
| 61 |
+
Jaccard = |A ∩ B| / |A ∪ B|
|
| 62 |
+
|
| 63 |
+
Measures overlap between two structures. Range [0, 1], where 1 is perfect overlap.
|
| 64 |
+
More conservative than Dice coefficient.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
structure1: First structure
|
| 68 |
+
structure2: Second structure
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Jaccard index (0-1)
|
| 72 |
+
|
| 73 |
+
References:
|
| 74 |
+
Jaccard, New Phytologist 1912
|
| 75 |
+
|
| 76 |
+
Examples:
|
| 77 |
+
>>> jaccard = compute_jaccard_index(auto_ptv, manual_ptv)
|
| 78 |
+
>>> print(f"IoU: {jaccard:.3f}")
|
| 79 |
+
"""
|
| 80 |
+
if structure1.mask is None or structure2.mask is None:
|
| 81 |
+
return 0.0
|
| 82 |
+
|
| 83 |
+
intersection = np.logical_and(structure1.mask, structure2.mask)
|
| 84 |
+
union = np.logical_or(structure1.mask, structure2.mask)
|
| 85 |
+
|
| 86 |
+
union_sum = np.sum(union)
|
| 87 |
+
if union_sum == 0:
|
| 88 |
+
return 0.0
|
| 89 |
+
|
| 90 |
+
return float(np.sum(intersection) / union_sum)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def compute_volume_difference(structure1: Structure, structure2: Structure) -> float:
|
| 94 |
+
"""
|
| 95 |
+
Compute absolute volume difference.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
structure1: First structure
|
| 99 |
+
structure2: Second structure
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Absolute volume difference in cubic centimeters
|
| 103 |
+
|
| 104 |
+
Examples:
|
| 105 |
+
>>> vol_diff = compute_volume_difference(auto_ptv, manual_ptv)
|
| 106 |
+
>>> print(f"Volume difference: {vol_diff:.2f} cc")
|
| 107 |
+
"""
|
| 108 |
+
return abs(structure1.volume_cc() - structure2.volume_cc())
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def compute_volume_ratio(structure1: Structure, structure2: Structure) -> float:
|
| 112 |
+
"""
|
| 113 |
+
Compute volume ratio V1/V2.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
structure1: First structure (numerator)
|
| 117 |
+
structure2: Second structure (denominator)
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Volume ratio (dimensionless)
|
| 121 |
+
|
| 122 |
+
Examples:
|
| 123 |
+
>>> ratio = compute_volume_ratio(auto_ptv, manual_ptv)
|
| 124 |
+
>>> print(f"Volume ratio: {ratio:.3f}")
|
| 125 |
+
"""
|
| 126 |
+
v2 = structure2.volume_cc()
|
| 127 |
+
if v2 == 0:
|
| 128 |
+
return float('inf') if structure1.volume_cc() > 0 else 1.0
|
| 129 |
+
|
| 130 |
+
return structure1.volume_cc() / v2
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def compute_sensitivity(structure1: Structure, structure2: Structure) -> float:
|
| 134 |
+
"""
|
| 135 |
+
Compute sensitivity (recall, true positive rate).
|
| 136 |
+
|
| 137 |
+
Sensitivity = TP / (TP + FN) = |A ∩ B| / |B|
|
| 138 |
+
|
| 139 |
+
Measures how much of structure2 is covered by structure1.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
structure1: Predicted/test structure
|
| 143 |
+
structure2: Reference/ground truth structure
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Sensitivity (0-1)
|
| 147 |
+
|
| 148 |
+
Examples:
|
| 149 |
+
>>> sens = compute_sensitivity(auto_structure, manual_structure)
|
| 150 |
+
>>> print(f"Sensitivity: {sens:.3f}")
|
| 151 |
+
"""
|
| 152 |
+
if structure1.mask is None or structure2.mask is None:
|
| 153 |
+
return 0.0
|
| 154 |
+
|
| 155 |
+
intersection = np.logical_and(structure1.mask, structure2.mask)
|
| 156 |
+
v2 = structure2.volume_voxels()
|
| 157 |
+
|
| 158 |
+
if v2 == 0:
|
| 159 |
+
return 0.0
|
| 160 |
+
|
| 161 |
+
return float(np.sum(intersection) / v2)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def compute_specificity(
|
| 165 |
+
structure1: Structure,
|
| 166 |
+
structure2: Structure,
|
| 167 |
+
background_mask: Optional[np.ndarray] = None
|
| 168 |
+
) -> float:
|
| 169 |
+
"""
|
| 170 |
+
Compute specificity (true negative rate).
|
| 171 |
+
|
| 172 |
+
Specificity = TN / (TN + FP)
|
| 173 |
+
|
| 174 |
+
Requires definition of background/universe. If not provided, uses
|
| 175 |
+
the bounding box union of both structures.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
structure1: Predicted/test structure
|
| 179 |
+
structure2: Reference/ground truth structure
|
| 180 |
+
background_mask: Mask defining the universe (optional)
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
Specificity (0-1)
|
| 184 |
+
|
| 185 |
+
Examples:
|
| 186 |
+
>>> spec = compute_specificity(auto_structure, manual_structure)
|
| 187 |
+
>>> print(f"Specificity: {spec:.3f}")
|
| 188 |
+
"""
|
| 189 |
+
if structure1.mask is None or structure2.mask is None:
|
| 190 |
+
return 0.0
|
| 191 |
+
|
| 192 |
+
# True negatives: voxels outside both structures
|
| 193 |
+
# False positives: in structure1 but not in structure2
|
| 194 |
+
not_s1 = ~structure1.mask
|
| 195 |
+
not_s2 = ~structure2.mask
|
| 196 |
+
|
| 197 |
+
true_negatives = np.logical_and(not_s1, not_s2)
|
| 198 |
+
false_positives = np.logical_and(structure1.mask, not_s2)
|
| 199 |
+
|
| 200 |
+
denominator = np.sum(true_negatives) + np.sum(false_positives)
|
| 201 |
+
|
| 202 |
+
if denominator == 0:
|
| 203 |
+
return 0.0
|
| 204 |
+
|
| 205 |
+
return float(np.sum(true_negatives) / denominator)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def compute_hausdorff_distance(
|
| 209 |
+
structure1: Structure,
|
| 210 |
+
structure2: Structure,
|
| 211 |
+
percentile: Optional[float] = None
|
| 212 |
+
) -> float:
|
| 213 |
+
"""
|
| 214 |
+
Compute Hausdorff distance between two structures.
|
| 215 |
+
|
| 216 |
+
If percentile is specified, computes the percentile Hausdorff distance
|
| 217 |
+
(e.g., 95th percentile HD95), which is more robust to outliers.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
structure1: First structure
|
| 221 |
+
structure2: Second structure
|
| 222 |
+
percentile: If specified, compute percentile HD (e.g., 95 for HD95)
|
| 223 |
+
|
| 224 |
+
Returns:
|
| 225 |
+
Hausdorff distance in mm
|
| 226 |
+
|
| 227 |
+
Examples:
|
| 228 |
+
>>> hd = compute_hausdorff_distance(auto_structure, manual_structure)
|
| 229 |
+
>>> hd95 = compute_hausdorff_distance(auto_structure, manual_structure, percentile=95)
|
| 230 |
+
"""
|
| 231 |
+
# Validate percentile
|
| 232 |
+
if percentile is not None:
|
| 233 |
+
if not (0 < percentile <= 100):
|
| 234 |
+
raise ValueError(f"Percentile must be between 0 and 100, got {percentile}")
|
| 235 |
+
|
| 236 |
+
if structure1.mask is None or structure2.mask is None:
|
| 237 |
+
return float('inf')
|
| 238 |
+
|
| 239 |
+
# Get surface points (boundary voxels)
|
| 240 |
+
# Use binary erosion to get boundary
|
| 241 |
+
eroded1 = ndimage.binary_erosion(structure1.mask)
|
| 242 |
+
eroded2 = ndimage.binary_erosion(structure2.mask)
|
| 243 |
+
surface1 = structure1.mask & ~eroded1
|
| 244 |
+
surface2 = structure2.mask & ~eroded2
|
| 245 |
+
|
| 246 |
+
# Get coordinates of surface points
|
| 247 |
+
points1 = np.argwhere(surface1)
|
| 248 |
+
points2 = np.argwhere(surface2)
|
| 249 |
+
|
| 250 |
+
if len(points1) == 0 or len(points2) == 0:
|
| 251 |
+
return float('inf')
|
| 252 |
+
|
| 253 |
+
# Scale by voxel spacing to get mm
|
| 254 |
+
spacing = np.array(structure1.spacing)
|
| 255 |
+
points1_mm = points1 * spacing
|
| 256 |
+
points2_mm = points2 * spacing
|
| 257 |
+
|
| 258 |
+
if percentile is not None:
|
| 259 |
+
# Compute percentile Hausdorff distance
|
| 260 |
+
# Calculate distances from points1 to points2
|
| 261 |
+
from scipy.spatial.distance import cdist
|
| 262 |
+
distances = cdist(points1_mm, points2_mm)
|
| 263 |
+
|
| 264 |
+
# For each point in set 1, find min distance to set 2
|
| 265 |
+
min_distances_1_to_2 = np.min(distances, axis=1)
|
| 266 |
+
# For each point in set 2, find min distance to set 1
|
| 267 |
+
min_distances_2_to_1 = np.min(distances, axis=0)
|
| 268 |
+
|
| 269 |
+
# Compute percentile
|
| 270 |
+
hd_1_to_2 = np.percentile(min_distances_1_to_2, percentile)
|
| 271 |
+
hd_2_to_1 = np.percentile(min_distances_2_to_1, percentile)
|
| 272 |
+
|
| 273 |
+
return float(max(hd_1_to_2, hd_2_to_1))
|
| 274 |
+
else:
|
| 275 |
+
# Standard Hausdorff distance
|
| 276 |
+
hd_1_to_2, _, _ = directed_hausdorff(points1_mm, points2_mm)
|
| 277 |
+
hd_2_to_1, _, _ = directed_hausdorff(points2_mm, points1_mm)
|
| 278 |
+
|
| 279 |
+
return float(max(hd_1_to_2, hd_2_to_1))
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def compute_mean_surface_distance(
|
| 283 |
+
structure1: Structure,
|
| 284 |
+
structure2: Structure
|
| 285 |
+
) -> float:
|
| 286 |
+
"""
|
| 287 |
+
Compute mean surface distance between two structures.
|
| 288 |
+
|
| 289 |
+
Average of all point-to-surface distances (symmetric).
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
structure1: First structure
|
| 293 |
+
structure2: Second structure
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
Mean surface distance in mm
|
| 297 |
+
"""
|
| 298 |
+
if structure1.mask is None or structure2.mask is None:
|
| 299 |
+
return float('inf')
|
| 300 |
+
|
| 301 |
+
# Get surface points (boundary voxels)
|
| 302 |
+
eroded1 = ndimage.binary_erosion(structure1.mask)
|
| 303 |
+
eroded2 = ndimage.binary_erosion(structure2.mask)
|
| 304 |
+
surface1 = structure1.mask & ~eroded1
|
| 305 |
+
surface2 = structure2.mask & ~eroded2
|
| 306 |
+
|
| 307 |
+
# Get coordinates of surface points
|
| 308 |
+
points1 = np.argwhere(surface1)
|
| 309 |
+
points2 = np.argwhere(surface2)
|
| 310 |
+
|
| 311 |
+
if len(points1) == 0 or len(points2) == 0:
|
| 312 |
+
return float('inf')
|
| 313 |
+
|
| 314 |
+
# Scale by voxel spacing to get mm
|
| 315 |
+
spacing = np.array(structure1.spacing)
|
| 316 |
+
points1_mm = points1 * spacing
|
| 317 |
+
points2_mm = points2 * spacing
|
| 318 |
+
|
| 319 |
+
# Compute pairwise distances
|
| 320 |
+
from scipy.spatial.distance import cdist
|
| 321 |
+
distances = cdist(points1_mm, points2_mm)
|
| 322 |
+
|
| 323 |
+
# Mean of minimum distances from each point to other surface
|
| 324 |
+
mean_1_to_2 = np.mean(np.min(distances, axis=1))
|
| 325 |
+
mean_2_to_1 = np.mean(np.min(distances, axis=0))
|
| 326 |
+
|
| 327 |
+
# Return symmetric average
|
| 328 |
+
return float((mean_1_to_2 + mean_2_to_1) / 2.0)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def compare_structure_sets(
|
| 332 |
+
structure_set1: StructureSet,
|
| 333 |
+
structure_set2: StructureSet,
|
| 334 |
+
structure_names: Optional[list] = None
|
| 335 |
+
) -> pd.DataFrame:
|
| 336 |
+
"""
|
| 337 |
+
Compute geometric metrics between two structure sets.
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
structure_set1: First structure set (e.g., auto-segmentation)
|
| 341 |
+
structure_set2: Second structure set (e.g., manual segmentation)
|
| 342 |
+
structure_names: List of structure names to compare (optional)
|
| 343 |
+
|
| 344 |
+
Returns:
|
| 345 |
+
DataFrame with geometric metrics for each structure
|
| 346 |
+
|
| 347 |
+
Examples:
|
| 348 |
+
>>> auto_structures = load_structure_set("auto/")
|
| 349 |
+
>>> manual_structures = load_structure_set("manual/")
|
| 350 |
+
>>> comparison = compare_structure_sets(auto_structures, manual_structures)
|
| 351 |
+
>>> print(comparison)
|
| 352 |
+
"""
|
| 353 |
+
if structure_names is None:
|
| 354 |
+
# Use common structures
|
| 355 |
+
names1 = set(structure_set1.structure_names)
|
| 356 |
+
names2 = set(structure_set2.structure_names)
|
| 357 |
+
structure_names = list(names1.intersection(names2))
|
| 358 |
+
|
| 359 |
+
results = []
|
| 360 |
+
|
| 361 |
+
for name in structure_names:
|
| 362 |
+
try:
|
| 363 |
+
struct1 = structure_set1.get_structure(name)
|
| 364 |
+
struct2 = structure_set2.get_structure(name)
|
| 365 |
+
|
| 366 |
+
dice = compute_dice_coefficient(struct1, struct2)
|
| 367 |
+
jaccard = compute_jaccard_index(struct1, struct2)
|
| 368 |
+
vol_diff = compute_volume_difference(struct1, struct2)
|
| 369 |
+
vol_ratio = compute_volume_ratio(struct1, struct2)
|
| 370 |
+
sensitivity = compute_sensitivity(struct1, struct2)
|
| 371 |
+
|
| 372 |
+
results.append({
|
| 373 |
+
'Structure': name,
|
| 374 |
+
'Dice': dice,
|
| 375 |
+
'Jaccard': jaccard,
|
| 376 |
+
'Volume_Difference_cc': vol_diff,
|
| 377 |
+
'Volume_Ratio': vol_ratio,
|
| 378 |
+
'Sensitivity': sensitivity,
|
| 379 |
+
})
|
| 380 |
+
except ValueError:
|
| 381 |
+
# Structure not found in one of the sets
|
| 382 |
+
continue
|
| 383 |
+
|
| 384 |
+
return pd.DataFrame(results)
|
src/dosemetrics/metrics/homogeneity.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Homogeneity indices for target dose uniformity.
|
| 3 |
+
|
| 4 |
+
This module provides metrics to assess the uniformity of dose distribution
|
| 5 |
+
within target volumes. More homogeneous dose distributions are generally
|
| 6 |
+
preferred for tumor control.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from ..dose import Dose
|
| 16 |
+
from ..structures import Structure
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def compute_homogeneity_index(
|
| 20 |
+
dose: Dose,
|
| 21 |
+
target: Structure,
|
| 22 |
+
d2_percentile: float = 2.0,
|
| 23 |
+
d98_percentile: float = 98.0
|
| 24 |
+
) -> float:
|
| 25 |
+
"""
|
| 26 |
+
Compute Homogeneity Index (HI).
|
| 27 |
+
|
| 28 |
+
HI = (D2 - D98) / D50
|
| 29 |
+
|
| 30 |
+
Where:
|
| 31 |
+
- D2 = dose received by 2% of volume (near-maximum)
|
| 32 |
+
- D98 = dose received by 98% of volume (near-minimum)
|
| 33 |
+
- D50 = median dose
|
| 34 |
+
|
| 35 |
+
Measures dose uniformity within target. Lower values indicate more
|
| 36 |
+
homogeneous dose distribution.
|
| 37 |
+
|
| 38 |
+
Typical acceptable range: 0.05 - 0.20
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
dose: Dose distribution object
|
| 42 |
+
target: Target structure (PTV, CTV, etc.)
|
| 43 |
+
d2_percentile: Upper percentile for near-max (typically 2%)
|
| 44 |
+
d98_percentile: Lower percentile for near-min (typically 98%)
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Homogeneity index (dimensionless)
|
| 48 |
+
|
| 49 |
+
References:
|
| 50 |
+
ICRU Report 83 (2010)
|
| 51 |
+
|
| 52 |
+
Examples:
|
| 53 |
+
>>> hi = compute_homogeneity_index(dose, ptv)
|
| 54 |
+
>>> print(f"Homogeneity Index: {hi:.3f}")
|
| 55 |
+
>>> if hi < 0.15:
|
| 56 |
+
... print("Excellent dose homogeneity")
|
| 57 |
+
"""
|
| 58 |
+
dose_values = dose.get_dose_in_structure(target)
|
| 59 |
+
|
| 60 |
+
if len(dose_values) == 0:
|
| 61 |
+
return 0.0
|
| 62 |
+
|
| 63 |
+
# Note: D2 means 2% of volume receives at least this dose
|
| 64 |
+
# This corresponds to 98th percentile of dose array
|
| 65 |
+
d2 = np.percentile(dose_values, 100 - d2_percentile)
|
| 66 |
+
d98 = np.percentile(dose_values, 100 - d98_percentile)
|
| 67 |
+
d50 = np.percentile(dose_values, 50)
|
| 68 |
+
|
| 69 |
+
if d50 == 0:
|
| 70 |
+
return float('inf')
|
| 71 |
+
|
| 72 |
+
return float((d2 - d98) / d50)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def compute_gradient_index(
|
| 76 |
+
dose: Dose,
|
| 77 |
+
target: Structure,
|
| 78 |
+
prescription_dose: float,
|
| 79 |
+
half_prescription_volume_method: bool = True
|
| 80 |
+
) -> float:
|
| 81 |
+
"""
|
| 82 |
+
Compute Gradient Index (GI) for dose fall-off outside target.
|
| 83 |
+
|
| 84 |
+
Two calculation methods:
|
| 85 |
+
1. Half-prescription volume: GI = V_50% / V_100%
|
| 86 |
+
2. Distance-based: Ratio of volumes at specific distances
|
| 87 |
+
|
| 88 |
+
Where:
|
| 89 |
+
- V_100% = volume receiving >= prescription dose
|
| 90 |
+
- V_50% = volume receiving >= 50% prescription dose
|
| 91 |
+
|
| 92 |
+
Lower values indicate steeper dose fall-off (better for sparing OARs).
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
dose: Dose distribution object
|
| 96 |
+
target: Target structure
|
| 97 |
+
prescription_dose: Prescription dose in Gy
|
| 98 |
+
half_prescription_volume_method: Use V_50%/V_100% method (default True)
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Gradient index (dimensionless, typically 2-8)
|
| 102 |
+
|
| 103 |
+
References:
|
| 104 |
+
Paddick and Lippitz, J Neurosurg 2006
|
| 105 |
+
|
| 106 |
+
Examples:
|
| 107 |
+
>>> gi = compute_gradient_index(dose, ptv, prescription_dose=60.0)
|
| 108 |
+
>>> print(f"Gradient Index: {gi:.2f}")
|
| 109 |
+
>>> if gi < 3.0:
|
| 110 |
+
... print("Excellent dose fall-off")
|
| 111 |
+
"""
|
| 112 |
+
v_100 = np.sum(dose.dose_array >= prescription_dose)
|
| 113 |
+
v_50 = np.sum(dose.dose_array >= 0.5 * prescription_dose)
|
| 114 |
+
|
| 115 |
+
if v_100 == 0:
|
| 116 |
+
return float('inf')
|
| 117 |
+
|
| 118 |
+
return float(v_50 / v_100)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def compute_dose_homogeneity(
|
| 122 |
+
dose: Dose,
|
| 123 |
+
target: Structure
|
| 124 |
+
) -> float:
|
| 125 |
+
"""
|
| 126 |
+
Compute coefficient of variation (CV) of dose within target.
|
| 127 |
+
|
| 128 |
+
CV = std_dose / mean_dose
|
| 129 |
+
|
| 130 |
+
Alternative measure of dose homogeneity. Lower values indicate
|
| 131 |
+
more uniform dose distribution.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
dose: Dose distribution object
|
| 135 |
+
target: Target structure
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Coefficient of variation (dimensionless)
|
| 139 |
+
|
| 140 |
+
Examples:
|
| 141 |
+
>>> cv = compute_dose_homogeneity(dose, ptv)
|
| 142 |
+
>>> print(f"Dose CV: {cv:.3f}")
|
| 143 |
+
"""
|
| 144 |
+
dose_values = dose.get_dose_in_structure(target)
|
| 145 |
+
|
| 146 |
+
if len(dose_values) == 0:
|
| 147 |
+
return 0.0
|
| 148 |
+
|
| 149 |
+
mean = np.mean(dose_values)
|
| 150 |
+
if mean == 0:
|
| 151 |
+
return float('inf')
|
| 152 |
+
|
| 153 |
+
std = np.std(dose_values)
|
| 154 |
+
return float(std / mean)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def compute_uniformity_index(
|
| 158 |
+
dose: Dose,
|
| 159 |
+
target: Structure
|
| 160 |
+
) -> float:
|
| 161 |
+
"""
|
| 162 |
+
Compute uniformity index.
|
| 163 |
+
|
| 164 |
+
UI = 1 - (D_max - D_min) / D_prescription
|
| 165 |
+
|
| 166 |
+
Values closer to 1.0 indicate better uniformity.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
dose: Dose distribution object
|
| 170 |
+
target: Target structure
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Uniformity index (0-1)
|
| 174 |
+
|
| 175 |
+
Note:
|
| 176 |
+
Requires prescription dose in target metadata or as parameter.
|
| 177 |
+
Currently uses median dose as approximation.
|
| 178 |
+
|
| 179 |
+
Examples:
|
| 180 |
+
>>> ui = compute_uniformity_index(dose, ptv)
|
| 181 |
+
>>> print(f"Uniformity Index: {ui:.3f}")
|
| 182 |
+
"""
|
| 183 |
+
dose_values = dose.get_dose_in_structure(target)
|
| 184 |
+
|
| 185 |
+
if len(dose_values) == 0:
|
| 186 |
+
return 0.0
|
| 187 |
+
|
| 188 |
+
d_max = np.max(dose_values)
|
| 189 |
+
d_min = np.min(dose_values)
|
| 190 |
+
d_ref = np.median(dose_values) # Use median as reference
|
| 191 |
+
|
| 192 |
+
if d_ref == 0:
|
| 193 |
+
return 0.0
|
| 194 |
+
|
| 195 |
+
return float(1.0 - (d_max - d_min) / d_ref)
|
src/dosemetrics/structure_set.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structure Set classes for managing collections of radiotherapy structures.
|
| 3 |
+
|
| 4 |
+
This module provides classes to manage collections of radiotherapy structures,
|
| 5 |
+
representing complete structure sets similar to DICOM RTSS files. These classes
|
| 6 |
+
facilitate bulk operations, organization of multiple structures, and geometric analysis.
|
| 7 |
+
|
| 8 |
+
StructureSets contain only geometric information. For dose analysis, combine a
|
| 9 |
+
StructureSet with a Dose object using the dosemetrics.dose module.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from typing import Dict, List, Optional, Tuple, Iterator
|
| 15 |
+
|
| 16 |
+
from .structures import Structure, OAR, Target, AvoidanceStructure, StructureType
|
| 17 |
+
from .dose import Dose
|
| 18 |
+
from .metrics import dvh as dvh_metrics
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StructureSet:
|
| 22 |
+
"""
|
| 23 |
+
Collection of radiotherapy structures representing a complete structure set.
|
| 24 |
+
|
| 25 |
+
Similar to a DICOM RTSS file, this class manages multiple structures
|
| 26 |
+
(OARs, targets, avoidance regions) with common geometric properties.
|
| 27 |
+
Contains only geometric information - dose analysis is performed separately
|
| 28 |
+
by combining with Dose objects.
|
| 29 |
+
|
| 30 |
+
Attributes:
|
| 31 |
+
structures (Dict[str, Structure]): Dictionary mapping structure names to Structure objects
|
| 32 |
+
spacing (Tuple[float, float, float]): Common voxel spacing for all structures
|
| 33 |
+
origin (Tuple[float, float, float]): Common origin for all structures
|
| 34 |
+
name (str): Identifier for this structure set
|
| 35 |
+
|
| 36 |
+
Examples:
|
| 37 |
+
>>> # Create a structure set
|
| 38 |
+
>>> structure_set = StructureSet(spacing=(1.0, 1.0, 3.0), name="Patient001")
|
| 39 |
+
>>>
|
| 40 |
+
>>> # Add structures
|
| 41 |
+
>>> structure_set.add_structure("PTV", ptv_mask, StructureType.TARGET)
|
| 42 |
+
>>> structure_set.add_structure("Brainstem", brain_mask, StructureType.OAR)
|
| 43 |
+
>>>
|
| 44 |
+
>>> # Access structures
|
| 45 |
+
>>> ptv = structure_set.get_structure("PTV")
|
| 46 |
+
>>> print(f"PTV volume: {ptv.volume_cc():.2f} cc")
|
| 47 |
+
>>>
|
| 48 |
+
>>> # For dose analysis, combine with Dose object
|
| 49 |
+
>>> from dosemetrics.dose import Dose
|
| 50 |
+
>>> dose = Dose.from_dicom("rtdose.dcm")
|
| 51 |
+
>>> stats = dose.compute_statistics(ptv)
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
| 57 |
+
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
| 58 |
+
name: str = "StructureSet",
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
Initialize an empty StructureSet.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
spacing: Common voxel spacing in (x, y, z) mm
|
| 65 |
+
origin: Common origin coordinates in mm
|
| 66 |
+
name: Name identifier for this structure set
|
| 67 |
+
"""
|
| 68 |
+
self.structures: Dict[str, Structure] = {}
|
| 69 |
+
self.spacing = tuple(spacing)
|
| 70 |
+
self.origin = tuple(origin)
|
| 71 |
+
self.name = name
|
| 72 |
+
|
| 73 |
+
def add_structure(
|
| 74 |
+
self,
|
| 75 |
+
name: str,
|
| 76 |
+
mask: np.ndarray,
|
| 77 |
+
structure_type: StructureType,
|
| 78 |
+
structure_class: Optional[type] = None,
|
| 79 |
+
) -> Structure:
|
| 80 |
+
"""
|
| 81 |
+
Add a structure to the set.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
name: Name of the structure
|
| 85 |
+
mask: 3D binary mask array
|
| 86 |
+
structure_type: Type of structure (OAR, TARGET, etc.)
|
| 87 |
+
structure_class: Specific structure class to use (defaults based on type)
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
The created Structure object
|
| 91 |
+
|
| 92 |
+
Raises:
|
| 93 |
+
ValueError: If structure name already exists or mask dimensions are invalid
|
| 94 |
+
"""
|
| 95 |
+
if name in self.structures:
|
| 96 |
+
raise ValueError(f"Structure '{name}' already exists in the set")
|
| 97 |
+
|
| 98 |
+
# Determine structure class if not specified
|
| 99 |
+
if structure_class is None:
|
| 100 |
+
if structure_type == StructureType.OAR:
|
| 101 |
+
structure_class = OAR
|
| 102 |
+
elif structure_type == StructureType.TARGET:
|
| 103 |
+
structure_class = Target
|
| 104 |
+
elif structure_type == StructureType.AVOIDANCE:
|
| 105 |
+
structure_class = AvoidanceStructure
|
| 106 |
+
else:
|
| 107 |
+
# For SUPPORT, EXTERNAL, or custom types, create dynamic class
|
| 108 |
+
structure_class = type(
|
| 109 |
+
f"{structure_type.value.title()}Structure",
|
| 110 |
+
(Structure,),
|
| 111 |
+
{"structure_type": property(lambda self: structure_type)},
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Create structure instance
|
| 115 |
+
structure = structure_class(
|
| 116 |
+
name=name, mask=mask, spacing=self.spacing, origin=self.origin
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
self.structures[name] = structure
|
| 120 |
+
return structure
|
| 121 |
+
|
| 122 |
+
def remove_structure(self, name: str) -> None:
|
| 123 |
+
"""
|
| 124 |
+
Remove a structure from the set.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
name: Name of the structure to remove
|
| 128 |
+
|
| 129 |
+
Raises:
|
| 130 |
+
ValueError: If structure name not found
|
| 131 |
+
"""
|
| 132 |
+
if name not in self.structures:
|
| 133 |
+
raise ValueError(f"Structure '{name}' not found in the set")
|
| 134 |
+
del self.structures[name]
|
| 135 |
+
|
| 136 |
+
def add_structure_object(self, structure: Structure) -> Structure:
|
| 137 |
+
"""
|
| 138 |
+
Add an existing `Structure` instance to the set.
|
| 139 |
+
|
| 140 |
+
Convenience wrapper to support tests and workflows that create
|
| 141 |
+
`Structure` objects independently and then attach them to a `StructureSet`.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
structure: A `Structure` instance to add.
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
The same `Structure` instance after being added to the set.
|
| 148 |
+
|
| 149 |
+
Raises:
|
| 150 |
+
ValueError: If a structure with the same name already exists,
|
| 151 |
+
or if spacing/origin are incompatible with the set.
|
| 152 |
+
"""
|
| 153 |
+
name = structure.name
|
| 154 |
+
if name in self.structures:
|
| 155 |
+
raise ValueError(f"Structure '{name}' already exists in the set")
|
| 156 |
+
|
| 157 |
+
if tuple(structure.spacing) != tuple(self.spacing):
|
| 158 |
+
raise ValueError("Structure spacing incompatible with StructureSet")
|
| 159 |
+
if tuple(structure.origin) != tuple(self.origin):
|
| 160 |
+
raise ValueError("Structure origin incompatible with StructureSet")
|
| 161 |
+
|
| 162 |
+
self.structures[name] = structure
|
| 163 |
+
return structure
|
| 164 |
+
|
| 165 |
+
def get_structure(self, name: str) -> Structure:
|
| 166 |
+
"""
|
| 167 |
+
Get a structure by name.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
name: Name of the structure
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Structure object
|
| 174 |
+
|
| 175 |
+
Raises:
|
| 176 |
+
ValueError: If structure name not found
|
| 177 |
+
"""
|
| 178 |
+
if name not in self.structures:
|
| 179 |
+
raise ValueError(f"Structure '{name}' not found in the set")
|
| 180 |
+
return self.structures[name]
|
| 181 |
+
|
| 182 |
+
def get_structures_by_type(
|
| 183 |
+
self, structure_type: StructureType
|
| 184 |
+
) -> Dict[str, Structure]:
|
| 185 |
+
"""
|
| 186 |
+
Get all structures of a specific type.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
structure_type: Type to filter by
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
Dictionary of structures matching the type
|
| 193 |
+
"""
|
| 194 |
+
return {
|
| 195 |
+
name: struct
|
| 196 |
+
for name, struct in self.structures.items()
|
| 197 |
+
if struct.structure_type == structure_type
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
def get_oars(self) -> Dict[str, OAR]:
|
| 201 |
+
"""Get all OAR structures."""
|
| 202 |
+
return self.get_structures_by_type(StructureType.OAR)
|
| 203 |
+
|
| 204 |
+
def get_targets(self) -> Dict[str, Target]:
|
| 205 |
+
"""Get all target structures."""
|
| 206 |
+
return self.get_structures_by_type(StructureType.TARGET)
|
| 207 |
+
|
| 208 |
+
def get_avoidance_structures(self) -> Dict[str, AvoidanceStructure]:
|
| 209 |
+
"""Get all avoidance structures."""
|
| 210 |
+
return self.get_structures_by_type(StructureType.AVOIDANCE)
|
| 211 |
+
|
| 212 |
+
@property
|
| 213 |
+
def structure_names(self) -> List[str]:
|
| 214 |
+
"""Get list of all structure names."""
|
| 215 |
+
return list(self.structures.keys())
|
| 216 |
+
|
| 217 |
+
@property
|
| 218 |
+
def oar_names(self) -> List[str]:
|
| 219 |
+
"""Get list of OAR structure names."""
|
| 220 |
+
return list(self.get_oars().keys())
|
| 221 |
+
|
| 222 |
+
@property
|
| 223 |
+
def target_names(self) -> List[str]:
|
| 224 |
+
"""Get list of target structure names."""
|
| 225 |
+
return list(self.get_targets().keys())
|
| 226 |
+
|
| 227 |
+
@property
|
| 228 |
+
def structure_count(self) -> int:
|
| 229 |
+
"""Get total number of structures."""
|
| 230 |
+
return len(self.structures)
|
| 231 |
+
|
| 232 |
+
def total_volume_cc(self) -> float:
|
| 233 |
+
"""
|
| 234 |
+
Calculate total volume of all structures in cc.
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
Sum of all structure volumes in cubic centimeters
|
| 238 |
+
"""
|
| 239 |
+
return sum(struct.volume_cc() for struct in self.structures.values())
|
| 240 |
+
|
| 241 |
+
def geometric_summary(self) -> pd.DataFrame:
|
| 242 |
+
"""
|
| 243 |
+
Generate geometric summary for all structures.
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
DataFrame with geometric properties of each structure including:
|
| 247 |
+
- Structure name and type
|
| 248 |
+
- Volume in cc and voxels
|
| 249 |
+
- Centroid coordinates
|
| 250 |
+
- Bounding box ranges
|
| 251 |
+
"""
|
| 252 |
+
geom_data = []
|
| 253 |
+
for name, structure in self.structures.items():
|
| 254 |
+
centroid = structure.centroid()
|
| 255 |
+
bbox = structure.bounding_box()
|
| 256 |
+
|
| 257 |
+
geom = {
|
| 258 |
+
"Structure": name,
|
| 259 |
+
"Type": structure.structure_type.value.upper(),
|
| 260 |
+
"Volume_cc": structure.volume_cc(),
|
| 261 |
+
"Volume_voxels": structure.volume_voxels(),
|
| 262 |
+
"Centroid_X": centroid[0] if centroid is not None else None,
|
| 263 |
+
"Centroid_Y": centroid[1] if centroid is not None else None,
|
| 264 |
+
"Centroid_Z": centroid[2] if centroid is not None else None,
|
| 265 |
+
"BBox_X_Range": f"{bbox[0][0]}-{bbox[0][1]}" if bbox is not None else None,
|
| 266 |
+
"BBox_Y_Range": f"{bbox[1][0]}-{bbox[1][1]}" if bbox is not None else None,
|
| 267 |
+
"BBox_Z_Range": f"{bbox[2][0]}-{bbox[2][1]}" if bbox is not None else None,
|
| 268 |
+
}
|
| 269 |
+
geom_data.append(geom)
|
| 270 |
+
|
| 271 |
+
return pd.DataFrame(geom_data)
|
| 272 |
+
|
| 273 |
+
def __len__(self) -> int:
|
| 274 |
+
"""Return number of structures in the set."""
|
| 275 |
+
return len(self.structures)
|
| 276 |
+
|
| 277 |
+
def __iter__(self) -> Iterator[Tuple[str, Structure]]:
|
| 278 |
+
"""Iterate over structure name-object pairs."""
|
| 279 |
+
return iter(self.structures.items())
|
| 280 |
+
|
| 281 |
+
def __getitem__(self, name: str) -> Structure:
|
| 282 |
+
"""Access structure by name using bracket notation."""
|
| 283 |
+
return self.get_structure(name)
|
| 284 |
+
|
| 285 |
+
def __contains__(self, name: str) -> bool:
|
| 286 |
+
"""Check if structure name exists in the set."""
|
| 287 |
+
return name in self.structures
|
| 288 |
+
|
| 289 |
+
def __str__(self) -> str:
|
| 290 |
+
"""String representation of the structure set."""
|
| 291 |
+
oar_count = len(self.get_oars())
|
| 292 |
+
target_count = len(self.get_targets())
|
| 293 |
+
total_volume = self.total_volume_cc()
|
| 294 |
+
|
| 295 |
+
return (
|
| 296 |
+
f"StructureSet '{self.name}': {self.structure_count} structures "
|
| 297 |
+
f"({target_count} targets, {oar_count} OARs) "
|
| 298 |
+
f"- Total volume: {total_volume:.1f} cc"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
def __repr__(self) -> str:
|
| 302 |
+
"""Detailed representation of the structure set."""
|
| 303 |
+
return (
|
| 304 |
+
f"StructureSet(name='{self.name}', "
|
| 305 |
+
f"structures={self.structure_count}, "
|
| 306 |
+
f"spacing={self.spacing}, "
|
| 307 |
+
f"origin={self.origin})"
|
| 308 |
+
)
|
src/dosemetrics/structures.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Radiotherapy structure classes for anatomical regions of interest.
|
| 3 |
+
|
| 4 |
+
This module provides core data structures to represent radiotherapy structures from
|
| 5 |
+
RTSS DICOM files or NIfTI files. These structures are 3D volumes with binary masks
|
| 6 |
+
representing anatomical regions of interest such as organs at risk (OARs), target
|
| 7 |
+
volumes, and avoidance regions.
|
| 8 |
+
|
| 9 |
+
Structures represent pure geometry. Dose analysis is performed by combining Structure
|
| 10 |
+
objects with Dose objects using the dosemetrics.dose module.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from typing import Optional, Tuple
|
| 15 |
+
from abc import ABC, abstractmethod
|
| 16 |
+
from enum import Enum
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class StructureType(Enum):
|
| 20 |
+
"""Enumeration for different types of radiotherapy structures."""
|
| 21 |
+
|
| 22 |
+
OAR = "oar" # Organ at Risk
|
| 23 |
+
TARGET = "target" # Target volume (PTV, CTV, etc.)
|
| 24 |
+
AVOIDANCE = "avoidance" # Avoidance structure
|
| 25 |
+
SUPPORT = "support" # Support structure
|
| 26 |
+
EXTERNAL = "external" # External contour
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Structure(ABC):
|
| 30 |
+
"""
|
| 31 |
+
Base class for radiotherapy structures.
|
| 32 |
+
|
| 33 |
+
Represents a 3D anatomical structure derived from RTSS DICOM files or
|
| 34 |
+
equivalent NIfTI masks. Contains geometric information only - dose analysis
|
| 35 |
+
is performed by combining with Dose objects.
|
| 36 |
+
|
| 37 |
+
Attributes:
|
| 38 |
+
name (str): Name/identifier of the structure
|
| 39 |
+
mask (np.ndarray): 3D binary mask array (boolean)
|
| 40 |
+
spacing (Tuple[float, float, float]): Voxel spacing in (x, y, z) mm
|
| 41 |
+
origin (Tuple[float, float, float]): Origin coordinates in mm
|
| 42 |
+
|
| 43 |
+
Examples:
|
| 44 |
+
>>> # Create a structure
|
| 45 |
+
>>> ptv = Target(name="PTV", mask=mask_array, spacing=(1.0, 1.0, 3.0))
|
| 46 |
+
>>>
|
| 47 |
+
>>> # Get volume
|
| 48 |
+
>>> volume_cc = ptv.volume_cc()
|
| 49 |
+
>>>
|
| 50 |
+
>>> # Compute dose statistics (using Dose object)
|
| 51 |
+
>>> from dosemetrics.dose import Dose
|
| 52 |
+
>>> dose = Dose.from_dicom("rtdose.dcm")
|
| 53 |
+
>>> stats = dose.compute_statistics(ptv)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
name: str,
|
| 59 |
+
mask: Optional[np.ndarray] = None,
|
| 60 |
+
spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
| 61 |
+
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
| 62 |
+
):
|
| 63 |
+
"""
|
| 64 |
+
Initialize a Structure.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
name: Name/identifier of the structure
|
| 68 |
+
mask: 3D binary mask array (will be converted to bool)
|
| 69 |
+
spacing: Voxel spacing in (x, y, z) mm
|
| 70 |
+
origin: Origin coordinates in mm
|
| 71 |
+
"""
|
| 72 |
+
self.name = name
|
| 73 |
+
self.spacing = tuple(spacing)
|
| 74 |
+
self.origin = tuple(origin)
|
| 75 |
+
|
| 76 |
+
# Set and validate mask
|
| 77 |
+
if mask is not None:
|
| 78 |
+
self.set_mask(mask)
|
| 79 |
+
else:
|
| 80 |
+
self._mask = None
|
| 81 |
+
|
| 82 |
+
def set_mask(self, mask: np.ndarray) -> None:
|
| 83 |
+
"""
|
| 84 |
+
Set the binary mask for this structure.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
mask: 3D array that will be converted to binary mask
|
| 88 |
+
|
| 89 |
+
Raises:
|
| 90 |
+
ValueError: If mask is not 3D
|
| 91 |
+
"""
|
| 92 |
+
mask_array = np.asarray(mask)
|
| 93 |
+
if mask_array.ndim != 3:
|
| 94 |
+
raise ValueError(f"Mask must be 3D, got {mask_array.ndim}D")
|
| 95 |
+
|
| 96 |
+
# Convert to boolean mask
|
| 97 |
+
self._mask = mask_array.astype(bool)
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def mask(self) -> Optional[np.ndarray]:
|
| 101 |
+
"""Get the binary mask array."""
|
| 102 |
+
return self._mask
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
@abstractmethod
|
| 106 |
+
def structure_type(self) -> StructureType:
|
| 107 |
+
"""Return the type of this structure."""
|
| 108 |
+
pass
|
| 109 |
+
|
| 110 |
+
@property
|
| 111 |
+
def has_mask(self) -> bool:
|
| 112 |
+
"""Check if structure has a valid mask."""
|
| 113 |
+
return self._mask is not None
|
| 114 |
+
|
| 115 |
+
def volume_voxels(self) -> int:
|
| 116 |
+
"""
|
| 117 |
+
Get structure volume in voxels.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Number of voxels in the structure (sum of mask)
|
| 121 |
+
"""
|
| 122 |
+
if not self.has_mask or self._mask is None:
|
| 123 |
+
return 0
|
| 124 |
+
return int(np.sum(self._mask))
|
| 125 |
+
|
| 126 |
+
def volume_cc(self) -> float:
|
| 127 |
+
"""
|
| 128 |
+
Get structure volume in cubic centimeters.
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Volume in cc (considering voxel spacing)
|
| 132 |
+
"""
|
| 133 |
+
voxel_volume_mm3 = np.prod(self.spacing) # mm³
|
| 134 |
+
voxel_volume_cc = float(voxel_volume_mm3 / 1000.0) # Convert mm³ to cc
|
| 135 |
+
return float(self.volume_voxels() * voxel_volume_cc)
|
| 136 |
+
|
| 137 |
+
def centroid(self) -> Optional[Tuple[float, float, float]]:
|
| 138 |
+
"""
|
| 139 |
+
Calculate the centroid of the structure in world coordinates.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Tuple of (x, y, z) coordinates in mm, or None if no mask
|
| 143 |
+
"""
|
| 144 |
+
if not self.has_mask or self._mask is None:
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
# Get indices of mask voxels
|
| 148 |
+
mask_indices = np.where(self._mask)
|
| 149 |
+
|
| 150 |
+
if len(mask_indices[0]) == 0:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
# Calculate centroid in voxel coordinates as mean of all voxel indices
|
| 154 |
+
centroid_voxel = [
|
| 155 |
+
float(np.mean(indices))
|
| 156 |
+
for indices in mask_indices
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
# Convert to world coordinates
|
| 160 |
+
centroid_world = [
|
| 161 |
+
float(self.origin[i] + centroid_voxel[i] * self.spacing[i])
|
| 162 |
+
for i in range(3)
|
| 163 |
+
]
|
| 164 |
+
|
| 165 |
+
return (centroid_world[0], centroid_world[1], centroid_world[2])
|
| 166 |
+
|
| 167 |
+
def bounding_box(
|
| 168 |
+
self,
|
| 169 |
+
) -> Optional[Tuple[Tuple[int, int], Tuple[int, int], Tuple[int, int]]]:
|
| 170 |
+
"""
|
| 171 |
+
Get bounding box of the structure in voxel coordinates.
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
Tuple of ((min_x, max_x), (min_y, max_y), (min_z, max_z)), or None if no mask
|
| 175 |
+
"""
|
| 176 |
+
if not self.has_mask or self._mask is None:
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
mask_indices = np.where(self._mask)
|
| 180 |
+
|
| 181 |
+
if len(mask_indices[0]) == 0:
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
bounds = []
|
| 185 |
+
for i in range(3):
|
| 186 |
+
min_idx = int(np.min(mask_indices[i]))
|
| 187 |
+
max_idx = int(np.max(mask_indices[i]))
|
| 188 |
+
bounds.append((min_idx, max_idx))
|
| 189 |
+
|
| 190 |
+
return (bounds[0], bounds[1], bounds[2])
|
| 191 |
+
|
| 192 |
+
def __str__(self) -> str:
|
| 193 |
+
"""String representation of the structure."""
|
| 194 |
+
volume_cc = self.volume_cc() if self.has_mask else 0
|
| 195 |
+
return (
|
| 196 |
+
f"{self.structure_type.value.upper()}: {self.name} "
|
| 197 |
+
f"(Volume: {volume_cc:.2f} cc)"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def __repr__(self) -> str:
|
| 201 |
+
"""Detailed representation of the structure."""
|
| 202 |
+
return (
|
| 203 |
+
f"{self.__class__.__name__}(name='{self.name}', "
|
| 204 |
+
f"type={self.structure_type.value}, "
|
| 205 |
+
f"has_mask={self.has_mask}, "
|
| 206 |
+
f"volume_cc={self.volume_cc():.2f})"
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class OAR(Structure):
|
| 211 |
+
"""
|
| 212 |
+
Organ at Risk (OAR) structure.
|
| 213 |
+
|
| 214 |
+
Represents critical normal organs that should receive limited radiation dose
|
| 215 |
+
to avoid complications (e.g., spinal cord, eyes, heart, brainstem).
|
| 216 |
+
"""
|
| 217 |
+
|
| 218 |
+
@property
|
| 219 |
+
def structure_type(self) -> StructureType:
|
| 220 |
+
"""Return OAR structure type."""
|
| 221 |
+
return StructureType.OAR
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class Target(Structure):
|
| 225 |
+
"""
|
| 226 |
+
Target volume structure.
|
| 227 |
+
|
| 228 |
+
Represents volumes that should receive the prescribed radiation dose
|
| 229 |
+
(e.g., PTV, CTV, GTV).
|
| 230 |
+
"""
|
| 231 |
+
|
| 232 |
+
@property
|
| 233 |
+
def structure_type(self) -> StructureType:
|
| 234 |
+
"""Return Target structure type."""
|
| 235 |
+
return StructureType.TARGET
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class AvoidanceStructure(Structure):
|
| 239 |
+
"""
|
| 240 |
+
Avoidance structure.
|
| 241 |
+
|
| 242 |
+
Represents regions where dose should be minimized during planning
|
| 243 |
+
(e.g., critical OAR expansions, sensitive areas).
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
@property
|
| 247 |
+
def structure_type(self) -> StructureType:
|
| 248 |
+
"""Return Avoidance structure type."""
|
| 249 |
+
return StructureType.AVOIDANCE
|
src/dosemetrics/utils/__init__.py
CHANGED
|
@@ -1,57 +1,76 @@
|
|
| 1 |
"""
|
| 2 |
-
Utilities for
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
from .compliance import (
|
| 6 |
get_custom_constraints,
|
| 7 |
get_default_constraints,
|
| 8 |
check_compliance,
|
| 9 |
quality_index,
|
| 10 |
-
compute_mirage_compliance,
|
| 11 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from .plot import (
|
| 13 |
-
from_dataframe,
|
| 14 |
-
compare_dvh,
|
| 15 |
-
variability,
|
| 16 |
-
generate_dvh_variations,
|
| 17 |
-
plot_dvh_variations,
|
| 18 |
plot_dvh,
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
read_dose_and_mask_files_from_folder,
|
| 27 |
-
create_standard_contents_csv,
|
| 28 |
-
validate_folder_structure,
|
| 29 |
-
batch_folder_validation,
|
| 30 |
-
find_subject_folders,
|
| 31 |
-
setup_output_structure,
|
| 32 |
)
|
| 33 |
|
| 34 |
__all__ = [
|
|
|
|
| 35 |
"get_custom_constraints",
|
| 36 |
"get_default_constraints",
|
| 37 |
"check_compliance",
|
| 38 |
"quality_index",
|
| 39 |
-
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"plot_dvh",
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"validate_folder_structure",
|
| 54 |
-
"batch_folder_validation",
|
| 55 |
-
"find_subject_folders",
|
| 56 |
-
"setup_output_structure",
|
| 57 |
]
|
|
|
|
| 1 |
"""
|
| 2 |
+
Utilities for batch processing, multi-level analysis, and publication-quality plotting.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
# Compliance checking
|
| 6 |
from .compliance import (
|
| 7 |
get_custom_constraints,
|
| 8 |
get_default_constraints,
|
| 9 |
check_compliance,
|
| 10 |
quality_index,
|
|
|
|
| 11 |
)
|
| 12 |
+
|
| 13 |
+
# Batch processing
|
| 14 |
+
from .batch import (
|
| 15 |
+
load_dataset,
|
| 16 |
+
load_multiple_doses,
|
| 17 |
+
process_dataset_with_metric,
|
| 18 |
+
batch_compute_dvh,
|
| 19 |
+
compare_doses_batch,
|
| 20 |
+
aggregate_results,
|
| 21 |
+
export_batch_results,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Multi-level analysis
|
| 25 |
+
from .analysis import (
|
| 26 |
+
analyze_by_structure,
|
| 27 |
+
analyze_by_subject,
|
| 28 |
+
analyze_by_dataset,
|
| 29 |
+
analyze_subset,
|
| 30 |
+
compute_cohort_statistics,
|
| 31 |
+
compare_cohorts,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Publication-quality plotting
|
| 35 |
from .plot import (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
plot_dvh,
|
| 37 |
+
plot_subject_dvhs,
|
| 38 |
+
plot_dvh_comparison,
|
| 39 |
+
plot_dvh_band,
|
| 40 |
+
plot_metric_boxplot,
|
| 41 |
+
plot_metric_comparison,
|
| 42 |
+
plot_dose_slice,
|
| 43 |
+
save_figure,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
)
|
| 45 |
|
| 46 |
__all__ = [
|
| 47 |
+
# Compliance
|
| 48 |
"get_custom_constraints",
|
| 49 |
"get_default_constraints",
|
| 50 |
"check_compliance",
|
| 51 |
"quality_index",
|
| 52 |
+
# Batch processing
|
| 53 |
+
"load_dataset",
|
| 54 |
+
"load_multiple_doses",
|
| 55 |
+
"process_dataset_with_metric",
|
| 56 |
+
"batch_compute_dvh",
|
| 57 |
+
"compare_doses_batch",
|
| 58 |
+
"aggregate_results",
|
| 59 |
+
"export_batch_results",
|
| 60 |
+
# Multi-level analysis
|
| 61 |
+
"analyze_by_structure",
|
| 62 |
+
"analyze_by_subject",
|
| 63 |
+
"analyze_by_dataset",
|
| 64 |
+
"analyze_subset",
|
| 65 |
+
"compute_cohort_statistics",
|
| 66 |
+
"compare_cohorts",
|
| 67 |
+
# Plotting
|
| 68 |
"plot_dvh",
|
| 69 |
+
"plot_subject_dvhs",
|
| 70 |
+
"plot_dvh_comparison",
|
| 71 |
+
"plot_dvh_band",
|
| 72 |
+
"plot_metric_boxplot",
|
| 73 |
+
"plot_metric_comparison",
|
| 74 |
+
"plot_dose_slice",
|
| 75 |
+
"save_figure",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
]
|
src/dosemetrics/utils/analysis.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-level analysis utilities for dosemetrics.
|
| 3 |
+
|
| 4 |
+
This module provides functions to analyze dosimetric data at different levels:
|
| 5 |
+
- By structure: Analyze a single structure across subjects
|
| 6 |
+
- By subject: Analyze all structures for a single subject
|
| 7 |
+
- By dataset: Analyze entire cohorts with summary statistics
|
| 8 |
+
- By subset: Filter and analyze specific groups
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Dict, List, Optional, Union, Tuple
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import numpy as np
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from ..dose import Dose
|
| 19 |
+
from ..structures import Structure
|
| 20 |
+
from ..structure_set import StructureSet
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def analyze_by_structure(
|
| 24 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 25 |
+
structure_name: str,
|
| 26 |
+
metrics: Dict[str, callable]
|
| 27 |
+
) -> pd.DataFrame:
|
| 28 |
+
"""
|
| 29 |
+
Analyze a single structure across all subjects.
|
| 30 |
+
|
| 31 |
+
Computes specified metrics for one structure across the entire dataset,
|
| 32 |
+
useful for population-level structure analysis (e.g., PTV coverage across cohort).
|
| 33 |
+
|
| 34 |
+
Parameters
|
| 35 |
+
----------
|
| 36 |
+
dataset : Dict
|
| 37 |
+
Dataset dictionary from batch.load_dataset()
|
| 38 |
+
structure_name : str
|
| 39 |
+
Name of structure to analyze
|
| 40 |
+
metrics : Dict[str, callable]
|
| 41 |
+
Dictionary of {metric_name: metric_function}
|
| 42 |
+
Each function should take (dose, structure) and return a value
|
| 43 |
+
|
| 44 |
+
Returns
|
| 45 |
+
-------
|
| 46 |
+
results : pd.DataFrame
|
| 47 |
+
DataFrame with subject_id and computed metrics
|
| 48 |
+
|
| 49 |
+
Examples
|
| 50 |
+
--------
|
| 51 |
+
>>> from dosemetrics.metrics import dvh
|
| 52 |
+
>>> from dosemetrics.utils import analysis
|
| 53 |
+
>>>
|
| 54 |
+
>>> metrics = {
|
| 55 |
+
... 'mean_dose': dvh.compute_mean_dose,
|
| 56 |
+
... 'max_dose': dvh.compute_max_dose,
|
| 57 |
+
... 'D95': lambda d, s: dvh.compute_dose_at_volume(d, s, 95)
|
| 58 |
+
... }
|
| 59 |
+
>>> results = analysis.analyze_by_structure(dataset, 'PTV', metrics)
|
| 60 |
+
>>> print(results.describe()) # Summary statistics for PTV across subjects
|
| 61 |
+
"""
|
| 62 |
+
results = []
|
| 63 |
+
|
| 64 |
+
for subject_id, data in dataset.items():
|
| 65 |
+
if 'dose' not in data or 'structures' not in data:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
dose = data['dose']
|
| 69 |
+
structures = data['structures']
|
| 70 |
+
|
| 71 |
+
# Find the structure
|
| 72 |
+
try:
|
| 73 |
+
structure = structures.get_structure(structure_name)
|
| 74 |
+
except (ValueError, KeyError):
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
row = {'subject_id': subject_id}
|
| 78 |
+
|
| 79 |
+
# Compute all metrics
|
| 80 |
+
for metric_name, metric_func in metrics.items():
|
| 81 |
+
try:
|
| 82 |
+
value = metric_func(dose, structure)
|
| 83 |
+
row[metric_name] = value
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"Warning: Error computing {metric_name} for {subject_id}/{structure_name}: {e}")
|
| 86 |
+
row[metric_name] = np.nan
|
| 87 |
+
|
| 88 |
+
results.append(row)
|
| 89 |
+
|
| 90 |
+
return pd.DataFrame(results)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def analyze_by_subject(
|
| 94 |
+
dose: Dose,
|
| 95 |
+
structures: StructureSet,
|
| 96 |
+
metrics: Dict[str, callable],
|
| 97 |
+
structure_names: Optional[List[str]] = None
|
| 98 |
+
) -> pd.DataFrame:
|
| 99 |
+
"""
|
| 100 |
+
Analyze all structures for a single subject.
|
| 101 |
+
|
| 102 |
+
Computes metrics for all (or selected) structures in a single subject's dataset.
|
| 103 |
+
|
| 104 |
+
Parameters
|
| 105 |
+
----------
|
| 106 |
+
dose : Dose
|
| 107 |
+
Subject's dose distribution
|
| 108 |
+
structures : StructureSet
|
| 109 |
+
Subject's structure set
|
| 110 |
+
metrics : Dict[str, callable]
|
| 111 |
+
Dictionary of {metric_name: metric_function}
|
| 112 |
+
structure_names : List[str], optional
|
| 113 |
+
Specific structures to analyze (default: all)
|
| 114 |
+
|
| 115 |
+
Returns
|
| 116 |
+
-------
|
| 117 |
+
results : pd.DataFrame
|
| 118 |
+
DataFrame with structure names and computed metrics
|
| 119 |
+
|
| 120 |
+
Examples
|
| 121 |
+
--------
|
| 122 |
+
>>> from dosemetrics.metrics import dvh
|
| 123 |
+
>>> from dosemetrics.utils import analysis
|
| 124 |
+
>>>
|
| 125 |
+
>>> dose = Dose.from_dicom('rtdose.dcm')
|
| 126 |
+
>>> structures = StructureSet.from_dicom('rtstruct.dcm')
|
| 127 |
+
>>>
|
| 128 |
+
>>> metrics = {
|
| 129 |
+
... 'mean_dose': dvh.compute_mean_dose,
|
| 130 |
+
... 'V20': lambda d, s: dvh.compute_volume_at_dose(d, s, 20)
|
| 131 |
+
... }
|
| 132 |
+
>>> results = analysis.analyze_by_subject(dose, structures, metrics)
|
| 133 |
+
"""
|
| 134 |
+
results = []
|
| 135 |
+
|
| 136 |
+
# Determine which structures to analyze
|
| 137 |
+
if structure_names:
|
| 138 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 139 |
+
if name in structures.structure_names]
|
| 140 |
+
else:
|
| 141 |
+
# Iterate over structure values only
|
| 142 |
+
struct_list = list(structures.structures.values())
|
| 143 |
+
|
| 144 |
+
for structure in struct_list:
|
| 145 |
+
row = {'structure': structure.name, 'type': structure.structure_type.value}
|
| 146 |
+
|
| 147 |
+
# Compute all metrics
|
| 148 |
+
for metric_name, metric_func in metrics.items():
|
| 149 |
+
try:
|
| 150 |
+
value = metric_func(dose, structure)
|
| 151 |
+
row[metric_name] = value
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"Warning: Error computing {metric_name} for {structure.name}: {e}")
|
| 154 |
+
row[metric_name] = np.nan
|
| 155 |
+
|
| 156 |
+
results.append(row)
|
| 157 |
+
|
| 158 |
+
return pd.DataFrame(results)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def analyze_by_dataset(
|
| 162 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 163 |
+
metrics: Dict[str, callable],
|
| 164 |
+
structure_names: Optional[List[str]] = None,
|
| 165 |
+
summary_stats: bool = True
|
| 166 |
+
) -> Union[pd.DataFrame, Tuple[pd.DataFrame, pd.DataFrame]]:
|
| 167 |
+
"""
|
| 168 |
+
Analyze entire dataset with population-level statistics.
|
| 169 |
+
|
| 170 |
+
Computes metrics across all subjects and structures, with optional
|
| 171 |
+
summary statistics grouped by structure.
|
| 172 |
+
|
| 173 |
+
Parameters
|
| 174 |
+
----------
|
| 175 |
+
dataset : Dict
|
| 176 |
+
Dataset dictionary
|
| 177 |
+
metrics : Dict[str, callable]
|
| 178 |
+
Metrics to compute
|
| 179 |
+
structure_names : List[str], optional
|
| 180 |
+
Specific structures to analyze
|
| 181 |
+
summary_stats : bool
|
| 182 |
+
If True, return both detailed and summary dataframes
|
| 183 |
+
|
| 184 |
+
Returns
|
| 185 |
+
-------
|
| 186 |
+
results : pd.DataFrame or Tuple[pd.DataFrame, pd.DataFrame]
|
| 187 |
+
If summary_stats=False: detailed results
|
| 188 |
+
If summary_stats=True: (detailed_results, summary_stats)
|
| 189 |
+
|
| 190 |
+
Examples
|
| 191 |
+
--------
|
| 192 |
+
>>> from dosemetrics.metrics import dvh
|
| 193 |
+
>>> from dosemetrics.utils import analysis
|
| 194 |
+
>>>
|
| 195 |
+
>>> metrics = {
|
| 196 |
+
... 'mean_dose': dvh.compute_mean_dose,
|
| 197 |
+
... 'D95': lambda d, s: dvh.compute_dose_at_volume(d, s, 95)
|
| 198 |
+
... }
|
| 199 |
+
>>> detailed, summary = analysis.analyze_by_dataset(
|
| 200 |
+
... dataset, metrics, structure_names=['PTV', 'Heart', 'Lung_L']
|
| 201 |
+
... )
|
| 202 |
+
>>> print(summary) # Mean ± std for each metric per structure
|
| 203 |
+
"""
|
| 204 |
+
results = []
|
| 205 |
+
|
| 206 |
+
for subject_id, data in dataset.items():
|
| 207 |
+
if 'dose' not in data or 'structures' not in data:
|
| 208 |
+
continue
|
| 209 |
+
|
| 210 |
+
dose = data['dose']
|
| 211 |
+
structures = data['structures']
|
| 212 |
+
|
| 213 |
+
# Determine which structures to analyze
|
| 214 |
+
if structure_names:
|
| 215 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 216 |
+
if name in structures.structure_names]
|
| 217 |
+
else:
|
| 218 |
+
struct_list = list(structures.structures.values())
|
| 219 |
+
|
| 220 |
+
for structure in struct_list:
|
| 221 |
+
row = {
|
| 222 |
+
'subject_id': subject_id,
|
| 223 |
+
'structure': structure.name,
|
| 224 |
+
'type': structure.structure_type.value
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
# Compute all metrics
|
| 228 |
+
for metric_name, metric_func in metrics.items():
|
| 229 |
+
try:
|
| 230 |
+
value = metric_func(dose, structure)
|
| 231 |
+
row[metric_name] = value
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f"Warning: Error computing {metric_name} for {subject_id}/{structure.name}: {e}")
|
| 234 |
+
row[metric_name] = np.nan
|
| 235 |
+
|
| 236 |
+
results.append(row)
|
| 237 |
+
|
| 238 |
+
detailed_df = pd.DataFrame(results)
|
| 239 |
+
|
| 240 |
+
if not summary_stats:
|
| 241 |
+
return detailed_df
|
| 242 |
+
|
| 243 |
+
# Compute summary statistics grouped by structure
|
| 244 |
+
metric_cols = list(metrics.keys())
|
| 245 |
+
summary = detailed_df.groupby('structure')[metric_cols].agg(['mean', 'std', 'min', 'max', 'median'])
|
| 246 |
+
|
| 247 |
+
return detailed_df, summary
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def analyze_subset(
|
| 251 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 252 |
+
metrics: Dict[str, callable],
|
| 253 |
+
subject_filter: Optional[callable] = None,
|
| 254 |
+
structure_filter: Optional[callable] = None,
|
| 255 |
+
**filter_kwargs
|
| 256 |
+
) -> pd.DataFrame:
|
| 257 |
+
"""
|
| 258 |
+
Analyze a filtered subset of the dataset.
|
| 259 |
+
|
| 260 |
+
Apply custom filters to subjects and/or structures before analysis.
|
| 261 |
+
|
| 262 |
+
Parameters
|
| 263 |
+
----------
|
| 264 |
+
dataset : Dict
|
| 265 |
+
Dataset dictionary
|
| 266 |
+
metrics : Dict[str, callable]
|
| 267 |
+
Metrics to compute
|
| 268 |
+
subject_filter : callable, optional
|
| 269 |
+
Function that takes (subject_id, data) and returns bool
|
| 270 |
+
structure_filter : callable, optional
|
| 271 |
+
Function that takes (structure) and returns bool
|
| 272 |
+
**filter_kwargs
|
| 273 |
+
Additional filter parameters
|
| 274 |
+
|
| 275 |
+
Returns
|
| 276 |
+
-------
|
| 277 |
+
results : pd.DataFrame
|
| 278 |
+
Analysis results for filtered subset
|
| 279 |
+
|
| 280 |
+
Examples
|
| 281 |
+
--------
|
| 282 |
+
>>> # Analyze only target structures
|
| 283 |
+
>>> def target_only(structure):
|
| 284 |
+
... return structure.structure_type == StructureType.TARGET
|
| 285 |
+
>>>
|
| 286 |
+
>>> results = analysis.analyze_subset(
|
| 287 |
+
... dataset,
|
| 288 |
+
... metrics={'mean_dose': compute_mean_dose},
|
| 289 |
+
... structure_filter=target_only
|
| 290 |
+
... )
|
| 291 |
+
"""
|
| 292 |
+
results = []
|
| 293 |
+
|
| 294 |
+
for subject_id, data in dataset.items():
|
| 295 |
+
# Apply subject filter
|
| 296 |
+
if subject_filter and not subject_filter(subject_id, data):
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
if 'dose' not in data or 'structures' not in data:
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
dose = data['dose']
|
| 303 |
+
structures = data['structures']
|
| 304 |
+
|
| 305 |
+
# Filter structures
|
| 306 |
+
if structure_filter:
|
| 307 |
+
struct_list = [s for s in structures.structures.values() if structure_filter(s)]
|
| 308 |
+
else:
|
| 309 |
+
struct_list = list(structures.structures.values())
|
| 310 |
+
|
| 311 |
+
for structure in struct_list:
|
| 312 |
+
row = {
|
| 313 |
+
'subject_id': subject_id,
|
| 314 |
+
'structure': structure.name,
|
| 315 |
+
'type': structure.structure_type.value
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
# Compute metrics
|
| 319 |
+
for metric_name, metric_func in metrics.items():
|
| 320 |
+
try:
|
| 321 |
+
value = metric_func(dose, structure)
|
| 322 |
+
row[metric_name] = value
|
| 323 |
+
except Exception as e:
|
| 324 |
+
print(f"Warning: Error computing {metric_name} for {subject_id}/{structure.name}: {e}")
|
| 325 |
+
row[metric_name] = np.nan
|
| 326 |
+
|
| 327 |
+
results.append(row)
|
| 328 |
+
|
| 329 |
+
return pd.DataFrame(results)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def compute_cohort_statistics(
|
| 333 |
+
results: pd.DataFrame,
|
| 334 |
+
metric_cols: Optional[List[str]] = None,
|
| 335 |
+
group_by: str = 'structure'
|
| 336 |
+
) -> pd.DataFrame:
|
| 337 |
+
"""
|
| 338 |
+
Compute cohort-level summary statistics.
|
| 339 |
+
|
| 340 |
+
Parameters
|
| 341 |
+
----------
|
| 342 |
+
results : pd.DataFrame
|
| 343 |
+
Results from analyze_by_dataset or similar
|
| 344 |
+
metric_cols : List[str], optional
|
| 345 |
+
Columns to summarize (default: all numeric)
|
| 346 |
+
group_by : str
|
| 347 |
+
Column to group by (default: 'structure')
|
| 348 |
+
|
| 349 |
+
Returns
|
| 350 |
+
-------
|
| 351 |
+
statistics : pd.DataFrame
|
| 352 |
+
Summary statistics (mean, std, CI, etc.)
|
| 353 |
+
|
| 354 |
+
Examples
|
| 355 |
+
--------
|
| 356 |
+
>>> results = analyze_by_dataset(dataset, metrics)
|
| 357 |
+
>>> stats = compute_cohort_statistics(results[0])
|
| 358 |
+
>>> print(stats) # Population statistics per structure
|
| 359 |
+
"""
|
| 360 |
+
if metric_cols is None:
|
| 361 |
+
metric_cols = results.select_dtypes(include=[np.number]).columns.tolist()
|
| 362 |
+
|
| 363 |
+
summary = results.groupby(group_by)[metric_cols].agg([
|
| 364 |
+
'count',
|
| 365 |
+
'mean',
|
| 366 |
+
'std',
|
| 367 |
+
'min',
|
| 368 |
+
('q25', lambda x: np.percentile(x, 25)),
|
| 369 |
+
'median',
|
| 370 |
+
('q75', lambda x: np.percentile(x, 75)),
|
| 371 |
+
'max'
|
| 372 |
+
])
|
| 373 |
+
|
| 374 |
+
# Add confidence intervals
|
| 375 |
+
for col in metric_cols:
|
| 376 |
+
if (group_by, col, 'count') in summary.columns or (col, 'count') in summary.columns:
|
| 377 |
+
n = summary[(col, 'count')] if (col, 'count') in summary.columns else summary[(group_by, col, 'count')]
|
| 378 |
+
std = summary[(col, 'std')] if (col, 'std') in summary.columns else summary[(group_by, col, 'std')]
|
| 379 |
+
se = std / np.sqrt(n)
|
| 380 |
+
summary[(col, 'ci_95')] = 1.96 * se
|
| 381 |
+
|
| 382 |
+
return summary
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def compare_cohorts(
|
| 386 |
+
results1: pd.DataFrame,
|
| 387 |
+
results2: pd.DataFrame,
|
| 388 |
+
metric_cols: Optional[List[str]] = None,
|
| 389 |
+
cohort_names: Tuple[str, str] = ('Cohort1', 'Cohort2')
|
| 390 |
+
) -> pd.DataFrame:
|
| 391 |
+
"""
|
| 392 |
+
Compare two cohorts statistically.
|
| 393 |
+
|
| 394 |
+
Performs t-tests and computes effect sizes between two groups.
|
| 395 |
+
|
| 396 |
+
Parameters
|
| 397 |
+
----------
|
| 398 |
+
results1, results2 : pd.DataFrame
|
| 399 |
+
Results from two different cohorts
|
| 400 |
+
metric_cols : List[str], optional
|
| 401 |
+
Metrics to compare
|
| 402 |
+
cohort_names : Tuple[str, str]
|
| 403 |
+
Names for the cohorts
|
| 404 |
+
|
| 405 |
+
Returns
|
| 406 |
+
-------
|
| 407 |
+
comparison : pd.DataFrame
|
| 408 |
+
Statistical comparison results
|
| 409 |
+
|
| 410 |
+
Examples
|
| 411 |
+
--------
|
| 412 |
+
>>> pre_treatment = analyze_by_dataset(pre_data, metrics)
|
| 413 |
+
>>> post_treatment = analyze_by_dataset(post_data, metrics)
|
| 414 |
+
>>> comparison = compare_cohorts(
|
| 415 |
+
... pre_treatment[0], post_treatment[0],
|
| 416 |
+
... cohort_names=('Pre', 'Post')
|
| 417 |
+
... )
|
| 418 |
+
"""
|
| 419 |
+
from scipy import stats
|
| 420 |
+
|
| 421 |
+
if metric_cols is None:
|
| 422 |
+
metric_cols = results1.select_dtypes(include=[np.number]).columns.tolist()
|
| 423 |
+
|
| 424 |
+
comparison_results = []
|
| 425 |
+
|
| 426 |
+
# Get common structures
|
| 427 |
+
structures1 = set(results1['structure'].unique())
|
| 428 |
+
structures2 = set(results2['structure'].unique())
|
| 429 |
+
common_structures = structures1 & structures2
|
| 430 |
+
|
| 431 |
+
for structure in common_structures:
|
| 432 |
+
data1 = results1[results1['structure'] == structure]
|
| 433 |
+
data2 = results2[results2['structure'] == structure]
|
| 434 |
+
|
| 435 |
+
for metric in metric_cols:
|
| 436 |
+
if metric not in data1.columns or metric not in data2.columns:
|
| 437 |
+
continue
|
| 438 |
+
|
| 439 |
+
values1 = data1[metric].dropna()
|
| 440 |
+
values2 = data2[metric].dropna()
|
| 441 |
+
|
| 442 |
+
if len(values1) < 2 or len(values2) < 2:
|
| 443 |
+
continue
|
| 444 |
+
|
| 445 |
+
# T-test
|
| 446 |
+
t_stat, p_value = stats.ttest_ind(values1, values2)
|
| 447 |
+
|
| 448 |
+
# Effect size (Cohen's d)
|
| 449 |
+
pooled_std = np.sqrt(((len(values1)-1)*values1.std()**2 + (len(values2)-1)*values2.std()**2) /
|
| 450 |
+
(len(values1) + len(values2) - 2))
|
| 451 |
+
cohens_d = (values1.mean() - values2.mean()) / pooled_std if pooled_std > 0 else 0
|
| 452 |
+
|
| 453 |
+
comparison_results.append({
|
| 454 |
+
'structure': structure,
|
| 455 |
+
'metric': metric,
|
| 456 |
+
f'{cohort_names[0]}_mean': values1.mean(),
|
| 457 |
+
f'{cohort_names[0]}_std': values1.std(),
|
| 458 |
+
f'{cohort_names[1]}_mean': values2.mean(),
|
| 459 |
+
f'{cohort_names[1]}_std': values2.std(),
|
| 460 |
+
'difference': values1.mean() - values2.mean(),
|
| 461 |
+
't_statistic': t_stat,
|
| 462 |
+
'p_value': p_value,
|
| 463 |
+
'cohens_d': cohens_d,
|
| 464 |
+
'significant': p_value < 0.05
|
| 465 |
+
})
|
| 466 |
+
|
| 467 |
+
return pd.DataFrame(comparison_results)
|
src/dosemetrics/utils/batch.py
CHANGED
|
@@ -1,330 +1,455 @@
|
|
| 1 |
"""
|
| 2 |
-
Batch processing
|
| 3 |
|
| 4 |
This module provides high-level functions for processing multiple subjects,
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
| 8 |
import os
|
| 9 |
-
import
|
| 10 |
-
from typing import Dict, List, Optional
|
| 11 |
import pandas as pd
|
| 12 |
-
import
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
--------
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
for
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
"""
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
Returns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
--------
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
"""
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
# Auto-detect structures if not provided
|
| 86 |
-
if structure_list is None:
|
| 87 |
-
structure_list = get_structures_from_folder(input_folder)
|
| 88 |
-
|
| 89 |
-
# Read structure masks
|
| 90 |
-
structure_masks = {}
|
| 91 |
-
for struct_name in structure_list:
|
| 92 |
-
struct_file = os.path.join(input_folder, f"{struct_name}.nii.gz")
|
| 93 |
-
if os.path.exists(struct_file):
|
| 94 |
-
structure_masks[struct_name] = read_from_nifti(struct_file)
|
| 95 |
else:
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
) -> pd.DataFrame:
|
| 107 |
"""
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
--------
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
"""
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
"""
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
Parameters
|
| 175 |
-
----------
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
--------
|
| 183 |
-
|
| 184 |
-
|
|
|
|
| 185 |
"""
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
) -> pd.DataFrame:
|
| 208 |
"""
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
Parameters
|
| 212 |
-
----------
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
--------
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
"""
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
"""
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
--------
|
| 268 |
-
|
| 269 |
-
|
|
|
|
| 270 |
"""
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
analysis_types: List[str] = ["dvh", "quality_index", "compliance", "plots"],
|
| 290 |
-
) -> Dict[str, str]:
|
| 291 |
"""
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
Parameters
|
| 295 |
-
----------
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
| 304 |
--------
|
| 305 |
-
|
| 306 |
-
|
| 307 |
"""
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
for subject in subject_names:
|
| 323 |
-
subject_folder = os.path.join(folder_structure["individual"], subject)
|
| 324 |
-
os.makedirs(subject_folder, exist_ok=True)
|
| 325 |
-
|
| 326 |
-
for analysis_type in analysis_types:
|
| 327 |
-
analysis_folder = os.path.join(subject_folder, analysis_type)
|
| 328 |
-
os.makedirs(analysis_folder, exist_ok=True)
|
| 329 |
-
|
| 330 |
-
return folder_structure
|
|
|
|
| 1 |
"""
|
| 2 |
+
Batch processing utilities for dosemetrics.
|
| 3 |
|
| 4 |
This module provides high-level functions for processing multiple subjects,
|
| 5 |
+
datasets, and performing batch dosimetric analysis across entire cohorts.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Dict, List, Optional, Tuple, Union, Callable
|
| 13 |
import pandas as pd
|
| 14 |
+
import numpy as np
|
| 15 |
+
from collections import defaultdict
|
| 16 |
+
|
| 17 |
+
from ..dose import Dose
|
| 18 |
+
from ..structures import Structure
|
| 19 |
+
from ..structure_set import StructureSet
|
| 20 |
+
from ..io import load_from_folder, detect_folder_format
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_dataset(
|
| 24 |
+
root_path: Union[str, Path],
|
| 25 |
+
subject_pattern: str = "*",
|
| 26 |
+
dose_pattern: str = "dose*",
|
| 27 |
+
structures_pattern: str = "*.nii.gz",
|
| 28 |
+
auto_detect: bool = True
|
| 29 |
+
) -> Dict[str, Dict[str, Union[Dose, StructureSet]]]:
|
| 30 |
"""
|
| 31 |
+
Load an entire dataset with multiple subjects.
|
| 32 |
+
|
| 33 |
+
Automatically detects folder structure and loads all doses and structure sets.
|
| 34 |
+
Supports both DICOM and NIfTI formats with automatic detection.
|
| 35 |
+
|
| 36 |
+
Parameters
|
| 37 |
+
----------
|
| 38 |
+
root_path : str or Path
|
| 39 |
+
Root directory containing subject folders
|
| 40 |
+
subject_pattern : str
|
| 41 |
+
Glob pattern for subject folder names (default: "*")
|
| 42 |
+
dose_pattern : str
|
| 43 |
+
Pattern to identify dose files/folders
|
| 44 |
+
structures_pattern : str
|
| 45 |
+
Pattern to identify structure files
|
| 46 |
+
auto_detect : bool
|
| 47 |
+
Automatically detect DICOM vs NIfTI format
|
| 48 |
+
|
| 49 |
+
Returns
|
| 50 |
+
-------
|
| 51 |
+
dataset : Dict[str, Dict[str, Union[Dose, StructureSet]]]
|
| 52 |
+
Nested dictionary: {subject_id: {'dose': Dose, 'structures': StructureSet}}
|
| 53 |
+
|
| 54 |
+
Examples
|
| 55 |
--------
|
| 56 |
+
>>> dataset = load_dataset('/data/clinical_study')
|
| 57 |
+
>>> for subject_id, data in dataset.items():
|
| 58 |
+
... dose = data['dose']
|
| 59 |
+
... structures = data['structures']
|
| 60 |
+
... print(f"Subject {subject_id}: {len(structures)} structures")
|
| 61 |
"""
|
| 62 |
+
root_path = Path(root_path)
|
| 63 |
+
dataset = {}
|
| 64 |
+
|
| 65 |
+
# Find all subject folders
|
| 66 |
+
subject_folders = sorted(root_path.glob(subject_pattern))
|
| 67 |
+
|
| 68 |
+
for subject_folder in subject_folders:
|
| 69 |
+
if not subject_folder.is_dir():
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
subject_id = subject_folder.name
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
# Try to load the entire folder
|
| 76 |
+
if auto_detect:
|
| 77 |
+
format_type = detect_folder_format(str(subject_folder))
|
| 78 |
+
else:
|
| 79 |
+
format_type = None
|
| 80 |
+
|
| 81 |
+
# Load dose and structures
|
| 82 |
+
result = load_from_folder(str(subject_folder))
|
| 83 |
+
|
| 84 |
+
if result:
|
| 85 |
+
dataset[subject_id] = result
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Warning: Could not load subject {subject_id}: {e}")
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
return dataset
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def load_multiple_doses(
|
| 95 |
+
folder_paths: List[Union[str, Path]],
|
| 96 |
+
dose_names: Optional[List[str]] = None
|
| 97 |
+
) -> Dict[str, Dose]:
|
| 98 |
"""
|
| 99 |
+
Load multiple dose distributions from different folders.
|
| 100 |
+
|
| 101 |
+
Useful for comparing different treatment plans (e.g., TPS vs predicted).
|
| 102 |
+
|
| 103 |
+
Parameters
|
| 104 |
+
----------
|
| 105 |
+
folder_paths : List[str or Path]
|
| 106 |
+
List of folders, each containing a dose distribution
|
| 107 |
+
dose_names : List[str], optional
|
| 108 |
+
Names for each dose (default: uses folder names)
|
| 109 |
+
|
| 110 |
+
Returns
|
| 111 |
+
-------
|
| 112 |
+
doses : Dict[str, Dose]
|
| 113 |
+
Dictionary mapping dose names to Dose objects
|
| 114 |
+
|
| 115 |
+
Examples
|
| 116 |
--------
|
| 117 |
+
>>> doses = load_multiple_doses([
|
| 118 |
+
... '/data/subject01/tps',
|
| 119 |
+
... '/data/subject01/predicted'
|
| 120 |
+
... ], dose_names=['TPS', 'Predicted'])
|
| 121 |
"""
|
| 122 |
+
doses = {}
|
| 123 |
+
|
| 124 |
+
for i, folder_path in enumerate(folder_paths):
|
| 125 |
+
folder_path = Path(folder_path)
|
| 126 |
+
|
| 127 |
+
if dose_names and i < len(dose_names):
|
| 128 |
+
name = dose_names[i]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
else:
|
| 130 |
+
name = folder_path.name
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
result = load_from_folder(str(folder_path))
|
| 134 |
+
if result and 'dose' in result:
|
| 135 |
+
doses[name] = result['dose']
|
| 136 |
+
except Exception as e:
|
| 137 |
+
print(f"Warning: Could not load dose from {folder_path}: {e}")
|
| 138 |
+
|
| 139 |
+
return doses
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def process_dataset_with_metric(
|
| 143 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 144 |
+
metric_func: Callable,
|
| 145 |
+
structure_names: Optional[List[str]] = None,
|
| 146 |
+
**metric_kwargs
|
| 147 |
) -> pd.DataFrame:
|
| 148 |
"""
|
| 149 |
+
Apply a metric function across an entire dataset.
|
| 150 |
+
|
| 151 |
+
Computes metrics for all subjects and all structures, returning results
|
| 152 |
+
in a structured DataFrame.
|
| 153 |
+
|
| 154 |
+
Parameters
|
| 155 |
+
----------
|
| 156 |
+
dataset : Dict
|
| 157 |
+
Dataset dictionary from load_dataset()
|
| 158 |
+
metric_func : Callable
|
| 159 |
+
Metric function that takes (dose, structure) and returns a value or dict
|
| 160 |
+
structure_names : List[str], optional
|
| 161 |
+
Specific structures to analyze (default: all structures)
|
| 162 |
+
**metric_kwargs
|
| 163 |
+
Additional keyword arguments passed to metric_func
|
| 164 |
+
|
| 165 |
+
Returns
|
| 166 |
+
-------
|
| 167 |
+
results : pd.DataFrame
|
| 168 |
+
DataFrame with columns: subject_id, structure_name, metric values
|
| 169 |
+
|
| 170 |
+
Examples
|
| 171 |
--------
|
| 172 |
+
>>> from dosemetrics.metrics import dvh
|
| 173 |
+
>>> dataset = load_dataset('/data/study')
|
| 174 |
+
>>> results = process_dataset_with_metric(
|
| 175 |
+
... dataset,
|
| 176 |
+
... dvh.compute_mean_dose,
|
| 177 |
+
... structure_names=['PTV', 'Heart']
|
| 178 |
+
... )
|
| 179 |
"""
|
| 180 |
+
results = []
|
| 181 |
+
|
| 182 |
+
for subject_id, data in dataset.items():
|
| 183 |
+
if 'dose' not in data or 'structures' not in data:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
dose = data['dose']
|
| 187 |
+
structures = data['structures']
|
| 188 |
+
|
| 189 |
+
# Determine which structures to process
|
| 190 |
+
if structure_names:
|
| 191 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 192 |
+
if name in structures.structure_names]
|
| 193 |
+
else:
|
| 194 |
+
struct_list = list(structures.structures.values())
|
| 195 |
+
|
| 196 |
+
for structure in struct_list:
|
| 197 |
+
try:
|
| 198 |
+
# Call the metric function
|
| 199 |
+
result = metric_func(dose, structure, **metric_kwargs)
|
| 200 |
+
|
| 201 |
+
# Handle different return types
|
| 202 |
+
if isinstance(result, dict):
|
| 203 |
+
row = {'subject_id': subject_id, 'structure': structure.name}
|
| 204 |
+
row.update(result)
|
| 205 |
+
else:
|
| 206 |
+
row = {
|
| 207 |
+
'subject_id': subject_id,
|
| 208 |
+
'structure': structure.name,
|
| 209 |
+
'value': result
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
results.append(row)
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
print(f"Warning: Error processing {subject_id}/{structure.name}: {e}")
|
| 216 |
+
continue
|
| 217 |
+
|
| 218 |
+
return pd.DataFrame(results)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def batch_compute_dvh(
|
| 222 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 223 |
+
structure_names: Optional[List[str]] = None,
|
| 224 |
+
max_dose: Optional[float] = None,
|
| 225 |
+
step_size: float = 0.1
|
| 226 |
+
) -> Dict[str, Dict[str, Tuple[np.ndarray, np.ndarray]]]:
|
| 227 |
"""
|
| 228 |
+
Compute DVHs for all subjects and structures in a dataset.
|
| 229 |
+
|
| 230 |
+
Parameters
|
| 231 |
+
----------
|
| 232 |
+
dataset : Dict
|
| 233 |
+
Dataset dictionary from load_dataset()
|
| 234 |
+
structure_names : List[str], optional
|
| 235 |
+
Specific structures to analyze
|
| 236 |
+
max_dose : float, optional
|
| 237 |
+
Maximum dose for DVH bins
|
| 238 |
+
step_size : float
|
| 239 |
+
DVH bin width in Gy
|
| 240 |
+
|
| 241 |
+
Returns
|
| 242 |
+
-------
|
| 243 |
+
dvhs : Dict[str, Dict[str, Tuple]]
|
| 244 |
+
Nested dict: {subject_id: {structure_name: (dose_bins, volumes)}}
|
| 245 |
+
|
| 246 |
+
Examples
|
| 247 |
--------
|
| 248 |
+
>>> from dosemetrics.utils import batch
|
| 249 |
+
>>> dataset = batch.load_dataset('/data/study')
|
| 250 |
+
>>> dvhs = batch.batch_compute_dvh(dataset, structure_names=['PTV', 'Heart'])
|
| 251 |
"""
|
| 252 |
+
from ..metrics import dvh as dvh_module
|
| 253 |
+
|
| 254 |
+
dvhs = {}
|
| 255 |
+
|
| 256 |
+
for subject_id, data in dataset.items():
|
| 257 |
+
if 'dose' not in data or 'structures' not in data:
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
dose = data['dose']
|
| 261 |
+
structures = data['structures']
|
| 262 |
+
subject_dvhs = {}
|
| 263 |
+
|
| 264 |
+
# Determine which structures to process
|
| 265 |
+
if structure_names:
|
| 266 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 267 |
+
if name in structures.structure_names]
|
| 268 |
+
else:
|
| 269 |
+
struct_list = list(structures.structures.values())
|
| 270 |
+
|
| 271 |
+
for structure in struct_list:
|
| 272 |
+
try:
|
| 273 |
+
dose_bins, volumes = dvh_module.compute_dvh(
|
| 274 |
+
dose, structure, max_dose=max_dose, step_size=step_size
|
| 275 |
+
)
|
| 276 |
+
subject_dvhs[structure.name] = {
|
| 277 |
+
'dose_bins': dose_bins,
|
| 278 |
+
'volumes': volumes
|
| 279 |
+
}
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f"Warning: Error computing DVH for {subject_id}/{structure.name}: {e}")
|
| 282 |
+
|
| 283 |
+
if subject_dvhs:
|
| 284 |
+
dvhs[subject_id] = subject_dvhs
|
| 285 |
+
|
| 286 |
+
return dvhs
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def compare_doses_batch(
|
| 290 |
+
dataset1: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 291 |
+
dataset2: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 292 |
+
comparison_func: Callable,
|
| 293 |
+
structure_names: Optional[List[str]] = None,
|
| 294 |
+
**kwargs
|
| 295 |
) -> pd.DataFrame:
|
| 296 |
"""
|
| 297 |
+
Compare two datasets (e.g., TPS vs predicted doses).
|
| 298 |
+
|
| 299 |
+
Parameters
|
| 300 |
+
----------
|
| 301 |
+
dataset1, dataset2 : Dict
|
| 302 |
+
Dataset dictionaries to compare
|
| 303 |
+
comparison_func : Callable
|
| 304 |
+
Function that takes (dose1, dose2, structure) and returns metrics
|
| 305 |
+
structure_names : List[str], optional
|
| 306 |
+
Specific structures to compare
|
| 307 |
+
**kwargs
|
| 308 |
+
Additional arguments for comparison_func
|
| 309 |
+
|
| 310 |
+
Returns
|
| 311 |
+
-------
|
| 312 |
+
comparison : pd.DataFrame
|
| 313 |
+
Comparison results for all subjects and structures
|
| 314 |
+
|
| 315 |
+
Examples
|
| 316 |
--------
|
| 317 |
+
>>> from dosemetrics.metrics import dose_comparison
|
| 318 |
+
>>> tps_data = load_dataset('/data/tps')
|
| 319 |
+
>>> pred_data = load_dataset('/data/predicted')
|
| 320 |
+
>>> comparison = compare_doses_batch(
|
| 321 |
+
... tps_data, pred_data,
|
| 322 |
+
... dose_comparison.compute_mae
|
| 323 |
+
... )
|
| 324 |
"""
|
| 325 |
+
results = []
|
| 326 |
+
|
| 327 |
+
# Find common subjects
|
| 328 |
+
common_subjects = set(dataset1.keys()) & set(dataset2.keys())
|
| 329 |
+
|
| 330 |
+
for subject_id in common_subjects:
|
| 331 |
+
data1 = dataset1[subject_id]
|
| 332 |
+
data2 = dataset2[subject_id]
|
| 333 |
+
|
| 334 |
+
if 'dose' not in data1 or 'dose' not in data2:
|
| 335 |
+
continue
|
| 336 |
+
|
| 337 |
+
dose1 = data1['dose']
|
| 338 |
+
dose2 = data2['dose']
|
| 339 |
+
|
| 340 |
+
# Get structures (use dataset1's structures)
|
| 341 |
+
if 'structures' not in data1:
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
structures = data1['structures']
|
| 345 |
+
|
| 346 |
+
# Determine which structures to process
|
| 347 |
+
if structure_names:
|
| 348 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 349 |
+
if name in structures.structure_names]
|
| 350 |
+
else:
|
| 351 |
+
struct_list = list(structures.structures.values())
|
| 352 |
+
|
| 353 |
+
for structure in struct_list:
|
| 354 |
+
try:
|
| 355 |
+
result = comparison_func(dose1, dose2, structure, **kwargs)
|
| 356 |
+
|
| 357 |
+
if isinstance(result, dict):
|
| 358 |
+
row = {'subject_id': subject_id, 'structure': structure.name}
|
| 359 |
+
row.update(result)
|
| 360 |
+
else:
|
| 361 |
+
row = {
|
| 362 |
+
'subject_id': subject_id,
|
| 363 |
+
'structure': structure.name,
|
| 364 |
+
'value': result
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
results.append(row)
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
print(f"Warning: Error comparing {subject_id}/{structure.name}: {e}")
|
| 371 |
+
|
| 372 |
+
return pd.DataFrame(results)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def aggregate_results(
|
| 376 |
+
results: pd.DataFrame,
|
| 377 |
+
group_by: Union[str, List[str]] = 'structure',
|
| 378 |
+
agg_funcs: Optional[Dict[str, Union[str, List[str]]]] = None
|
| 379 |
+
) -> pd.DataFrame:
|
| 380 |
"""
|
| 381 |
+
Aggregate batch processing results.
|
| 382 |
+
|
| 383 |
+
Compute summary statistics across subjects, structures, or other groupings.
|
| 384 |
+
|
| 385 |
+
Parameters
|
| 386 |
+
----------
|
| 387 |
+
results : pd.DataFrame
|
| 388 |
+
Results from process_dataset_with_metric or similar
|
| 389 |
+
group_by : str or List[str]
|
| 390 |
+
Column(s) to group by (e.g., 'structure', 'subject_id')
|
| 391 |
+
agg_funcs : Dict, optional
|
| 392 |
+
Aggregation functions for each column
|
| 393 |
+
Default: {'value': ['mean', 'std', 'min', 'max']}
|
| 394 |
+
|
| 395 |
+
Returns
|
| 396 |
+
-------
|
| 397 |
+
summary : pd.DataFrame
|
| 398 |
+
Aggregated statistics
|
| 399 |
+
|
| 400 |
+
Examples
|
| 401 |
--------
|
| 402 |
+
>>> results = process_dataset_with_metric(dataset, compute_mean_dose)
|
| 403 |
+
>>> summary = aggregate_results(results, group_by='structure')
|
| 404 |
+
>>> print(summary) # Mean dose statistics per structure
|
| 405 |
"""
|
| 406 |
+
if agg_funcs is None:
|
| 407 |
+
# Default aggregations for numeric columns
|
| 408 |
+
numeric_cols = results.select_dtypes(include=[np.number]).columns
|
| 409 |
+
if len(numeric_cols) == 0:
|
| 410 |
+
return results.groupby(group_by).size().to_frame('count')
|
| 411 |
+
|
| 412 |
+
agg_funcs = {col: ['mean', 'std', 'min', 'max', 'median']
|
| 413 |
+
for col in numeric_cols if col != group_by}
|
| 414 |
+
|
| 415 |
+
return results.groupby(group_by).agg(agg_funcs)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def export_batch_results(
|
| 419 |
+
results: pd.DataFrame,
|
| 420 |
+
output_path: Union[str, Path],
|
| 421 |
+
format: str = 'csv',
|
| 422 |
+
**kwargs
|
| 423 |
+
) -> None:
|
|
|
|
|
|
|
| 424 |
"""
|
| 425 |
+
Export batch processing results to file.
|
| 426 |
+
|
| 427 |
+
Parameters
|
| 428 |
+
----------
|
| 429 |
+
results : pd.DataFrame
|
| 430 |
+
Results dataframe to export
|
| 431 |
+
output_path : str or Path
|
| 432 |
+
Output file path
|
| 433 |
+
format : str
|
| 434 |
+
Output format: 'csv', 'excel', 'json', 'parquet'
|
| 435 |
+
**kwargs
|
| 436 |
+
Additional arguments for the export function
|
| 437 |
+
|
| 438 |
+
Examples
|
| 439 |
--------
|
| 440 |
+
>>> results = process_dataset_with_metric(dataset, compute_mean_dose)
|
| 441 |
+
>>> export_batch_results(results, 'results/mean_dose.csv')
|
| 442 |
"""
|
| 443 |
+
output_path = Path(output_path)
|
| 444 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 445 |
+
|
| 446 |
+
if format == 'csv':
|
| 447 |
+
results.to_csv(output_path, **kwargs)
|
| 448 |
+
elif format == 'excel':
|
| 449 |
+
results.to_excel(output_path, **kwargs)
|
| 450 |
+
elif format == 'json':
|
| 451 |
+
results.to_json(output_path, **kwargs)
|
| 452 |
+
elif format == 'parquet':
|
| 453 |
+
results.to_parquet(output_path, **kwargs)
|
| 454 |
+
else:
|
| 455 |
+
raise ValueError(f"Unsupported format: {format}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/dosemetrics/utils/compliance.py
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pandas as pd
|
| 2 |
import numpy as np
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def get_custom_constraints():
|
|
@@ -144,284 +156,91 @@ def check_compliance(df, constraint):
|
|
| 144 |
|
| 145 |
|
| 146 |
def quality_index(
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
) -> float:
|
| 152 |
"""
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
"""
|
| 160 |
-
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
else:
|
| 169 |
-
|
| 170 |
-
gap_between = (_constraint_level - _mean_dose) / _constraint_level
|
| 171 |
-
# Ideal value here is 1, as max_dose will be 0,
|
| 172 |
-
# and constraint_value will be non-zero positive.
|
| 173 |
-
return float(gap_between)
|
| 174 |
|
| 175 |
-
elif _constraint_type == "max":
|
| 176 |
-
proportion_above = np.max(values[np.where(bins > _constraint_level)[0]])
|
| 177 |
if proportion_above > 0:
|
| 178 |
-
#
|
| 179 |
-
#
|
| 180 |
-
return -proportion_above / 100
|
| 181 |
else:
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
return gap_between
|
| 187 |
-
|
| 188 |
-
elif _constraint_type == "min":
|
| 189 |
-
# This is for targets, but could be applied to other structures.
|
| 190 |
-
proportion_below = np.min(values[np.where(bins < _constraint_level)[0]])
|
| 191 |
-
if proportion_below < 100:
|
| 192 |
-
# negative value here to indicate crossing the constraint,
|
| 193 |
-
# worst case is -1, where all voxels are above constraint.
|
| 194 |
-
return -(100 - proportion_below) / 100 # percentage to ratio.
|
| 195 |
-
|
| 196 |
-
# If none of the constraint types match, return a default float value
|
| 197 |
-
return 0.0
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def compute_mirage_compliance(dose_volume: np.ndarray, structure_masks: dict):
|
| 201 |
-
compliance_stats = {}
|
| 202 |
-
|
| 203 |
-
for struct_name in sorted(structure_masks.keys()):
|
| 204 |
-
struct_mask = structure_masks[struct_name]
|
| 205 |
-
dose_in_struct = dose_volume[struct_mask > 0]
|
| 206 |
-
if struct_name == "Chiasm":
|
| 207 |
-
# Optic Chiasm: ≤55 Gy. to 0.03cc, Optic Chiasm PRV ≤55Gy to 0.03cc
|
| 208 |
-
# Checking for 4 because our voxel grid is 2mmx2mmx2mm, meaning each voxel is 8mm3.
|
| 209 |
-
# 0.03cc is 30mm3, which is between 3 and 4 voxels - 24mm3 and 32mm3.
|
| 210 |
-
if struct_mask.sum() > 4:
|
| 211 |
-
sorted_dose = np.sort(dose_in_struct)[::-1]
|
| 212 |
-
calculated_dose = sorted_dose[3]
|
| 213 |
-
limit_dose = 55
|
| 214 |
-
if calculated_dose >= limit_dose:
|
| 215 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc violated. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 216 |
-
compliance_stats[struct_name] = [
|
| 217 |
-
"Fail",
|
| 218 |
-
reason,
|
| 219 |
-
limit_dose - calculated_dose,
|
| 220 |
-
]
|
| 221 |
-
else:
|
| 222 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 223 |
-
compliance_stats[struct_name] = [
|
| 224 |
-
"Pass",
|
| 225 |
-
reason,
|
| 226 |
-
limit_dose - calculated_dose,
|
| 227 |
-
]
|
| 228 |
-
else:
|
| 229 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 230 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 231 |
-
print(compliance_stats[struct_name])
|
| 232 |
-
|
| 233 |
-
elif (struct_name == "Brainstem") or (struct_name == "BrainStem"):
|
| 234 |
-
# Brainstem: ≤56 Gy. to 0.03cc
|
| 235 |
-
if struct_mask.sum() > 4:
|
| 236 |
-
sorted_dose = np.sort(dose_in_struct)[::-1]
|
| 237 |
-
calculated_dose = sorted_dose[3]
|
| 238 |
-
limit_dose = 56
|
| 239 |
-
if calculated_dose >= limit_dose:
|
| 240 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc violated. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 241 |
-
compliance_stats[struct_name] = [
|
| 242 |
-
"Fail",
|
| 243 |
-
reason,
|
| 244 |
-
limit_dose - calculated_dose,
|
| 245 |
-
]
|
| 246 |
-
else:
|
| 247 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 248 |
-
compliance_stats[struct_name] = [
|
| 249 |
-
"Pass",
|
| 250 |
-
reason,
|
| 251 |
-
limit_dose - calculated_dose,
|
| 252 |
-
]
|
| 253 |
-
else:
|
| 254 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 255 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 256 |
-
print(compliance_stats[struct_name])
|
| 257 |
-
|
| 258 |
-
elif "Cochlea" in struct_name:
|
| 259 |
-
# Cochlea: ≤45 Gy if both sides are involved; otherwise ≤60 Gy. (Low priority OaR) to 0.03cc
|
| 260 |
-
if struct_mask.sum() > 4:
|
| 261 |
-
sorted_dose = np.sort(dose_in_struct)[::-1]
|
| 262 |
-
calculated_dose = sorted_dose[3]
|
| 263 |
-
limit_dose = 45
|
| 264 |
-
if calculated_dose >= limit_dose:
|
| 265 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc violated. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 266 |
-
compliance_stats[struct_name] = [
|
| 267 |
-
"Fail",
|
| 268 |
-
reason,
|
| 269 |
-
limit_dose - calculated_dose,
|
| 270 |
-
]
|
| 271 |
-
else:
|
| 272 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 273 |
-
compliance_stats[struct_name] = [
|
| 274 |
-
"Pass",
|
| 275 |
-
reason,
|
| 276 |
-
limit_dose - calculated_dose,
|
| 277 |
-
]
|
| 278 |
-
else:
|
| 279 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 280 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 281 |
-
print(compliance_stats[struct_name])
|
| 282 |
-
|
| 283 |
-
elif "LacrimalGland" in struct_name:
|
| 284 |
-
# Lacrimal glands: <40 Gy to 0.03cc
|
| 285 |
-
if struct_mask.sum() > 4:
|
| 286 |
-
sorted_dose = np.sort(dose_in_struct)[::-1]
|
| 287 |
-
calculated_dose = sorted_dose[3]
|
| 288 |
-
limit_dose = 40
|
| 289 |
-
if calculated_dose >= limit_dose:
|
| 290 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc violated. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 291 |
-
compliance_stats[struct_name] = [
|
| 292 |
-
"Fail",
|
| 293 |
-
reason,
|
| 294 |
-
limit_dose - calculated_dose,
|
| 295 |
-
]
|
| 296 |
-
else:
|
| 297 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 298 |
-
compliance_stats[struct_name] = [
|
| 299 |
-
"Pass",
|
| 300 |
-
reason,
|
| 301 |
-
limit_dose - calculated_dose,
|
| 302 |
-
]
|
| 303 |
-
else:
|
| 304 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 305 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 306 |
-
print(compliance_stats[struct_name])
|
| 307 |
-
|
| 308 |
-
elif "OpticNerve" in struct_name:
|
| 309 |
-
# Optic Nerves ≤ 56 Gy to 0.03cc, Optic Nerves PRV: ≤56 Gy. to 0.03cc
|
| 310 |
-
if struct_mask.sum() > 4:
|
| 311 |
-
sorted_dose = np.sort(dose_in_struct)[::-1]
|
| 312 |
-
calculated_dose = sorted_dose[3]
|
| 313 |
-
limit_dose = 56
|
| 314 |
-
if calculated_dose >= limit_dose:
|
| 315 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc violated. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 316 |
-
compliance_stats[struct_name] = [
|
| 317 |
-
"Fail",
|
| 318 |
-
reason,
|
| 319 |
-
limit_dose - calculated_dose,
|
| 320 |
-
]
|
| 321 |
-
else:
|
| 322 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 323 |
-
compliance_stats[struct_name] = [
|
| 324 |
-
"Pass",
|
| 325 |
-
reason,
|
| 326 |
-
limit_dose - calculated_dose,
|
| 327 |
-
]
|
| 328 |
-
else:
|
| 329 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 330 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 331 |
-
print(compliance_stats[struct_name])
|
| 332 |
-
|
| 333 |
-
elif struct_name == "Brain":
|
| 334 |
-
# The dose to the normal brain minus the PTV should be kept as low as possible. The Dmean is to be ≤ 30 Gy
|
| 335 |
-
limit_dose = 30
|
| 336 |
-
calculated_dose = dose_in_struct.mean()
|
| 337 |
-
if calculated_dose >= limit_dose:
|
| 338 |
-
reason = f"{struct_name} Dmean <= {limit_dose} Gy is violated. Dmean is {calculated_dose:.3f}"
|
| 339 |
-
compliance_stats[struct_name] = [
|
| 340 |
-
"Fail",
|
| 341 |
-
reason,
|
| 342 |
-
limit_dose - calculated_dose,
|
| 343 |
-
]
|
| 344 |
-
else:
|
| 345 |
-
reason = f"{struct_name} Dmean <= {limit_dose} Gy is achieved. Dmean is {calculated_dose:.3f}"
|
| 346 |
-
compliance_stats[struct_name] = [
|
| 347 |
-
"Pass",
|
| 348 |
-
reason,
|
| 349 |
-
limit_dose - calculated_dose,
|
| 350 |
-
]
|
| 351 |
-
print(compliance_stats[struct_name])
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
"Fail",
|
| 361 |
-
reason,
|
| 362 |
-
limit_dose - calculated_dose,
|
| 363 |
-
]
|
| 364 |
-
else:
|
| 365 |
-
reason = f"{struct_name} Dmax <= {limit_dose} Gy is achieved. Dmean is {calculated_dose:.3f}"
|
| 366 |
-
compliance_stats[struct_name] = [
|
| 367 |
-
"Pass",
|
| 368 |
-
reason,
|
| 369 |
-
limit_dose - calculated_dose,
|
| 370 |
-
]
|
| 371 |
-
print(compliance_stats[struct_name])
|
| 372 |
|
| 373 |
-
|
| 374 |
-
#
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
compliance_stats[struct_name] = [
|
| 382 |
-
"Fail",
|
| 383 |
-
reason,
|
| 384 |
-
limit_dose - calculated_dose,
|
| 385 |
-
]
|
| 386 |
-
else:
|
| 387 |
-
reason = f"{struct_name} <= {limit_dose} Gy to 0.03cc achieved. Dose in 0.03cc is {calculated_dose:.3f}"
|
| 388 |
-
compliance_stats[struct_name] = [
|
| 389 |
-
"Pass",
|
| 390 |
-
reason,
|
| 391 |
-
limit_dose - calculated_dose,
|
| 392 |
-
]
|
| 393 |
-
else:
|
| 394 |
-
reason = f"{struct_name} volume is smaller than 0.03cc."
|
| 395 |
-
compliance_stats[struct_name] = ["NA", reason, 0]
|
| 396 |
-
print(compliance_stats[struct_name])
|
| 397 |
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
"Fail",
|
| 406 |
-
reason,
|
| 407 |
-
limit_dose - calculated_dose,
|
| 408 |
-
]
|
| 409 |
-
else:
|
| 410 |
-
reason = f"{struct_name} Dmin >= {limit_dose} Gy is achieved. Dmin is {calculated_dose:.3f}"
|
| 411 |
-
compliance_stats[struct_name] = [
|
| 412 |
-
"Pass",
|
| 413 |
-
reason,
|
| 414 |
-
limit_dose - calculated_dose,
|
| 415 |
-
]
|
| 416 |
-
print(compliance_stats[struct_name])
|
| 417 |
|
|
|
|
|
|
|
|
|
|
| 418 |
else:
|
| 419 |
-
|
| 420 |
-
"NA",
|
| 421 |
-
f"{struct_name} either has no constraints; or is not defined for both versions.",
|
| 422 |
-
0,
|
| 423 |
-
]
|
| 424 |
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
return compliance_df
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compliance checking and quality indices for dose constraints.
|
| 3 |
+
|
| 4 |
+
This module provides functions to check compliance with dose constraints
|
| 5 |
+
and compute quality indices for treatment plan evaluation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
import pandas as pd
|
| 11 |
import numpy as np
|
| 12 |
+
from typing import TYPE_CHECKING
|
| 13 |
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from ..dose import Dose
|
| 16 |
+
from ..structures import Structure
|
| 17 |
|
| 18 |
|
| 19 |
def get_custom_constraints():
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
def quality_index(
|
| 159 |
+
dose: Dose,
|
| 160 |
+
structure: Structure,
|
| 161 |
+
constraint_type: str,
|
| 162 |
+
constraint_level: float,
|
| 163 |
) -> float:
|
| 164 |
"""
|
| 165 |
+
Compute the quality index of a dose distribution relative to a constraint.
|
| 166 |
+
|
| 167 |
+
Quality index interpretation:
|
| 168 |
+
- Positive values: Constraint is met (higher is better, 1.0 is ideal)
|
| 169 |
+
- Negative values: Constraint is violated (magnitude indicates severity)
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
dose: Dose distribution object
|
| 173 |
+
structure: Structure to evaluate
|
| 174 |
+
constraint_type: Type of constraint ('max', 'mean', or 'min')
|
| 175 |
+
constraint_level: Constraint value in Gy
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
Quality index (-1 to 1)
|
| 179 |
+
|
| 180 |
+
Examples:
|
| 181 |
+
>>> from dosemetrics.dose import Dose
|
| 182 |
+
>>> from dosemetrics.utils.compliance import quality_index
|
| 183 |
+
>>>
|
| 184 |
+
>>> dose = Dose.from_dicom("rtdose.dcm")
|
| 185 |
+
>>> brainstem = structures.get_structure("Brainstem")
|
| 186 |
+
>>>
|
| 187 |
+
>>> # Check max dose constraint
|
| 188 |
+
>>> qi = quality_index(dose, brainstem, "max", 54.0)
|
| 189 |
+
>>> if qi < 0:
|
| 190 |
+
... print("Constraint violated!")
|
| 191 |
"""
|
| 192 |
+
from ..metrics import dvh, statistics
|
| 193 |
|
| 194 |
+
dose_bins, volumes = dvh.compute_dvh(dose, structure)
|
| 195 |
+
|
| 196 |
+
if constraint_type == "mean":
|
| 197 |
+
# Check if mean dose exceeds constraint
|
| 198 |
+
indices = np.where(dose_bins > constraint_level)[0]
|
| 199 |
+
if len(indices) > 0:
|
| 200 |
+
proportion_above = np.max(volumes[indices])
|
| 201 |
else:
|
| 202 |
+
proportion_above = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
|
|
|
|
|
|
| 204 |
if proportion_above > 0:
|
| 205 |
+
# Negative value indicates violation
|
| 206 |
+
# Worst case is -1 (all voxels above constraint)
|
| 207 |
+
return -proportion_above / 100.0
|
| 208 |
else:
|
| 209 |
+
# Constraint is met - compute gap
|
| 210 |
+
mean_dose_val = statistics.compute_mean_dose(dose, structure)
|
| 211 |
+
gap_between = (constraint_level - mean_dose_val) / constraint_level
|
| 212 |
+
return float(gap_between)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
+
elif constraint_type == "max":
|
| 215 |
+
# Check if any dose exceeds constraint
|
| 216 |
+
indices = np.where(dose_bins > constraint_level)[0]
|
| 217 |
+
if len(indices) > 0:
|
| 218 |
+
proportion_above = np.max(volumes[indices])
|
| 219 |
+
else:
|
| 220 |
+
proportion_above = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
+
if proportion_above > 0:
|
| 223 |
+
# Negative value indicates violation
|
| 224 |
+
return -proportion_above / 100.0
|
| 225 |
+
else:
|
| 226 |
+
# Constraint is met - compute gap
|
| 227 |
+
max_dose_val = statistics.compute_max_dose(dose, structure)
|
| 228 |
+
gap_between = (constraint_level - max_dose_val) / constraint_level
|
| 229 |
+
return float(gap_between)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
+
elif constraint_type == "min":
|
| 232 |
+
# For targets - check if dose is below constraint
|
| 233 |
+
indices = np.where(dose_bins < constraint_level)[0]
|
| 234 |
+
if len(indices) > 0:
|
| 235 |
+
proportion_below = np.min(volumes[indices])
|
| 236 |
+
else:
|
| 237 |
+
proportion_below = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
+
if proportion_below < 100:
|
| 240 |
+
# Negative value indicates violation
|
| 241 |
+
return -(100 - proportion_below) / 100.0
|
| 242 |
else:
|
| 243 |
+
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
+
# Default return
|
| 246 |
+
return 0.0
|
|
|
src/dosemetrics/utils/plot.py
CHANGED
|
@@ -1,714 +1,668 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
import pandas as pd
|
| 4 |
-
import numpy as np
|
| 5 |
-
from typing import Optional
|
| 6 |
-
from ..metrics.dvh import dvh_by_structure, compute_dvh
|
| 7 |
-
from matplotlib.transforms import Bbox
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def _get_cmap(n, name="gist_ncar"):
|
| 11 |
-
"""Returns a function that maps each index in 0, 1, ..., n-1 to a distinct
|
| 12 |
-
RGB color; the keyword argument name must be a standard mpl colormap name."""
|
| 13 |
-
return plt.cm.get_cmap(name, n)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def from_dataframe(dataframe: pd.DataFrame, plot_title: str, output_path: str) -> None:
|
| 17 |
-
col_names = dataframe.columns
|
| 18 |
-
cmap = _get_cmap(40)
|
| 19 |
-
|
| 20 |
-
plt.style.use("dark_background")
|
| 21 |
-
fig, ax = plt.subplots()
|
| 22 |
-
|
| 23 |
-
for i in range(len(col_names)):
|
| 24 |
-
if i % 2 == 0:
|
| 25 |
-
name = col_names[i].split("\n")[0]
|
| 26 |
-
line_color = cmap(i)
|
| 27 |
-
x = dataframe[col_names[i]]
|
| 28 |
-
y = dataframe[col_names[i + 1]]
|
| 29 |
-
plt.plot(x, y, color=line_color, label=name)
|
| 30 |
-
|
| 31 |
-
plt.xlabel("Dose [Gy]")
|
| 32 |
-
plt.xlim([0, 65])
|
| 33 |
-
plt.grid()
|
| 34 |
-
plt.ylabel("Ratio of Total Structure Volume [%]")
|
| 35 |
-
# Shrink current axis by 20%
|
| 36 |
-
box = ax.get_position()
|
| 37 |
-
ax.set_position([box.x0, box.y0, box.width * 0.8, box.height])
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
plt.savefig(output_path)
|
| 44 |
-
plt.close(fig)
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
_struct_mask: np.ndarray,
|
| 52 |
-
max_dose=65,
|
| 53 |
-
step_size=0.1,
|
| 54 |
-
):
|
| 55 |
-
bins_gt, values_gt = compute_dvh(
|
| 56 |
-
_gt, _struct_mask, max_dose=max_dose, step_size=step_size
|
| 57 |
-
)
|
| 58 |
-
bins_pred, values_pred = compute_dvh(
|
| 59 |
-
_pred, _struct_mask, max_dose=max_dose, step_size=step_size
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
fig = plt.figure()
|
| 63 |
-
plt.plot(bins_gt, values_gt, color="b", label="ground truth")
|
| 64 |
-
plt.plot(bins_pred, values_pred, color="r", label="prediction")
|
| 65 |
|
| 66 |
-
plt.xlabel("Dose [Gy]")
|
| 67 |
-
plt.ylabel("Ratio of Total Structure Volume [%]")
|
| 68 |
-
plt.legend(loc="best")
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
-
def
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 82 |
"""
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
Parameters
|
| 86 |
-
----------
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
Number of
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
--------
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"""
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
start[1] : start[1] + original_shape[1],
|
| 157 |
-
start[2] : start[2] + original_shape[2],
|
| 158 |
-
]
|
| 159 |
-
else:
|
| 160 |
-
# Pad
|
| 161 |
-
pad_width = [
|
| 162 |
-
(os - cs) // 2 for cs, os in zip(current_shape, original_shape)
|
| 163 |
-
]
|
| 164 |
-
pad_width = [
|
| 165 |
-
(p, os - cs - p)
|
| 166 |
-
for p, cs, os in zip(pad_width, current_shape, original_shape)
|
| 167 |
-
]
|
| 168 |
-
transformed_mask = np.pad(
|
| 169 |
-
transformed_mask, pad_width, mode="constant", constant_values=0
|
| 170 |
-
)
|
| 171 |
-
|
| 172 |
-
# Apply elastic deformation
|
| 173 |
-
# Create random displacement fields
|
| 174 |
-
shape = transformed_mask.shape
|
| 175 |
-
dx = ndimage.gaussian_filter(
|
| 176 |
-
(np.random.rand(*shape) - 0.5) * alpha, sigma, mode="constant", cval=0
|
| 177 |
-
)
|
| 178 |
-
dy = ndimage.gaussian_filter(
|
| 179 |
-
(np.random.rand(*shape) - 0.5) * alpha, sigma, mode="constant", cval=0
|
| 180 |
-
)
|
| 181 |
-
dz = ndimage.gaussian_filter(
|
| 182 |
-
(np.random.rand(*shape) - 0.5) * alpha, sigma, mode="constant", cval=0
|
| 183 |
-
)
|
| 184 |
-
|
| 185 |
-
# Create coordinate grids
|
| 186 |
-
x, y, z = np.meshgrid(
|
| 187 |
-
np.arange(shape[0]), np.arange(shape[1]), np.arange(shape[2]), indexing="ij"
|
| 188 |
-
)
|
| 189 |
-
|
| 190 |
-
# Apply displacement
|
| 191 |
-
indices = np.array(
|
| 192 |
-
[
|
| 193 |
-
np.clip(x + dx, 0, shape[0] - 1),
|
| 194 |
-
np.clip(y + dy, 0, shape[1] - 1),
|
| 195 |
-
np.clip(z + dz, 0, shape[2] - 1),
|
| 196 |
-
]
|
| 197 |
-
)
|
| 198 |
-
|
| 199 |
-
transformed_mask = ndimage.map_coordinates(
|
| 200 |
-
transformed_mask, indices, order=1, mode="constant", cval=0
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
# Apply small rotation
|
| 204 |
-
for axis_idx, angle in enumerate(rotation_angles):
|
| 205 |
-
if abs(angle) > 0.1:
|
| 206 |
-
axes = [(axis_idx + 1) % 3, (axis_idx + 2) % 3]
|
| 207 |
-
transformed_mask = ndimage.rotate(
|
| 208 |
-
transformed_mask, angle, axes=axes, reshape=False, order=1
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
-
# Threshold to binary mask
|
| 212 |
-
transformed_mask = (transformed_mask > 0.5).astype(np.uint8)
|
| 213 |
-
|
| 214 |
-
# Compute Dice coefficient
|
| 215 |
-
intersection = np.logical_and(structure_mask, transformed_mask)
|
| 216 |
-
dice = (
|
| 217 |
-
(2 * intersection.sum() / (structure_mask.sum() + transformed_mask.sum()))
|
| 218 |
-
if (structure_mask.sum() + transformed_mask.sum()) > 0
|
| 219 |
-
else 0
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
# Compute DVH for transformed mask
|
| 223 |
-
bins, values = compute_dvh(
|
| 224 |
-
dose_volume, transformed_mask, max_dose=max_dose, step_size=step_size
|
| 225 |
-
)
|
| 226 |
-
|
| 227 |
-
dvh_data.append((bins, values))
|
| 228 |
-
dice_coefficients.append(dice)
|
| 229 |
-
|
| 230 |
-
return dvh_data, dice_coefficients, (original_bins, original_values)
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def plot_dvh_variations(
|
| 234 |
-
dvh_data: list,
|
| 235 |
-
dice_coefficients: list,
|
| 236 |
-
original_dvh: tuple,
|
| 237 |
-
constraint_limit: float,
|
| 238 |
-
structure_name: str,
|
| 239 |
-
) -> tuple:
|
| 240 |
"""
|
| 241 |
-
Plot
|
| 242 |
-
|
| 243 |
-
Parameters
|
| 244 |
-
----------
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
--------
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
"""
|
| 262 |
-
fig = plt.
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
linewidth=2,
|
| 307 |
-
zorder=10,
|
| 308 |
-
)
|
| 309 |
-
|
| 310 |
-
# Add constraint line
|
| 311 |
-
plt.axvline(
|
| 312 |
-
x=constraint_limit,
|
| 313 |
-
color="g",
|
| 314 |
-
label="Constraint Limit",
|
| 315 |
-
linewidth=2,
|
| 316 |
-
linestyle="--",
|
| 317 |
-
zorder=10,
|
| 318 |
-
)
|
| 319 |
-
|
| 320 |
-
plt.xlabel("Dose [Gy]")
|
| 321 |
-
plt.ylabel("Ratio of Total Structure Volume [%]")
|
| 322 |
-
plt.title(f"DVH Family for {structure_name}")
|
| 323 |
-
|
| 324 |
-
# Add colorbar
|
| 325 |
-
if sc is not None:
|
| 326 |
-
color_bar = plt.colorbar(sc, label="Dice Coefficient")
|
| 327 |
-
color_bar.set_alpha(1)
|
| 328 |
-
# Set colorbar ticks
|
| 329 |
-
color_bar.set_ticks([0, 0.5, 1.0])
|
| 330 |
-
color_bar.set_ticklabels(
|
| 331 |
-
[f"{min_dice:.2f}", f"{(min_dice+max_dice)/2:.2f}", f"{max_dice:.2f}"]
|
| 332 |
-
)
|
| 333 |
-
|
| 334 |
-
plt.legend()
|
| 335 |
-
plt.grid()
|
| 336 |
-
|
| 337 |
-
return fig, (min_dice, max_dice)
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def variability(dose_volume, structure_mask, constraint_limit, structure_of_interest):
|
| 341 |
"""
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
"""
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
return fig,
|
| 361 |
|
| 362 |
|
| 363 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
"""
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
"""
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
"""
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
Parameters
|
| 402 |
-
----------
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
"""
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
output_file: str,
|
| 460 |
-
labels: Optional[list] = None,
|
| 461 |
-
max_value: float = 1000000,
|
| 462 |
-
) -> None:
|
| 463 |
"""
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
"""
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
#
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
for
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
"""
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
Parameters
|
| 524 |
-
----------
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
"""
|
| 540 |
-
fig, ax = plt.subplots(figsize=
|
| 541 |
-
|
| 542 |
-
#
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
ax.
|
| 578 |
-
ax.
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
"""
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
"""
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
struct_array = sitk.GetArrayFromImage(struct_image)
|
| 600 |
-
|
| 601 |
-
from ..metrics.dvh import mean_dose, max_dose, volume
|
| 602 |
-
|
| 603 |
-
stats["bins"], stats["values"] = compute_dvh(dose_array, struct_array)
|
| 604 |
-
stats["max"] = mean_dose(
|
| 605 |
-
dose_array, struct_array
|
| 606 |
-
) # Note: function name seems swapped in original
|
| 607 |
-
stats["mean"] = max_dose(
|
| 608 |
-
dose_array, struct_array
|
| 609 |
-
) # Note: function name seems swapped in original
|
| 610 |
-
stats["volume"] = volume(struct_array, struct_image.GetSpacing())
|
| 611 |
-
stats["color"] = "b"
|
| 612 |
-
return stats
|
| 613 |
-
|
| 614 |
-
def plot_stats(stats_dict):
|
| 615 |
-
"""Plot DVH statistics."""
|
| 616 |
-
fig = plt.figure(figsize=(10, 6))
|
| 617 |
-
plt.plot(
|
| 618 |
-
stats_dict["bins"],
|
| 619 |
-
stats_dict["values"],
|
| 620 |
-
color=stats_dict["color"],
|
| 621 |
-
label=stats_dict["name"],
|
| 622 |
-
)
|
| 623 |
-
plt.legend(loc="best")
|
| 624 |
-
plt.xlabel("Dose [Gy]")
|
| 625 |
-
plt.ylabel("Ratio of Total Structure Volume [%]")
|
| 626 |
-
plt.title(
|
| 627 |
-
f"Volume: {stats_dict['volume']:4.3f} (cc); "
|
| 628 |
-
f"Max Dose: {stats_dict['max']:2.3f}; "
|
| 629 |
-
f"Mean Dose: {stats_dict['mean']:2.3f}"
|
| 630 |
-
)
|
| 631 |
-
plt.axvline(x=stats_dict["mean"], color="y", label="Mean")
|
| 632 |
-
plt.axvline(x=stats_dict["max"], color="r", label="Max")
|
| 633 |
-
plt.grid()
|
| 634 |
-
return fig
|
| 635 |
-
|
| 636 |
-
# Initialize tkinter
|
| 637 |
-
root = tk.Tk()
|
| 638 |
-
root.withdraw() # Hide the main window
|
| 639 |
-
|
| 640 |
-
# Select dose file
|
| 641 |
-
dose_file = askopenfilename(
|
| 642 |
-
title="Select Dose File",
|
| 643 |
-
filetypes=[("NIfTI files", "*.nii.gz"), ("All files", "*.*")],
|
| 644 |
-
)
|
| 645 |
-
|
| 646 |
-
if not dose_file:
|
| 647 |
-
return
|
| 648 |
-
|
| 649 |
-
# Read dose array
|
| 650 |
-
dose_image = sitk.ReadImage(dose_file)
|
| 651 |
-
dose_array = sitk.GetArrayFromImage(dose_image)
|
| 652 |
-
|
| 653 |
-
# Select structure files
|
| 654 |
-
structure_files = []
|
| 655 |
-
while True:
|
| 656 |
-
struct_file = askopenfilename(
|
| 657 |
-
title="Select Structure File (Cancel to finish)",
|
| 658 |
-
filetypes=[("NIfTI files", "*.nii.gz"), ("All files", "*.*")],
|
| 659 |
-
)
|
| 660 |
-
if not struct_file:
|
| 661 |
-
break
|
| 662 |
-
structure_files.append(struct_file)
|
| 663 |
-
|
| 664 |
-
if not structure_files:
|
| 665 |
-
print("No structure files selected.")
|
| 666 |
-
return
|
| 667 |
-
|
| 668 |
-
# Select output file
|
| 669 |
-
output_file = asksaveasfilename(
|
| 670 |
-
title="Save DVH Plot As",
|
| 671 |
-
defaultextension=".pdf",
|
| 672 |
-
filetypes=[
|
| 673 |
-
("PDF files", "*.pdf"),
|
| 674 |
-
("PNG files", "*.png"),
|
| 675 |
-
("All files", "*.*"),
|
| 676 |
-
],
|
| 677 |
-
)
|
| 678 |
-
|
| 679 |
-
if not output_file:
|
| 680 |
-
return
|
| 681 |
-
|
| 682 |
-
# Generate plots
|
| 683 |
-
if output_file.endswith(".pdf"):
|
| 684 |
-
with PdfPages(output_file) as pp:
|
| 685 |
-
for struct_file in structure_files:
|
| 686 |
-
stats = compute_stats(struct_file, dose_array)
|
| 687 |
-
fig = plot_stats(stats)
|
| 688 |
-
pp.savefig(fig)
|
| 689 |
-
plt.close()
|
| 690 |
-
else:
|
| 691 |
-
# For single image outputs, plot all structures together
|
| 692 |
-
fig = plt.figure(figsize=(12, 8))
|
| 693 |
-
colors = plt.cm.get_cmap("tab10")(np.linspace(0, 1, len(structure_files)))
|
| 694 |
-
|
| 695 |
-
for i, struct_file in enumerate(structure_files):
|
| 696 |
-
stats = compute_stats(struct_file, dose_array)
|
| 697 |
-
stats["color"] = colors[i]
|
| 698 |
-
plt.plot(
|
| 699 |
-
stats["bins"],
|
| 700 |
-
stats["values"],
|
| 701 |
-
color=stats["color"],
|
| 702 |
-
label=stats["name"],
|
| 703 |
-
)
|
| 704 |
-
|
| 705 |
-
plt.legend(loc="best")
|
| 706 |
-
plt.xlabel("Dose [Gy]")
|
| 707 |
-
plt.ylabel("Ratio of Total Structure Volume [%]")
|
| 708 |
-
plt.title("DVH Comparison")
|
| 709 |
-
plt.grid()
|
| 710 |
-
plt.savefig(output_file, dpi=300, bbox_inches="tight")
|
| 711 |
-
plt.close()
|
| 712 |
-
|
| 713 |
-
print(f"DVH plot saved to: {output_file}")
|
| 714 |
-
root.destroy()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Publication-quality plotting utilities for dosemetrics.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
This module provides functions for creating publication-ready plots at different levels:
|
| 5 |
+
- Structure-level: Plot data for individual structures (DVH, metrics box plots)
|
| 6 |
+
- Subject-level: Plot all structures for one subject
|
| 7 |
+
- Dataset-level: Population-level plots (DVH bands, violin plots, comparisons)
|
| 8 |
+
"""
|
| 9 |
|
| 10 |
+
from __future__ import annotations
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
from typing import Dict, List, Optional, Union, Tuple, Any
|
| 13 |
+
import numpy as np
|
| 14 |
+
import matplotlib.pyplot as plt
|
| 15 |
+
from matplotlib import patches
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
import pandas as pd
|
| 18 |
|
| 19 |
+
from ..dose import Dose
|
| 20 |
+
from ..structures import Structure
|
| 21 |
+
from ..structure_set import StructureSet
|
| 22 |
+
from ..metrics import dvh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
# Color schemes
|
| 26 |
+
DEFAULT_COLORS = plt.cm.tab10.colors
|
| 27 |
+
OAR_COLOR = '#1f77b4' # Blue
|
| 28 |
+
TARGET_COLOR = '#d62728' # Red
|
| 29 |
|
| 30 |
|
| 31 |
+
def plot_dvh(
|
| 32 |
+
dose: Dose,
|
| 33 |
+
structure: Structure,
|
| 34 |
+
bins: int = 1000,
|
| 35 |
+
relative_volume: bool = True,
|
| 36 |
+
ax: Optional[plt.Axes] = None,
|
| 37 |
+
label: Optional[str] = None,
|
| 38 |
+
color: Optional[str] = None,
|
| 39 |
+
**plot_kwargs
|
| 40 |
+
) -> plt.Axes:
|
| 41 |
"""
|
| 42 |
+
Plot dose-volume histogram for a single structure.
|
| 43 |
+
|
| 44 |
+
Parameters
|
| 45 |
+
----------
|
| 46 |
+
dose : Dose
|
| 47 |
+
Dose distribution
|
| 48 |
+
structure : Structure
|
| 49 |
+
Structure to plot DVH for
|
| 50 |
+
bins : int
|
| 51 |
+
Number of bins for DVH computation
|
| 52 |
+
relative_volume : bool
|
| 53 |
+
If True, plot relative volume (%), else absolute volume (cc)
|
| 54 |
+
ax : plt.Axes, optional
|
| 55 |
+
Axis to plot on (creates new if None)
|
| 56 |
+
label : str, optional
|
| 57 |
+
Label for the curve (default: structure name)
|
| 58 |
+
color : str, optional
|
| 59 |
+
Color for the curve
|
| 60 |
+
**plot_kwargs
|
| 61 |
+
Additional arguments passed to plt.plot()
|
| 62 |
+
|
| 63 |
+
Returns
|
| 64 |
+
-------
|
| 65 |
+
ax : plt.Axes
|
| 66 |
+
The plot axis
|
| 67 |
+
|
| 68 |
+
Examples
|
| 69 |
--------
|
| 70 |
+
>>> import matplotlib.pyplot as plt
|
| 71 |
+
>>> from dosemetrics.utils import plot
|
| 72 |
+
>>>
|
| 73 |
+
>>> fig, ax = plt.subplots()
|
| 74 |
+
>>> plot.plot_dvh(dose, ptv, ax=ax, label='PTV', color='red')
|
| 75 |
+
>>> plot.plot_dvh(dose, heart, ax=ax, label='Heart', color='blue')
|
| 76 |
+
>>> plt.legend()
|
| 77 |
+
>>> plt.show()
|
| 78 |
"""
|
| 79 |
+
if ax is None:
|
| 80 |
+
fig, ax = plt.subplots(figsize=(8, 6))
|
| 81 |
+
|
| 82 |
+
# Compute DVH
|
| 83 |
+
# Convert bins to step_size (approximate)
|
| 84 |
+
max_dose = dose.max_dose
|
| 85 |
+
step_size = max_dose / bins if bins > 0 else 0.1
|
| 86 |
+
dose_bins, volumes = dvh.compute_dvh(dose, structure, step_size=step_size)
|
| 87 |
+
|
| 88 |
+
if not relative_volume:
|
| 89 |
+
# DVH returns relative volume by default, convert to absolute if needed
|
| 90 |
+
volumes = volumes / 100.0 * structure.volume_cc if hasattr(structure, 'volume_cc') else volumes
|
| 91 |
+
|
| 92 |
+
# Plot
|
| 93 |
+
if label is None:
|
| 94 |
+
label = structure.name
|
| 95 |
+
|
| 96 |
+
plot_kwargs.setdefault('linewidth', 2)
|
| 97 |
+
if color:
|
| 98 |
+
plot_kwargs['color'] = color
|
| 99 |
+
|
| 100 |
+
ax.plot(dose_bins, volumes, label=label, **plot_kwargs)
|
| 101 |
+
|
| 102 |
+
# Format axis
|
| 103 |
+
ax.set_xlabel('Dose (Gy)', fontsize=12)
|
| 104 |
+
if relative_volume:
|
| 105 |
+
ax.set_ylabel('Volume (%)', fontsize=12)
|
| 106 |
+
ax.set_ylim(0, 105)
|
| 107 |
+
else:
|
| 108 |
+
ax.set_ylabel('Volume (cc)', fontsize=12)
|
| 109 |
+
|
| 110 |
+
ax.grid(True, alpha=0.3)
|
| 111 |
+
ax.spines['top'].set_visible(False)
|
| 112 |
+
ax.spines['right'].set_visible(False)
|
| 113 |
+
|
| 114 |
+
return ax
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def plot_subject_dvhs(
|
| 118 |
+
dose: Dose,
|
| 119 |
+
structures: StructureSet,
|
| 120 |
+
structure_names: Optional[List[str]] = None,
|
| 121 |
+
bins: int = 1000,
|
| 122 |
+
relative_volume: bool = True,
|
| 123 |
+
color_by_type: bool = True,
|
| 124 |
+
figsize: Tuple[float, float] = (10, 7)
|
| 125 |
+
) -> Tuple[plt.Figure, plt.Axes]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
"""
|
| 127 |
+
Plot DVHs for all structures of a subject.
|
| 128 |
+
|
| 129 |
+
Parameters
|
| 130 |
+
----------
|
| 131 |
+
dose : Dose
|
| 132 |
+
Dose distribution
|
| 133 |
+
structures : StructureSet
|
| 134 |
+
Structure set
|
| 135 |
+
structure_names : List[str], optional
|
| 136 |
+
Specific structures to plot (default: all)
|
| 137 |
+
bins : int
|
| 138 |
+
Number of bins
|
| 139 |
+
relative_volume : bool
|
| 140 |
+
Plot relative vs absolute volume
|
| 141 |
+
color_by_type : bool
|
| 142 |
+
Use different colors for targets vs OARs
|
| 143 |
+
figsize : Tuple[float, float]
|
| 144 |
+
Figure size
|
| 145 |
+
|
| 146 |
+
Returns
|
| 147 |
+
-------
|
| 148 |
+
fig, ax : Figure and Axes
|
| 149 |
+
|
| 150 |
+
Examples
|
| 151 |
--------
|
| 152 |
+
>>> from dosemetrics.utils import plot
|
| 153 |
+
>>> fig, ax = plot.plot_subject_dvhs(dose, structures)
|
| 154 |
+
>>> plt.savefig('subject_dvhs.png', dpi=300, bbox_inches='tight')
|
| 155 |
"""
|
| 156 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 157 |
+
|
| 158 |
+
# Filter structures
|
| 159 |
+
if structure_names:
|
| 160 |
+
struct_list = [structures.get_structure(name) for name in structure_names
|
| 161 |
+
if name in structures.structure_names]
|
| 162 |
+
else:
|
| 163 |
+
struct_list = list(structures.structures.values())
|
| 164 |
+
|
| 165 |
+
# Assign colors
|
| 166 |
+
if color_by_type:
|
| 167 |
+
from ..structures import StructureType
|
| 168 |
+
colors = {}
|
| 169 |
+
for s in struct_list:
|
| 170 |
+
if s.structure_type == StructureType.TARGET:
|
| 171 |
+
colors[s.name] = TARGET_COLOR
|
| 172 |
+
else:
|
| 173 |
+
colors[s.name] = OAR_COLOR
|
| 174 |
+
else:
|
| 175 |
+
colors = {s.name: DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
|
| 176 |
+
for i, s in enumerate(struct_list)}
|
| 177 |
+
|
| 178 |
+
# Plot each DVH
|
| 179 |
+
for structure in struct_list:
|
| 180 |
+
plot_dvh(dose, structure, bins=bins, relative_volume=relative_volume,
|
| 181 |
+
ax=ax, color=colors[structure.name])
|
| 182 |
+
|
| 183 |
+
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
|
| 184 |
+
ax.set_title('Dose-Volume Histograms', fontsize=14, fontweight='bold')
|
| 185 |
+
|
| 186 |
+
fig.tight_layout()
|
| 187 |
+
|
| 188 |
+
return fig, ax
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def plot_dvh_comparison(
|
| 192 |
+
dose1: Dose,
|
| 193 |
+
dose2: Dose,
|
| 194 |
+
structure: Structure,
|
| 195 |
+
labels: Tuple[str, str] = ('Dose 1', 'Dose 2'),
|
| 196 |
+
bins: int = 1000,
|
| 197 |
+
relative_volume: bool = True,
|
| 198 |
+
figsize: Tuple[float, float] = (8, 6)
|
| 199 |
+
) -> Tuple[plt.Figure, plt.Axes]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
"""
|
| 201 |
+
Compare DVHs from two different dose distributions.
|
| 202 |
+
|
| 203 |
+
Useful for comparing TPS vs predicted, or different treatment plans.
|
| 204 |
+
|
| 205 |
+
Parameters
|
| 206 |
+
----------
|
| 207 |
+
dose1, dose2 : Dose
|
| 208 |
+
Dose distributions to compare
|
| 209 |
+
structure : Structure
|
| 210 |
+
Structure to analyze
|
| 211 |
+
labels : Tuple[str, str]
|
| 212 |
+
Labels for the two doses
|
| 213 |
+
bins : int
|
| 214 |
+
Number of bins
|
| 215 |
+
relative_volume : bool
|
| 216 |
+
Plot relative vs absolute volume
|
| 217 |
+
figsize : Tuple[float, float]
|
| 218 |
+
Figure size
|
| 219 |
+
|
| 220 |
+
Returns
|
| 221 |
+
-------
|
| 222 |
+
fig, ax : Figure and Axes
|
| 223 |
+
|
| 224 |
+
Examples
|
| 225 |
+
--------
|
| 226 |
+
>>> fig, ax = plot.plot_dvh_comparison(
|
| 227 |
+
... tps_dose, pred_dose, ptv,
|
| 228 |
+
... labels=('TPS', 'Predicted')
|
| 229 |
+
... )
|
| 230 |
"""
|
| 231 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 232 |
+
|
| 233 |
+
# Plot both DVHs
|
| 234 |
+
plot_dvh(dose1, structure, bins=bins, relative_volume=relative_volume,
|
| 235 |
+
ax=ax, label=labels[0], color='#1f77b4', linestyle='-')
|
| 236 |
+
plot_dvh(dose2, structure, bins=bins, relative_volume=relative_volume,
|
| 237 |
+
ax=ax, label=labels[1], color='#ff7f0e', linestyle='--')
|
| 238 |
+
|
| 239 |
+
ax.legend()
|
| 240 |
+
ax.set_title(f'DVH Comparison: {structure.name}', fontsize=14, fontweight='bold')
|
| 241 |
+
|
| 242 |
+
fig.tight_layout()
|
| 243 |
+
|
| 244 |
+
return fig, ax
|
| 245 |
|
| 246 |
|
| 247 |
+
def plot_dvh_band(
|
| 248 |
+
dataset: Dict[str, Dict[str, Union[Dose, StructureSet]]],
|
| 249 |
+
structure_name: str,
|
| 250 |
+
bins: int = 1000,
|
| 251 |
+
relative_volume: bool = True,
|
| 252 |
+
percentiles: Tuple[float, float] = (25, 75),
|
| 253 |
+
show_median: bool = True,
|
| 254 |
+
show_individual: bool = False,
|
| 255 |
+
ax: Optional[plt.Axes] = None,
|
| 256 |
+
color: Optional[str] = None,
|
| 257 |
+
label: Optional[str] = None
|
| 258 |
+
) -> plt.Axes:
|
| 259 |
"""
|
| 260 |
+
Plot DVH band showing population statistics.
|
| 261 |
+
|
| 262 |
+
Creates a band plot showing median and interquartile range across
|
| 263 |
+
multiple subjects for a single structure.
|
| 264 |
+
|
| 265 |
+
Parameters
|
| 266 |
+
----------
|
| 267 |
+
dataset : Dict
|
| 268 |
+
Dataset dictionary from batch.load_dataset()
|
| 269 |
+
structure_name : str
|
| 270 |
+
Structure to plot
|
| 271 |
+
bins : int
|
| 272 |
+
Number of bins
|
| 273 |
+
relative_volume : bool
|
| 274 |
+
Plot relative vs absolute volume
|
| 275 |
+
percentiles : Tuple[float, float]
|
| 276 |
+
Lower and upper percentiles for band
|
| 277 |
+
show_median : bool
|
| 278 |
+
Whether to show median curve
|
| 279 |
+
show_individual : bool
|
| 280 |
+
Whether to show individual DVHs with transparency
|
| 281 |
+
ax : plt.Axes, optional
|
| 282 |
+
Axis to plot on
|
| 283 |
+
color : str, optional
|
| 284 |
+
Color for the band
|
| 285 |
+
label : str, optional
|
| 286 |
+
Label for the legend
|
| 287 |
+
|
| 288 |
+
Returns
|
| 289 |
+
-------
|
| 290 |
+
ax : plt.Axes
|
| 291 |
+
|
| 292 |
+
Examples
|
| 293 |
+
--------
|
| 294 |
+
>>> fig, ax = plt.subplots()
|
| 295 |
+
>>> plot.plot_dvh_band(dataset, 'PTV', ax=ax, color='red', label='PTV')
|
| 296 |
+
>>> plot.plot_dvh_band(dataset, 'Heart', ax=ax, color='blue', label='Heart')
|
| 297 |
+
>>> plt.legend()
|
| 298 |
"""
|
| 299 |
+
if ax is None:
|
| 300 |
+
fig, ax = plt.subplots(figsize=(10, 7))
|
| 301 |
+
|
| 302 |
+
# Collect DVHs from all subjects
|
| 303 |
+
all_dvhs = []
|
| 304 |
+
max_dose = 0
|
| 305 |
+
|
| 306 |
+
for subject_id, data in dataset.items():
|
| 307 |
+
if 'dose' not in data or 'structures' not in data:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
dose = data['dose']
|
| 311 |
+
structures = data['structures']
|
| 312 |
+
structure = structures.get_structure(structure_name) if structure_name in structures else None
|
| 313 |
+
|
| 314 |
+
if structure is None:
|
| 315 |
+
continue
|
| 316 |
+
|
| 317 |
+
try:
|
| 318 |
+
max_dose_val = dose.max_dose
|
| 319 |
+
step_size = max_dose_val / bins if bins > 0 else 0.1
|
| 320 |
+
dose_bins, volumes = dvh.compute_dvh(dose, structure, step_size=step_size)
|
| 321 |
+
|
| 322 |
+
# volumes are already in percentage (0-100)
|
| 323 |
+
|
| 324 |
+
all_dvhs.append((dose_bins, volumes))
|
| 325 |
+
max_dose = max(max_dose, dose_bins[-1])
|
| 326 |
+
|
| 327 |
+
# Plot individual if requested
|
| 328 |
+
if show_individual:
|
| 329 |
+
ax.plot(dose_bins, volumes, alpha=0.1, color=color or 'gray', linewidth=1)
|
| 330 |
+
|
| 331 |
+
except Exception as e:
|
| 332 |
+
print(f"Warning: Error computing DVH for {subject_id}/{structure_name}: {e}")
|
| 333 |
+
|
| 334 |
+
if not all_dvhs:
|
| 335 |
+
print(f"No valid DVHs found for {structure_name}")
|
| 336 |
+
return ax
|
| 337 |
+
|
| 338 |
+
# Create common dose axis
|
| 339 |
+
common_doses = np.linspace(0, max_dose, bins)
|
| 340 |
+
|
| 341 |
+
# Interpolate all DVHs to common dose axis
|
| 342 |
+
interpolated_dvhs = []
|
| 343 |
+
for dose_bins, volumes in all_dvhs:
|
| 344 |
+
interp_volumes = np.interp(common_doses, dose_bins, volumes)
|
| 345 |
+
interpolated_dvhs.append(interp_volumes)
|
| 346 |
+
|
| 347 |
+
dvh_array = np.array(interpolated_dvhs)
|
| 348 |
+
|
| 349 |
+
# Compute statistics
|
| 350 |
+
median_dvh = np.median(dvh_array, axis=0)
|
| 351 |
+
lower_percentile = np.percentile(dvh_array, percentiles[0], axis=0)
|
| 352 |
+
upper_percentile = np.percentile(dvh_array, percentiles[1], axis=0)
|
| 353 |
+
|
| 354 |
+
# Plot band
|
| 355 |
+
if color is None:
|
| 356 |
+
color = DEFAULT_COLORS[0]
|
| 357 |
+
|
| 358 |
+
ax.fill_between(common_doses, lower_percentile, upper_percentile,
|
| 359 |
+
alpha=0.3, color=color, label=f'{label or structure_name} (IQR)')
|
| 360 |
+
|
| 361 |
+
if show_median:
|
| 362 |
+
ax.plot(common_doses, median_dvh, color=color, linewidth=2,
|
| 363 |
+
label=f'{label or structure_name} (median)')
|
| 364 |
+
|
| 365 |
+
# Format
|
| 366 |
+
ax.set_xlabel('Dose (Gy)', fontsize=12)
|
| 367 |
+
if relative_volume:
|
| 368 |
+
ax.set_ylabel('Volume (%)', fontsize=12)
|
| 369 |
+
ax.set_ylim(0, 105)
|
| 370 |
+
else:
|
| 371 |
+
ax.set_ylabel('Volume (cc)', fontsize=12)
|
| 372 |
+
|
| 373 |
+
ax.grid(True, alpha=0.3)
|
| 374 |
+
ax.spines['top'].set_visible(False)
|
| 375 |
+
ax.spines['right'].set_visible(False)
|
| 376 |
+
|
| 377 |
+
return ax
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def plot_metric_boxplot(
|
| 381 |
+
results: pd.DataFrame,
|
| 382 |
+
metric: str,
|
| 383 |
+
group_by: str = 'structure',
|
| 384 |
+
figsize: Tuple[float, float] = (10, 6),
|
| 385 |
+
show_points: bool = True,
|
| 386 |
+
horizontal: bool = False
|
| 387 |
+
) -> Tuple[plt.Figure, plt.Axes]:
|
| 388 |
"""
|
| 389 |
+
Create box plot for a metric across structures or subjects.
|
| 390 |
+
|
| 391 |
+
Parameters
|
| 392 |
+
----------
|
| 393 |
+
results : pd.DataFrame
|
| 394 |
+
Results from analysis functions
|
| 395 |
+
metric : str
|
| 396 |
+
Metric column to plot
|
| 397 |
+
group_by : str
|
| 398 |
+
Column to group by ('structure' or 'subject_id')
|
| 399 |
+
figsize : Tuple[float, float]
|
| 400 |
+
Figure size
|
| 401 |
+
show_points : bool
|
| 402 |
+
Whether to show individual data points
|
| 403 |
+
horizontal : bool
|
| 404 |
+
Whether to make horizontal box plot
|
| 405 |
+
|
| 406 |
+
Returns
|
| 407 |
+
-------
|
| 408 |
+
fig, ax : Figure and Axes
|
| 409 |
+
|
| 410 |
+
Examples
|
| 411 |
+
--------
|
| 412 |
+
>>> from dosemetrics.utils import analysis, plot
|
| 413 |
+
>>> results = analysis.analyze_by_dataset(dataset, metrics)
|
| 414 |
+
>>> fig, ax = plot.plot_metric_boxplot(results[0], 'mean_dose')
|
| 415 |
"""
|
| 416 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 417 |
+
|
| 418 |
+
# Prepare data
|
| 419 |
+
groups = results[group_by].unique()
|
| 420 |
+
data = [results[results[group_by] == g][metric].dropna() for g in groups]
|
| 421 |
+
|
| 422 |
+
# Create box plot
|
| 423 |
+
if horizontal:
|
| 424 |
+
bp = ax.boxplot(data, labels=groups, vert=False, patch_artist=True)
|
| 425 |
+
ax.set_xlabel(metric, fontsize=12)
|
| 426 |
+
ax.set_ylabel(group_by.replace('_', ' ').title(), fontsize=12)
|
| 427 |
+
else:
|
| 428 |
+
bp = ax.boxplot(data, labels=groups, patch_artist=True)
|
| 429 |
+
ax.set_ylabel(metric, fontsize=12)
|
| 430 |
+
ax.set_xlabel(group_by.replace('_', ' ').title(), fontsize=12)
|
| 431 |
+
plt.xticks(rotation=45, ha='right')
|
| 432 |
+
|
| 433 |
+
# Color boxes
|
| 434 |
+
for patch in bp['boxes']:
|
| 435 |
+
patch.set_facecolor(DEFAULT_COLORS[0])
|
| 436 |
+
patch.set_alpha(0.6)
|
| 437 |
+
|
| 438 |
+
# Add individual points
|
| 439 |
+
if show_points:
|
| 440 |
+
for i, (group, d) in enumerate(zip(groups, data)):
|
| 441 |
+
x = np.random.normal(i + 1, 0.04, size=len(d))
|
| 442 |
+
ax.plot(x, d, 'o', alpha=0.3, color='black', markersize=4)
|
| 443 |
+
|
| 444 |
+
ax.grid(True, alpha=0.3, axis='y')
|
| 445 |
+
ax.spines['top'].set_visible(False)
|
| 446 |
+
ax.spines['right'].set_visible(False)
|
| 447 |
+
|
| 448 |
+
fig.tight_layout()
|
| 449 |
+
|
| 450 |
+
return fig, ax
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def plot_metric_comparison(
|
| 454 |
+
results1: pd.DataFrame,
|
| 455 |
+
results2: pd.DataFrame,
|
| 456 |
+
metric: str,
|
| 457 |
+
cohort_names: Tuple[str, str] = ('Cohort 1', 'Cohort 2'),
|
| 458 |
+
structure_names: Optional[List[str]] = None,
|
| 459 |
+
figsize: Tuple[float, float] = (12, 6)
|
| 460 |
+
) -> Tuple[plt.Figure, plt.Axes]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
"""
|
| 462 |
+
Compare a metric between two cohorts.
|
| 463 |
+
|
| 464 |
+
Creates side-by-side box plots for comparison.
|
| 465 |
+
|
| 466 |
+
Parameters
|
| 467 |
+
----------
|
| 468 |
+
results1, results2 : pd.DataFrame
|
| 469 |
+
Results from two cohorts
|
| 470 |
+
metric : str
|
| 471 |
+
Metric to compare
|
| 472 |
+
cohort_names : Tuple[str, str]
|
| 473 |
+
Names for the cohorts
|
| 474 |
+
structure_names : List[str], optional
|
| 475 |
+
Specific structures to include
|
| 476 |
+
figsize : Tuple[float, float]
|
| 477 |
+
Figure size
|
| 478 |
+
|
| 479 |
+
Returns
|
| 480 |
+
-------
|
| 481 |
+
fig, ax : Figure and Axes
|
| 482 |
+
|
| 483 |
+
Examples
|
| 484 |
+
--------
|
| 485 |
+
>>> fig, ax = plot.plot_metric_comparison(
|
| 486 |
+
... pre_results, post_results, 'mean_dose',
|
| 487 |
+
... cohort_names=('Pre-treatment', 'Post-treatment')
|
| 488 |
+
... )
|
| 489 |
"""
|
| 490 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 491 |
+
|
| 492 |
+
# Filter structures if specified
|
| 493 |
+
if structure_names:
|
| 494 |
+
results1 = results1[results1['structure'].isin(structure_names)]
|
| 495 |
+
results2 = results2[results2['structure'].isin(structure_names)]
|
| 496 |
+
|
| 497 |
+
# Get common structures
|
| 498 |
+
structures1 = set(results1['structure'].unique())
|
| 499 |
+
structures2 = set(results2['structure'].unique())
|
| 500 |
+
common_structures = sorted(structures1 & structures2)
|
| 501 |
+
|
| 502 |
+
if not common_structures:
|
| 503 |
+
print("No common structures found")
|
| 504 |
+
return fig, ax
|
| 505 |
+
|
| 506 |
+
# Prepare data for grouped box plot
|
| 507 |
+
x_pos = np.arange(len(common_structures))
|
| 508 |
+
width = 0.35
|
| 509 |
+
|
| 510 |
+
means1 = [results1[results1['structure'] == s][metric].mean() for s in common_structures]
|
| 511 |
+
means2 = [results2[results2['structure'] == s][metric].mean() for s in common_structures]
|
| 512 |
+
|
| 513 |
+
stds1 = [results1[results1['structure'] == s][metric].std() for s in common_structures]
|
| 514 |
+
stds2 = [results2[results2['structure'] == s][metric].std() for s in common_structures]
|
| 515 |
+
|
| 516 |
+
# Create bars
|
| 517 |
+
ax.bar(x_pos - width/2, means1, width, label=cohort_names[0],
|
| 518 |
+
yerr=stds1, capsize=5, alpha=0.8, color=DEFAULT_COLORS[0])
|
| 519 |
+
ax.bar(x_pos + width/2, means2, width, label=cohort_names[1],
|
| 520 |
+
yerr=stds2, capsize=5, alpha=0.8, color=DEFAULT_COLORS[1])
|
| 521 |
+
|
| 522 |
+
# Format
|
| 523 |
+
ax.set_ylabel(metric, fontsize=12)
|
| 524 |
+
ax.set_xlabel('Structure', fontsize=12)
|
| 525 |
+
ax.set_title(f'{metric} Comparison', fontsize=14, fontweight='bold')
|
| 526 |
+
ax.set_xticks(x_pos)
|
| 527 |
+
ax.set_xticklabels(common_structures, rotation=45, ha='right')
|
| 528 |
+
ax.legend()
|
| 529 |
+
ax.grid(True, alpha=0.3, axis='y')
|
| 530 |
+
ax.spines['top'].set_visible(False)
|
| 531 |
+
ax.spines['right'].set_visible(False)
|
| 532 |
+
|
| 533 |
+
fig.tight_layout()
|
| 534 |
+
|
| 535 |
+
return fig, ax
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def plot_dose_slice(
|
| 539 |
+
dose: Dose,
|
| 540 |
+
slice_idx: Optional[int] = None,
|
| 541 |
+
axis: int = 2,
|
| 542 |
+
structures: Optional[StructureSet] = None,
|
| 543 |
+
structure_names: Optional[List[str]] = None,
|
| 544 |
+
vmin: Optional[float] = None,
|
| 545 |
+
vmax: Optional[float] = None,
|
| 546 |
+
cmap: str = 'viridis',
|
| 547 |
+
show_colorbar: bool = True,
|
| 548 |
+
figsize: Tuple[float, float] = (10, 8)
|
| 549 |
+
) -> Tuple[plt.Figure, plt.Axes]:
|
| 550 |
"""
|
| 551 |
+
Plot a 2D slice of dose distribution with optional structure contours.
|
| 552 |
+
|
| 553 |
+
Parameters
|
| 554 |
+
----------
|
| 555 |
+
dose : Dose
|
| 556 |
+
Dose distribution
|
| 557 |
+
slice_idx : int, optional
|
| 558 |
+
Slice index (default: middle slice)
|
| 559 |
+
axis : int
|
| 560 |
+
Axis to slice along (0=sagittal, 1=coronal, 2=axial)
|
| 561 |
+
structures : StructureSet, optional
|
| 562 |
+
Structures to overlay
|
| 563 |
+
structure_names : List[str], optional
|
| 564 |
+
Specific structures to show
|
| 565 |
+
vmin, vmax : float, optional
|
| 566 |
+
Dose value range for colormap
|
| 567 |
+
cmap : str
|
| 568 |
+
Colormap name
|
| 569 |
+
show_colorbar : bool
|
| 570 |
+
Whether to show colorbar
|
| 571 |
+
figsize : Tuple[float, float]
|
| 572 |
+
Figure size
|
| 573 |
+
|
| 574 |
+
Returns
|
| 575 |
+
-------
|
| 576 |
+
fig, ax : Figure and Axes
|
| 577 |
+
|
| 578 |
+
Examples
|
| 579 |
+
--------
|
| 580 |
+
>>> fig, ax = plot.plot_dose_slice(
|
| 581 |
+
... dose, structures=structures,
|
| 582 |
+
... structure_names=['PTV', 'Heart']
|
| 583 |
+
... )
|
| 584 |
"""
|
| 585 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 586 |
+
|
| 587 |
+
# Get middle slice if not specified
|
| 588 |
+
if slice_idx is None:
|
| 589 |
+
slice_idx = dose.dose_array.shape[axis] // 2
|
| 590 |
+
|
| 591 |
+
# Extract slice
|
| 592 |
+
if axis == 0:
|
| 593 |
+
dose_slice = dose.dose_array[slice_idx, :, :]
|
| 594 |
+
elif axis == 1:
|
| 595 |
+
dose_slice = dose.dose_array[:, slice_idx, :]
|
| 596 |
+
else: # axis == 2
|
| 597 |
+
dose_slice = dose.dose_array[:, :, slice_idx]
|
| 598 |
+
|
| 599 |
+
# Plot dose
|
| 600 |
+
im = ax.imshow(dose_slice.T, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax,
|
| 601 |
+
aspect='equal', interpolation='bilinear')
|
| 602 |
+
|
| 603 |
+
# Add colorbar
|
| 604 |
+
if show_colorbar:
|
| 605 |
+
cbar = plt.colorbar(im, ax=ax)
|
| 606 |
+
cbar.set_label('Dose (Gy)', fontsize=12)
|
| 607 |
+
|
| 608 |
+
# Overlay structure contours
|
| 609 |
+
if structures:
|
| 610 |
+
struct_list = [s for s in structures if s.name in structure_names] if structure_names else list(structures)
|
| 611 |
+
|
| 612 |
+
for i, structure in enumerate(struct_list):
|
| 613 |
+
# Get contour on this slice
|
| 614 |
+
# Note: This is a simplified version - actual implementation would need
|
| 615 |
+
# proper coordinate transformation and contour extraction
|
| 616 |
+
color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
|
| 617 |
+
|
| 618 |
+
# Placeholder for contour plotting
|
| 619 |
+
# In practice, you'd extract the contour points for this slice
|
| 620 |
+
# and plot them using ax.plot()
|
| 621 |
+
|
| 622 |
+
ax.set_xlabel('X (pixels)', fontsize=12)
|
| 623 |
+
ax.set_ylabel('Y (pixels)', fontsize=12)
|
| 624 |
+
ax.set_title(f'Dose Distribution - Slice {slice_idx}', fontsize=14, fontweight='bold')
|
| 625 |
+
|
| 626 |
+
fig.tight_layout()
|
| 627 |
+
|
| 628 |
+
return fig, ax
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def save_figure(
|
| 632 |
+
fig: plt.Figure,
|
| 633 |
+
filepath: Union[str, Path],
|
| 634 |
+
dpi: int = 300,
|
| 635 |
+
formats: List[str] = ['png'],
|
| 636 |
+
**savefig_kwargs
|
| 637 |
+
) -> None:
|
| 638 |
"""
|
| 639 |
+
Save figure in multiple formats with publication-quality settings.
|
| 640 |
+
|
| 641 |
+
Parameters
|
| 642 |
+
----------
|
| 643 |
+
fig : plt.Figure
|
| 644 |
+
Figure to save
|
| 645 |
+
filepath : str or Path
|
| 646 |
+
Output path (without extension)
|
| 647 |
+
dpi : int
|
| 648 |
+
Resolution for raster formats
|
| 649 |
+
formats : List[str]
|
| 650 |
+
Formats to save (e.g., ['png', 'pdf', 'svg'])
|
| 651 |
+
**savefig_kwargs
|
| 652 |
+
Additional arguments for fig.savefig()
|
| 653 |
+
|
| 654 |
+
Examples
|
| 655 |
+
--------
|
| 656 |
+
>>> fig, ax = plot.plot_dvh(dose, structure)
|
| 657 |
+
>>> plot.save_figure(fig, 'figures/ptv_dvh', formats=['png', 'pdf'])
|
| 658 |
"""
|
| 659 |
+
filepath = Path(filepath)
|
| 660 |
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
| 661 |
+
|
| 662 |
+
savefig_kwargs.setdefault('bbox_inches', 'tight')
|
| 663 |
+
savefig_kwargs.setdefault('dpi', dpi)
|
| 664 |
+
|
| 665 |
+
for fmt in formats:
|
| 666 |
+
output_path = filepath.with_suffix(f'.{fmt}')
|
| 667 |
+
fig.savefig(output_path, **savefig_kwargs)
|
| 668 |
+
print(f"Saved: {output_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/dosemetrics_app/app.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
| 1 |
import hmac
|
| 2 |
import streamlit as st
|
| 3 |
|
| 4 |
-
from dosemetrics_app.tabs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def check_password():
|
|
@@ -35,7 +41,7 @@ def check_password():
|
|
| 35 |
# Show inputs for username + password.
|
| 36 |
login_form()
|
| 37 |
if "password_correct" in st.session_state:
|
| 38 |
-
st.error("
|
| 39 |
return False
|
| 40 |
|
| 41 |
|
|
@@ -44,27 +50,32 @@ def main_loop():
|
|
| 44 |
# if not check_password():
|
| 45 |
# st.stop()
|
| 46 |
|
| 47 |
-
def
|
| 48 |
-
st.markdown("#
|
| 49 |
-
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
st.markdown("#
|
| 53 |
-
|
| 54 |
|
| 55 |
-
def
|
| 56 |
-
st.markdown("#
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
def instructions_page():
|
| 60 |
st.markdown("# Instructions")
|
| 61 |
instructions.panel()
|
| 62 |
|
| 63 |
page_names_to_funcs = {
|
| 64 |
-
"Calculate DVH": calculate_dvh_page,
|
| 65 |
-
"Visualize Dose": visualize_dose_page,
|
| 66 |
-
"Contour Variation Robustness": dice_dvh_analysis,
|
| 67 |
"Instructions": instructions_page,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
task_selection = st.sidebar.selectbox("Choose a task:", page_names_to_funcs.keys())
|
|
|
|
| 1 |
import hmac
|
| 2 |
import streamlit as st
|
| 3 |
|
| 4 |
+
from dosemetrics_app.tabs import (
|
| 5 |
+
comprehensive_analysis,
|
| 6 |
+
geometric_tab,
|
| 7 |
+
gamma_tab,
|
| 8 |
+
compliance_tab,
|
| 9 |
+
instructions,
|
| 10 |
+
)
|
| 11 |
|
| 12 |
|
| 13 |
def check_password():
|
|
|
|
| 41 |
# Show inputs for username + password.
|
| 42 |
login_form()
|
| 43 |
if "password_correct" in st.session_state:
|
| 44 |
+
st.error("User not known or password incorrect")
|
| 45 |
return False
|
| 46 |
|
| 47 |
|
|
|
|
| 50 |
# if not check_password():
|
| 51 |
# st.stop()
|
| 52 |
|
| 53 |
+
def comprehensive_analysis_page():
|
| 54 |
+
st.markdown("# Dosimetric Analysis")
|
| 55 |
+
comprehensive_analysis.panel()
|
| 56 |
|
| 57 |
+
def geometric_page():
|
| 58 |
+
st.markdown("# Geometric Comparison")
|
| 59 |
+
geometric_tab.panel()
|
| 60 |
|
| 61 |
+
def gamma_page():
|
| 62 |
+
st.markdown("# Gamma Analysis")
|
| 63 |
+
gamma_tab.panel()
|
| 64 |
+
|
| 65 |
+
def compliance_page():
|
| 66 |
+
st.markdown("# Compliance Checking")
|
| 67 |
+
compliance_tab.panel()
|
| 68 |
|
| 69 |
def instructions_page():
|
| 70 |
st.markdown("# Instructions")
|
| 71 |
instructions.panel()
|
| 72 |
|
| 73 |
page_names_to_funcs = {
|
|
|
|
|
|
|
|
|
|
| 74 |
"Instructions": instructions_page,
|
| 75 |
+
"Dosimetric Analysis": comprehensive_analysis_page,
|
| 76 |
+
"Geometric Comparison": geometric_page,
|
| 77 |
+
"Gamma Analysis": gamma_page,
|
| 78 |
+
"Compliance Checking": compliance_page,
|
| 79 |
}
|
| 80 |
|
| 81 |
task_selection = st.sidebar.selectbox("Choose a task:", page_names_to_funcs.keys())
|
src/dosemetrics_app/tabs/__init__.py
CHANGED
|
@@ -2,25 +2,16 @@
|
|
| 2 |
Streamlit app tabs for the dosemetrics application.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
from .
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
generate_dvh_family,
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
from . import calculate_dvh
|
| 13 |
-
from . import visualize_dose
|
| 14 |
from . import instructions
|
| 15 |
-
from . import variations
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
"calculate_dvh",
|
| 23 |
-
"visualize_dose",
|
| 24 |
"instructions",
|
| 25 |
-
"variations",
|
| 26 |
]
|
|
|
|
| 2 |
Streamlit app tabs for the dosemetrics application.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from . import comprehensive_analysis
|
| 6 |
+
from . import geometric_tab
|
| 7 |
+
from . import gamma_tab
|
| 8 |
+
from . import compliance_tab
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from . import instructions
|
|
|
|
| 10 |
|
| 11 |
__all__ = [
|
| 12 |
+
"comprehensive_analysis",
|
| 13 |
+
"geometric_tab",
|
| 14 |
+
"gamma_tab",
|
| 15 |
+
"compliance_tab",
|
|
|
|
|
|
|
| 16 |
"instructions",
|
|
|
|
| 17 |
]
|
src/dosemetrics_app/tabs/calculate_dvh.py
CHANGED
|
@@ -2,23 +2,78 @@ import streamlit as st
|
|
| 2 |
import pandas as pd
|
| 3 |
import numpy as np
|
| 4 |
import plotly.express as px
|
|
|
|
| 5 |
|
| 6 |
-
from
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def request_dose_and_masks(instruction_text):
|
| 11 |
-
"""Helper function to request dose and mask file uploads"""
|
| 12 |
st.markdown(instruction_text)
|
| 13 |
st.markdown(f"Check instructions on the sidebar for more information.")
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
mask_files = st.file_uploader(
|
| 19 |
-
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 20 |
)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
return dose_file, mask_files
|
| 23 |
|
| 24 |
|
|
|
|
| 2 |
import pandas as pd
|
| 3 |
import numpy as np
|
| 4 |
import plotly.express as px
|
| 5 |
+
from io import BytesIO
|
| 6 |
|
| 7 |
+
from dosemetrics_app.utils import (
|
| 8 |
+
read_byte_data,
|
| 9 |
+
get_example_datasets,
|
| 10 |
+
load_example_files,
|
| 11 |
+
dvh_by_structure,
|
| 12 |
+
)
|
| 13 |
|
| 14 |
|
| 15 |
def request_dose_and_masks(instruction_text):
|
| 16 |
+
"""Helper function to request dose and mask file uploads or example selection"""
|
| 17 |
st.markdown(instruction_text)
|
| 18 |
st.markdown(f"Check instructions on the sidebar for more information.")
|
| 19 |
|
| 20 |
+
# Add option to use example data
|
| 21 |
+
data_source = st.radio(
|
| 22 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
+
dose_file = None
|
| 26 |
+
mask_files = None
|
| 27 |
+
|
| 28 |
+
if data_source == "Upload your own files":
|
| 29 |
+
dose_file = st.file_uploader(
|
| 30 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 31 |
+
)
|
| 32 |
+
mask_files = st.file_uploader(
|
| 33 |
+
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 34 |
+
)
|
| 35 |
+
else:
|
| 36 |
+
# Load example data
|
| 37 |
+
example_datasets = get_example_datasets()
|
| 38 |
+
if example_datasets:
|
| 39 |
+
# Get list of dataset names with test_subject first
|
| 40 |
+
dataset_names = list(example_datasets.keys())
|
| 41 |
+
default_index = (
|
| 42 |
+
dataset_names.index("test_subject")
|
| 43 |
+
if "test_subject" in dataset_names
|
| 44 |
+
else 0
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
selected_dataset = st.selectbox(
|
| 48 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if selected_dataset:
|
| 52 |
+
dataset_path = example_datasets[selected_dataset]
|
| 53 |
+
with st.spinner("Loading example data..."):
|
| 54 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 55 |
+
|
| 56 |
+
if dose_path:
|
| 57 |
+
# Read files and create BytesIO objects for compatibility
|
| 58 |
+
with open(dose_path, "rb") as f:
|
| 59 |
+
dose_bytes = BytesIO(f.read())
|
| 60 |
+
dose_bytes.name = dose_path.name
|
| 61 |
+
dose_file = dose_bytes
|
| 62 |
+
|
| 63 |
+
mask_files = []
|
| 64 |
+
for mask_path in mask_paths:
|
| 65 |
+
with open(mask_path, "rb") as f:
|
| 66 |
+
mask_bytes = BytesIO(f.read())
|
| 67 |
+
mask_bytes.name = mask_path.name
|
| 68 |
+
mask_files.append(mask_bytes)
|
| 69 |
+
|
| 70 |
+
st.success(
|
| 71 |
+
f"Loaded {len(mask_files)} structures from {selected_dataset}"
|
| 72 |
+
)
|
| 73 |
+
else:
|
| 74 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 75 |
+
data_source = "Upload your own files"
|
| 76 |
+
|
| 77 |
return dose_file, mask_files
|
| 78 |
|
| 79 |
|
src/dosemetrics_app/tabs/compliance_tab.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compliance checking tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
from dosemetrics_app.utils import read_byte_data
|
| 11 |
+
from dosemetrics import Dose, StructureSet, get_default_constraints, check_compliance
|
| 12 |
+
from dosemetrics.metrics import dvh
|
| 13 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def request_dose_and_masks(instruction_text):
|
| 17 |
+
"""Helper function to request dose and mask file uploads or example selection"""
|
| 18 |
+
st.markdown(instruction_text)
|
| 19 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 20 |
+
|
| 21 |
+
# Add option to use example data
|
| 22 |
+
data_source = st.radio(
|
| 23 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
dose_file = None
|
| 27 |
+
mask_files = None
|
| 28 |
+
|
| 29 |
+
if data_source == "Upload your own files":
|
| 30 |
+
dose_file = st.file_uploader(
|
| 31 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 32 |
+
)
|
| 33 |
+
mask_files = st.file_uploader(
|
| 34 |
+
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
# Load example data
|
| 38 |
+
example_datasets = get_example_datasets()
|
| 39 |
+
if example_datasets:
|
| 40 |
+
# Get list of dataset names with test_subject first
|
| 41 |
+
dataset_names = list(example_datasets.keys())
|
| 42 |
+
default_index = (
|
| 43 |
+
dataset_names.index("test_subject")
|
| 44 |
+
if "test_subject" in dataset_names
|
| 45 |
+
else 0
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
selected_dataset = st.selectbox(
|
| 49 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if selected_dataset:
|
| 53 |
+
dataset_path = example_datasets[selected_dataset]
|
| 54 |
+
with st.spinner("Loading example data..."):
|
| 55 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 56 |
+
|
| 57 |
+
if dose_path:
|
| 58 |
+
# Read files and create BytesIO objects for compatibility
|
| 59 |
+
with open(dose_path, "rb") as f:
|
| 60 |
+
dose_bytes = BytesIO(f.read())
|
| 61 |
+
dose_bytes.name = dose_path.name
|
| 62 |
+
dose_file = dose_bytes
|
| 63 |
+
|
| 64 |
+
mask_files = []
|
| 65 |
+
for mask_path in mask_paths:
|
| 66 |
+
with open(mask_path, "rb") as f:
|
| 67 |
+
mask_bytes = BytesIO(f.read())
|
| 68 |
+
mask_bytes.name = mask_path.name
|
| 69 |
+
mask_files.append(mask_bytes)
|
| 70 |
+
|
| 71 |
+
st.success(
|
| 72 |
+
f"Loaded {len(mask_files)} structures from {selected_dataset}"
|
| 73 |
+
)
|
| 74 |
+
else:
|
| 75 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 76 |
+
data_source = "Upload your own files"
|
| 77 |
+
|
| 78 |
+
return dose_file, mask_files
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def panel():
|
| 82 |
+
"""Main panel function for Compliance Checking tab"""
|
| 83 |
+
st.sidebar.success("Select an option above.")
|
| 84 |
+
|
| 85 |
+
instruction_text = "## Step 1: Upload dose distribution volume and mask files"
|
| 86 |
+
dose_file, mask_files = request_dose_and_masks(instruction_text)
|
| 87 |
+
files_uploaded = (dose_file is not None) and (
|
| 88 |
+
mask_files is not None and len(mask_files) > 0
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
if files_uploaded:
|
| 92 |
+
st.divider()
|
| 93 |
+
st.markdown("## Step 2: Select constraint set")
|
| 94 |
+
|
| 95 |
+
constraint_option = st.radio(
|
| 96 |
+
"Constraint set:",
|
| 97 |
+
["Use default constraints", "Upload custom constraints"],
|
| 98 |
+
horizontal=True,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
constraints = None
|
| 102 |
+
if constraint_option == "Use default constraints":
|
| 103 |
+
constraints = get_default_constraints()
|
| 104 |
+
st.info(f"Using default constraints for {len(constraints)} structures")
|
| 105 |
+
else:
|
| 106 |
+
constraints_file = st.file_uploader(
|
| 107 |
+
"Upload custom constraints CSV file",
|
| 108 |
+
type=["csv"],
|
| 109 |
+
help="CSV file with columns: Structure (index), Constraint Type, Level",
|
| 110 |
+
)
|
| 111 |
+
if constraints_file:
|
| 112 |
+
constraints = pd.read_csv(constraints_file, index_col=0)
|
| 113 |
+
st.info(f"Loaded custom constraints for {len(constraints)} structures")
|
| 114 |
+
|
| 115 |
+
if constraints is not None:
|
| 116 |
+
# Show constraint preview
|
| 117 |
+
with st.expander("View constraints"):
|
| 118 |
+
st.dataframe(constraints, use_container_width=True)
|
| 119 |
+
|
| 120 |
+
st.divider()
|
| 121 |
+
st.markdown("## Step 3: Check compliance")
|
| 122 |
+
|
| 123 |
+
if st.button("Check Compliance") and constraints is not None:
|
| 124 |
+
with st.spinner("Loading data and checking compliance..."):
|
| 125 |
+
# Load data (read_byte_data returns Dose object and structures)
|
| 126 |
+
dose, structure_masks = read_byte_data(dose_file, mask_files)
|
| 127 |
+
|
| 128 |
+
# Create StructureSet and add structures
|
| 129 |
+
structure_set = StructureSet()
|
| 130 |
+
structure_set.spacing = dose.spacing
|
| 131 |
+
structure_set.origin = dose.origin
|
| 132 |
+
for name, struct in structure_masks.items():
|
| 133 |
+
structure_set.structures[name] = struct
|
| 134 |
+
|
| 135 |
+
# Compute statistics for all structures
|
| 136 |
+
stats_data = []
|
| 137 |
+
for struct in structure_set.structures.values():
|
| 138 |
+
stats_data.append(
|
| 139 |
+
{
|
| 140 |
+
"Structure": struct.name,
|
| 141 |
+
"Mean Dose": dvh.compute_mean_dose(dose, struct),
|
| 142 |
+
"Max Dose": dvh.compute_max_dose(dose, struct),
|
| 143 |
+
"Min Dose": dvh.compute_min_dose(dose, struct),
|
| 144 |
+
}
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
stats_df = pd.DataFrame(stats_data).set_index("Structure")
|
| 148 |
+
|
| 149 |
+
# Check compliance
|
| 150 |
+
compliance_df = check_compliance(stats_df, constraints)
|
| 151 |
+
|
| 152 |
+
st.success("Compliance checking completed")
|
| 153 |
+
|
| 154 |
+
# Display results
|
| 155 |
+
st.markdown("### Compliance Results")
|
| 156 |
+
|
| 157 |
+
# Add color coding to compliance column
|
| 158 |
+
def highlight_compliance(row):
|
| 159 |
+
if "No" in str(row["Compliance"]) or "❌" in str(row["Compliance"]):
|
| 160 |
+
return ["background-color: #ffcccc"] * len(row)
|
| 161 |
+
elif "Yes" in str(row["Compliance"]) or "✅" in str(row["Compliance"]):
|
| 162 |
+
return ["background-color: #ccffcc"] * len(row)
|
| 163 |
+
else:
|
| 164 |
+
return [""] * len(row)
|
| 165 |
+
|
| 166 |
+
styled_df = compliance_df.style.apply(highlight_compliance, axis=1)
|
| 167 |
+
st.dataframe(styled_df, use_container_width=True)
|
| 168 |
+
|
| 169 |
+
# Summary statistics
|
| 170 |
+
st.divider()
|
| 171 |
+
st.markdown("### Summary")
|
| 172 |
+
|
| 173 |
+
# Count compliant vs non-compliant
|
| 174 |
+
compliant_count = sum(
|
| 175 |
+
"Yes" in str(c) or "✅" in str(c) for c in compliance_df["Compliance"]
|
| 176 |
+
)
|
| 177 |
+
non_compliant_count = sum(
|
| 178 |
+
"No" in str(c) or "❌" in str(c) for c in compliance_df["Compliance"]
|
| 179 |
+
)
|
| 180 |
+
total_count = len(compliance_df)
|
| 181 |
+
|
| 182 |
+
col1, col2, col3 = st.columns(3)
|
| 183 |
+
|
| 184 |
+
with col1:
|
| 185 |
+
st.metric("Total Structures", total_count)
|
| 186 |
+
|
| 187 |
+
with col2:
|
| 188 |
+
st.metric(
|
| 189 |
+
"Compliant",
|
| 190 |
+
compliant_count,
|
| 191 |
+
delta=f"{100*compliant_count/total_count:.1f}%",
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
with col3:
|
| 195 |
+
st.metric(
|
| 196 |
+
"Non-Compliant",
|
| 197 |
+
non_compliant_count,
|
| 198 |
+
delta=f"{100*non_compliant_count/total_count:.1f}%",
|
| 199 |
+
delta_color="inverse",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Visualization
|
| 203 |
+
st.divider()
|
| 204 |
+
st.markdown("### Visualization")
|
| 205 |
+
|
| 206 |
+
# Compliance pie chart
|
| 207 |
+
compliance_summary = pd.DataFrame(
|
| 208 |
+
{
|
| 209 |
+
"Status": ["Compliant", "Non-Compliant"],
|
| 210 |
+
"Count": [compliant_count, non_compliant_count],
|
| 211 |
+
}
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
fig_pie = px.pie(
|
| 215 |
+
compliance_summary,
|
| 216 |
+
values="Count",
|
| 217 |
+
names="Status",
|
| 218 |
+
title="Compliance Status Distribution",
|
| 219 |
+
color="Status",
|
| 220 |
+
color_discrete_map={"Compliant": "green", "Non-Compliant": "red"},
|
| 221 |
+
)
|
| 222 |
+
st.plotly_chart(fig_pie, use_container_width=True)
|
| 223 |
+
|
| 224 |
+
# Dose statistics with constraint overlay
|
| 225 |
+
if len(stats_df) > 0:
|
| 226 |
+
st.markdown("### Dose Statistics vs Constraints")
|
| 227 |
+
|
| 228 |
+
# Merge stats with constraints for structures that have constraints
|
| 229 |
+
merged_data = []
|
| 230 |
+
for struct_name in stats_df.index:
|
| 231 |
+
if struct_name in constraints.index:
|
| 232 |
+
constraint_type = constraints.loc[
|
| 233 |
+
struct_name, "Constraint Type"
|
| 234 |
+
]
|
| 235 |
+
constraint_level = constraints.loc[struct_name, "Level"]
|
| 236 |
+
|
| 237 |
+
if constraint_type == "mean":
|
| 238 |
+
actual_dose = stats_df.loc[struct_name, "Mean Dose"]
|
| 239 |
+
elif constraint_type == "max":
|
| 240 |
+
actual_dose = stats_df.loc[struct_name, "Max Dose"]
|
| 241 |
+
elif constraint_type == "min":
|
| 242 |
+
actual_dose = stats_df.loc[struct_name, "Min Dose"]
|
| 243 |
+
else:
|
| 244 |
+
continue
|
| 245 |
+
|
| 246 |
+
merged_data.append(
|
| 247 |
+
{
|
| 248 |
+
"Structure": struct_name,
|
| 249 |
+
"Constraint Type": constraint_type,
|
| 250 |
+
"Actual Dose": actual_dose,
|
| 251 |
+
"Constraint Level": constraint_level,
|
| 252 |
+
"Difference": actual_dose - constraint_level,
|
| 253 |
+
}
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
if merged_data:
|
| 257 |
+
merged_df = pd.DataFrame(merged_data)
|
| 258 |
+
|
| 259 |
+
fig_comparison = px.bar(
|
| 260 |
+
merged_df,
|
| 261 |
+
x="Structure",
|
| 262 |
+
y=["Actual Dose", "Constraint Level"],
|
| 263 |
+
title="Actual Dose vs Constraint Levels",
|
| 264 |
+
labels={"value": "Dose (Gy)", "variable": "Metric"},
|
| 265 |
+
barmode="group",
|
| 266 |
+
)
|
| 267 |
+
st.plotly_chart(fig_comparison, use_container_width=True)
|
| 268 |
+
|
| 269 |
+
# Download results
|
| 270 |
+
csv = compliance_df.to_csv()
|
| 271 |
+
st.download_button(
|
| 272 |
+
label="Download compliance results as CSV",
|
| 273 |
+
data=csv,
|
| 274 |
+
file_name="compliance_results.csv",
|
| 275 |
+
mime="text/csv",
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Explanation
|
| 279 |
+
st.markdown(
|
| 280 |
+
"""
|
| 281 |
+
### Interpretation
|
| 282 |
+
|
| 283 |
+
This analysis checks whether dose statistics for each structure meet the specified constraints:
|
| 284 |
+
|
| 285 |
+
- **Max constraint**: Maximum dose in structure must not exceed the constraint level
|
| 286 |
+
- **Mean constraint**: Mean dose in structure must not exceed the constraint level
|
| 287 |
+
- **Min constraint**: Minimum dose in structure must meet or exceed the constraint level (typically for targets)
|
| 288 |
+
|
| 289 |
+
Structures marked as compliant meet their respective constraints, while non-compliant structures
|
| 290 |
+
exceed the constraint thresholds. The reason column provides details on the specific constraint
|
| 291 |
+
violation or compliance margin.
|
| 292 |
+
|
| 293 |
+
### Clinical Relevance
|
| 294 |
+
|
| 295 |
+
Compliance checking helps ensure treatment plans meet clinical protocol requirements and
|
| 296 |
+
safety constraints. Non-compliant structures may require plan optimization or protocol
|
| 297 |
+
deviation documentation.
|
| 298 |
+
"""
|
| 299 |
+
)
|
src/dosemetrics_app/tabs/comprehensive_analysis.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comprehensive dosimetric analysis tab with DVH, statistics, conformity, and homogeneity metrics.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import numpy as np
|
| 8 |
+
import plotly.express as px
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
from plotly.subplots import make_subplots
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
|
| 13 |
+
from dosemetrics_app.utils import (
|
| 14 |
+
read_byte_data,
|
| 15 |
+
get_example_datasets,
|
| 16 |
+
load_example_files,
|
| 17 |
+
dvh_by_structure,
|
| 18 |
+
)
|
| 19 |
+
from dosemetrics import Dose, StructureSet
|
| 20 |
+
from dosemetrics.metrics import dvh, conformity, homogeneity
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def request_dose_and_masks(instruction_text):
|
| 24 |
+
"""Helper function to request dose and mask file uploads or example selection"""
|
| 25 |
+
st.markdown(instruction_text)
|
| 26 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 27 |
+
|
| 28 |
+
# Add option to use example data
|
| 29 |
+
data_source = st.radio(
|
| 30 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
dose_file = None
|
| 34 |
+
mask_files = None
|
| 35 |
+
|
| 36 |
+
if data_source == "Upload your own files":
|
| 37 |
+
dose_file = st.file_uploader(
|
| 38 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 39 |
+
)
|
| 40 |
+
mask_files = st.file_uploader(
|
| 41 |
+
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 42 |
+
)
|
| 43 |
+
else:
|
| 44 |
+
# Load example data
|
| 45 |
+
example_datasets = get_example_datasets()
|
| 46 |
+
if example_datasets:
|
| 47 |
+
# Get list of dataset names with test_subject first
|
| 48 |
+
dataset_names = list(example_datasets.keys())
|
| 49 |
+
default_index = (
|
| 50 |
+
dataset_names.index("test_subject")
|
| 51 |
+
if "test_subject" in dataset_names
|
| 52 |
+
else 0
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
selected_dataset = st.selectbox(
|
| 56 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if selected_dataset:
|
| 60 |
+
dataset_path = example_datasets[selected_dataset]
|
| 61 |
+
with st.spinner("Loading example data..."):
|
| 62 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 63 |
+
|
| 64 |
+
if dose_path:
|
| 65 |
+
dose_file = dose_path
|
| 66 |
+
mask_files = mask_paths
|
| 67 |
+
|
| 68 |
+
st.success(
|
| 69 |
+
f"Loaded example data: {len(mask_paths)} structures found"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return dose_file, mask_files
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def plot_structure_slice(
|
| 76 |
+
main_struct, other_structures, slice_idx=None, axis="axial", dose=None
|
| 77 |
+
):
|
| 78 |
+
"""Plot a slice of a structure with other structure overlays and optional dose"""
|
| 79 |
+
if slice_idx is None:
|
| 80 |
+
slice_idx = (
|
| 81 |
+
main_struct.shape[0] // 2 if axis == "axial" else main_struct.shape[1] // 2
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Get structure slice
|
| 85 |
+
if axis == "axial":
|
| 86 |
+
main_slice = main_struct.mask[slice_idx, :, :]
|
| 87 |
+
elif axis == "coronal":
|
| 88 |
+
main_slice = main_struct.mask[:, slice_idx, :]
|
| 89 |
+
else: # sagittal
|
| 90 |
+
main_slice = main_struct.mask[:, :, slice_idx]
|
| 91 |
+
|
| 92 |
+
# Create figure
|
| 93 |
+
fig = go.Figure()
|
| 94 |
+
|
| 95 |
+
# Add main structure as heatmap
|
| 96 |
+
fig.add_trace(
|
| 97 |
+
go.Heatmap(
|
| 98 |
+
z=main_slice.astype(float),
|
| 99 |
+
colorscale="Viridis",
|
| 100 |
+
name=main_struct.name,
|
| 101 |
+
colorbar=dict(title=main_struct.name),
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Optionally add dose as contours
|
| 106 |
+
if dose is not None:
|
| 107 |
+
if axis == "axial":
|
| 108 |
+
dose_slice = dose.dose_array[slice_idx, :, :]
|
| 109 |
+
elif axis == "coronal":
|
| 110 |
+
dose_slice = dose.dose_array[:, slice_idx, :]
|
| 111 |
+
else:
|
| 112 |
+
dose_slice = dose.dose_array[:, :, slice_idx]
|
| 113 |
+
|
| 114 |
+
fig.add_trace(
|
| 115 |
+
go.Contour(
|
| 116 |
+
z=dose_slice,
|
| 117 |
+
showscale=True,
|
| 118 |
+
contours=dict(coloring="lines"),
|
| 119 |
+
line=dict(width=1),
|
| 120 |
+
name="Dose",
|
| 121 |
+
colorbar=dict(title="Dose (Gy)", x=1.1),
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Add other structure contours
|
| 126 |
+
colors = ["cyan", "yellow", "magenta", "red", "blue", "orange", "white"]
|
| 127 |
+
for idx, (name, struct) in enumerate(other_structures.items()):
|
| 128 |
+
if axis == "axial":
|
| 129 |
+
mask_slice = struct.mask[slice_idx, :, :]
|
| 130 |
+
elif axis == "coronal":
|
| 131 |
+
mask_slice = struct.mask[:, slice_idx, :]
|
| 132 |
+
else:
|
| 133 |
+
mask_slice = struct.mask[:, :, slice_idx]
|
| 134 |
+
|
| 135 |
+
if mask_slice.sum() > 0:
|
| 136 |
+
fig.add_trace(
|
| 137 |
+
go.Contour(
|
| 138 |
+
z=mask_slice.astype(float),
|
| 139 |
+
showscale=False,
|
| 140 |
+
contours=dict(start=0.5, end=0.5, size=1),
|
| 141 |
+
line=dict(color=colors[idx % len(colors)], width=2),
|
| 142 |
+
name=name,
|
| 143 |
+
hoverinfo="name",
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
fig.update_layout(
|
| 148 |
+
title=f"{main_struct.name} - {axis.capitalize()} view (slice {slice_idx})",
|
| 149 |
+
height=600,
|
| 150 |
+
xaxis=dict(showgrid=False, zeroline=False),
|
| 151 |
+
yaxis=dict(showgrid=False, zeroline=False, scaleanchor="x", scaleratio=1),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return fig
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def plot_dose_only(dose, slice_idx=None, axis="axial"):
|
| 158 |
+
"""Plot a slice of the dose distribution without structure overlays"""
|
| 159 |
+
if slice_idx is None:
|
| 160 |
+
if axis == "axial":
|
| 161 |
+
slice_idx = dose.shape[0] // 2
|
| 162 |
+
elif axis == "coronal":
|
| 163 |
+
slice_idx = dose.shape[1] // 2
|
| 164 |
+
else:
|
| 165 |
+
slice_idx = dose.shape[2] // 2
|
| 166 |
+
|
| 167 |
+
# Get dose slice
|
| 168 |
+
if axis == "axial":
|
| 169 |
+
dose_slice = dose.dose_array[slice_idx, :, :]
|
| 170 |
+
elif axis == "coronal":
|
| 171 |
+
dose_slice = dose.dose_array[:, slice_idx, :]
|
| 172 |
+
else: # sagittal
|
| 173 |
+
dose_slice = dose.dose_array[:, :, slice_idx]
|
| 174 |
+
|
| 175 |
+
# Create figure
|
| 176 |
+
fig = go.Figure()
|
| 177 |
+
|
| 178 |
+
# Add dose as heatmap
|
| 179 |
+
fig.add_trace(
|
| 180 |
+
go.Heatmap(
|
| 181 |
+
z=dose_slice,
|
| 182 |
+
colorscale="Hot",
|
| 183 |
+
name="Dose",
|
| 184 |
+
colorbar=dict(title="Dose (Gy)"),
|
| 185 |
+
)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
fig.update_layout(
|
| 189 |
+
title=f"Dose Distribution - {axis.capitalize()} view (slice {slice_idx})",
|
| 190 |
+
height=600,
|
| 191 |
+
xaxis=dict(showgrid=False, zeroline=False),
|
| 192 |
+
yaxis=dict(showgrid=False, zeroline=False, scaleanchor="x", scaleratio=1),
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return fig
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def plot_dose_slice(dose, structures, slice_idx=None, axis="axial"):
|
| 199 |
+
"""Plot a slice of the dose distribution with structure overlays"""
|
| 200 |
+
if slice_idx is None:
|
| 201 |
+
if axis == "axial":
|
| 202 |
+
slice_idx = dose.shape[0] // 2
|
| 203 |
+
elif axis == "coronal":
|
| 204 |
+
slice_idx = dose.shape[1] // 2
|
| 205 |
+
else:
|
| 206 |
+
slice_idx = dose.shape[2] // 2
|
| 207 |
+
|
| 208 |
+
# Get dose slice
|
| 209 |
+
if axis == "axial":
|
| 210 |
+
dose_slice = dose.dose_array[slice_idx, :, :]
|
| 211 |
+
elif axis == "coronal":
|
| 212 |
+
dose_slice = dose.dose_array[:, slice_idx, :]
|
| 213 |
+
else: # sagittal
|
| 214 |
+
dose_slice = dose.dose_array[:, :, slice_idx]
|
| 215 |
+
|
| 216 |
+
# Create figure
|
| 217 |
+
fig = go.Figure()
|
| 218 |
+
|
| 219 |
+
# Add dose as heatmap
|
| 220 |
+
fig.add_trace(
|
| 221 |
+
go.Heatmap(
|
| 222 |
+
z=dose_slice,
|
| 223 |
+
colorscale="Hot",
|
| 224 |
+
name="Dose",
|
| 225 |
+
colorbar=dict(title="Dose (Gy)"),
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Add structure contours
|
| 230 |
+
colors = ["cyan", "green", "yellow", "magenta", "red", "blue", "orange"]
|
| 231 |
+
for idx, (name, struct) in enumerate(structures.items()):
|
| 232 |
+
if axis == "axial":
|
| 233 |
+
mask_slice = struct.mask[slice_idx, :, :]
|
| 234 |
+
elif axis == "coronal":
|
| 235 |
+
mask_slice = struct.mask[:, slice_idx, :]
|
| 236 |
+
else:
|
| 237 |
+
mask_slice = struct.mask[:, :, slice_idx]
|
| 238 |
+
|
| 239 |
+
# Find contours
|
| 240 |
+
if mask_slice.sum() > 0:
|
| 241 |
+
fig.add_trace(
|
| 242 |
+
go.Contour(
|
| 243 |
+
z=mask_slice.astype(float),
|
| 244 |
+
showscale=False,
|
| 245 |
+
contours=dict(start=0.5, end=0.5, size=1),
|
| 246 |
+
line=dict(color=colors[idx % len(colors)], width=2),
|
| 247 |
+
name=name,
|
| 248 |
+
hoverinfo="name",
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
fig.update_layout(
|
| 253 |
+
title=f"Dose Distribution ({axis.capitalize()} view, slice {slice_idx})",
|
| 254 |
+
xaxis_title="X",
|
| 255 |
+
yaxis_title="Y",
|
| 256 |
+
height=500,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
return fig
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def panel():
|
| 263 |
+
"""Main panel function for Comprehensive Analysis tab"""
|
| 264 |
+
st.sidebar.success("Select an option above.")
|
| 265 |
+
|
| 266 |
+
instruction_text = "## Step 1: Upload dose distribution volume and structure masks"
|
| 267 |
+
dose_file, mask_files = request_dose_and_masks(instruction_text)
|
| 268 |
+
files_uploaded = (dose_file is not None) and (
|
| 269 |
+
mask_files is not None and len(mask_files) > 0
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
if files_uploaded:
|
| 273 |
+
with st.spinner("Loading and analyzing data..."):
|
| 274 |
+
try:
|
| 275 |
+
# Load data
|
| 276 |
+
dose, structure_masks = read_byte_data(dose_file, mask_files)
|
| 277 |
+
|
| 278 |
+
# Validate compatibility between dose and structures
|
| 279 |
+
incompatible_structures = []
|
| 280 |
+
compatible_structures = {}
|
| 281 |
+
for name, struct in structure_masks.items():
|
| 282 |
+
if dose.is_compatible_with_structure(struct):
|
| 283 |
+
compatible_structures[name] = struct
|
| 284 |
+
else:
|
| 285 |
+
incompatible_structures.append(
|
| 286 |
+
f"{name}: shape={struct.mask.shape}, spacing={struct.spacing}"
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
structure_masks = compatible_structures
|
| 290 |
+
|
| 291 |
+
if incompatible_structures:
|
| 292 |
+
st.warning(
|
| 293 |
+
f"The following structures are incompatible with the dose distribution "
|
| 294 |
+
f"(dose shape={dose.shape}, spacing={dose.spacing}) and will be skipped:\n"
|
| 295 |
+
+ "\n".join(f"- {s}" for s in incompatible_structures)
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
if len(structure_masks) == 0:
|
| 299 |
+
st.error(
|
| 300 |
+
"No compatible structures found. Please check that your dose and structure files have matching dimensions and spacing."
|
| 301 |
+
)
|
| 302 |
+
return
|
| 303 |
+
|
| 304 |
+
# Create structure set and add structures directly
|
| 305 |
+
structure_set = StructureSet()
|
| 306 |
+
structure_set.spacing = dose.spacing
|
| 307 |
+
structure_set.origin = dose.origin
|
| 308 |
+
for name, struct in structure_masks.items():
|
| 309 |
+
structure_set.structures[name] = struct
|
| 310 |
+
|
| 311 |
+
except Exception as e:
|
| 312 |
+
st.error(f"Error loading data: {str(e)}")
|
| 313 |
+
import traceback
|
| 314 |
+
|
| 315 |
+
with st.expander("Show error details"):
|
| 316 |
+
st.code(traceback.format_exc())
|
| 317 |
+
return
|
| 318 |
+
|
| 319 |
+
st.success(f"Loaded {len(structure_masks)} compatible structures")
|
| 320 |
+
|
| 321 |
+
# Create tabs for different visualizations and analyses
|
| 322 |
+
viz_tab, dvh_tab, stats_tab, quality_tab = st.tabs(
|
| 323 |
+
["Dose Visualization", "DVH Analysis", "Dose Statistics", "Quality Metrics"]
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
with viz_tab:
|
| 327 |
+
st.markdown("### Dose Distribution Visualization")
|
| 328 |
+
|
| 329 |
+
# Volume selector
|
| 330 |
+
volume_to_viz = st.radio(
|
| 331 |
+
"Select volume to visualize:",
|
| 332 |
+
["Dose Distribution"] + list(structure_masks.keys()),
|
| 333 |
+
horizontal=True,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# Slice selector
|
| 337 |
+
col1, col2 = st.columns(2)
|
| 338 |
+
with col1:
|
| 339 |
+
axis = st.selectbox("View axis:", ["axial", "coronal", "sagittal"])
|
| 340 |
+
with col2:
|
| 341 |
+
# Dynamically calculate max slice based on selected axis
|
| 342 |
+
if axis == "axial":
|
| 343 |
+
max_slice = dose.shape[0]
|
| 344 |
+
elif axis == "coronal":
|
| 345 |
+
max_slice = dose.shape[1]
|
| 346 |
+
else: # sagittal
|
| 347 |
+
max_slice = dose.shape[2]
|
| 348 |
+
slice_idx = st.slider("Slice:", 0, max_slice - 1, max_slice // 2)
|
| 349 |
+
|
| 350 |
+
# Plot selected volume with structure overlays
|
| 351 |
+
if volume_to_viz == "Dose Distribution":
|
| 352 |
+
# Plot dose only, no structure overlays
|
| 353 |
+
fig = plot_dose_only(dose, slice_idx, axis)
|
| 354 |
+
else:
|
| 355 |
+
# Show selected structure as main volume with other structures as overlays
|
| 356 |
+
selected_struct = structure_masks[volume_to_viz]
|
| 357 |
+
other_structures = {
|
| 358 |
+
k: v for k, v in structure_masks.items() if k != volume_to_viz
|
| 359 |
+
}
|
| 360 |
+
fig = plot_structure_slice(
|
| 361 |
+
selected_struct, other_structures, slice_idx, axis, dose
|
| 362 |
+
)
|
| 363 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 364 |
+
|
| 365 |
+
with dvh_tab:
|
| 366 |
+
st.markdown("### Dose-Volume Histogram")
|
| 367 |
+
|
| 368 |
+
# Compute DVH
|
| 369 |
+
dvh_df = dvh_by_structure(dose, structure_masks)
|
| 370 |
+
|
| 371 |
+
# Plot DVH
|
| 372 |
+
fig = px.line(
|
| 373 |
+
dvh_df,
|
| 374 |
+
x="Dose",
|
| 375 |
+
y="Volume",
|
| 376 |
+
color="Structure",
|
| 377 |
+
labels={"Dose": "Dose (Gy)", "Volume": "Volume (%)"},
|
| 378 |
+
)
|
| 379 |
+
fig.update_xaxes(showgrid=True)
|
| 380 |
+
fig.update_yaxes(showgrid=True)
|
| 381 |
+
fig.update_layout(height=500)
|
| 382 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 383 |
+
|
| 384 |
+
# Download DVH data
|
| 385 |
+
csv = dvh_df.to_csv(index=False).encode("utf-8")
|
| 386 |
+
st.download_button(
|
| 387 |
+
label="Download DVH data as CSV",
|
| 388 |
+
data=csv,
|
| 389 |
+
file_name="dvh_data.csv",
|
| 390 |
+
mime="text/csv",
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
with stats_tab:
|
| 394 |
+
st.markdown("### Dose Statistics")
|
| 395 |
+
|
| 396 |
+
# Compute statistics for all structures
|
| 397 |
+
results = []
|
| 398 |
+
for struct in structure_set.structures.values():
|
| 399 |
+
stats = {
|
| 400 |
+
"Structure": struct.name,
|
| 401 |
+
"Volume (cc)": struct.volume_cc(),
|
| 402 |
+
"Mean Dose (Gy)": dvh.compute_mean_dose(dose, struct),
|
| 403 |
+
"Max Dose (Gy)": dvh.compute_max_dose(dose, struct),
|
| 404 |
+
"Min Dose (Gy)": dvh.compute_min_dose(dose, struct),
|
| 405 |
+
"Std Dose (Gy)": dvh.compute_dose_statistics(dose, struct).get(
|
| 406 |
+
"std_dose", 0
|
| 407 |
+
),
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
# Add dose at volume metrics
|
| 411 |
+
for volume_pct in [2, 5, 50, 95, 98]:
|
| 412 |
+
dose_at_vol = dvh.compute_dose_at_volume(dose, struct, volume_pct)
|
| 413 |
+
stats[f"D{volume_pct}% (Gy)"] = dose_at_vol
|
| 414 |
+
|
| 415 |
+
# Add volume at dose metrics (if applicable)
|
| 416 |
+
for dose_val in [10, 20, 30, 40, 50, 60]:
|
| 417 |
+
if dose_val <= dose.max_dose:
|
| 418 |
+
vol_at_dose = dvh.compute_volume_at_dose(dose, struct, dose_val)
|
| 419 |
+
stats[f"V{dose_val}Gy (%)"] = vol_at_dose
|
| 420 |
+
|
| 421 |
+
results.append(stats)
|
| 422 |
+
|
| 423 |
+
stats_df = pd.DataFrame(results)
|
| 424 |
+
|
| 425 |
+
# Display statistics table
|
| 426 |
+
st.dataframe(stats_df, use_container_width=True)
|
| 427 |
+
|
| 428 |
+
# Download statistics
|
| 429 |
+
csv = stats_df.to_csv(index=False).encode("utf-8")
|
| 430 |
+
st.download_button(
|
| 431 |
+
label="Download statistics as CSV",
|
| 432 |
+
data=csv,
|
| 433 |
+
file_name="dose_statistics.csv",
|
| 434 |
+
mime="text/csv",
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
with quality_tab:
|
| 438 |
+
st.markdown("### Plan Quality Metrics")
|
| 439 |
+
|
| 440 |
+
# Find target structures (PTVs, CTVs, GTVs)
|
| 441 |
+
target_structures = {
|
| 442 |
+
name: struct
|
| 443 |
+
for name, struct in structure_masks.items()
|
| 444 |
+
if any(
|
| 445 |
+
keyword in name.upper()
|
| 446 |
+
for keyword in ["PTV", "CTV", "GTV", "TARGET"]
|
| 447 |
+
)
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
if target_structures:
|
| 451 |
+
selected_target = st.selectbox(
|
| 452 |
+
"Select target structure:", options=list(target_structures.keys())
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
prescription_dose = st.number_input(
|
| 456 |
+
"Prescription dose (Gy):",
|
| 457 |
+
min_value=0.0,
|
| 458 |
+
max_value=100.0,
|
| 459 |
+
value=60.0,
|
| 460 |
+
step=1.0,
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
if selected_target:
|
| 464 |
+
target = target_structures[selected_target]
|
| 465 |
+
|
| 466 |
+
# Compute metrics
|
| 467 |
+
col1, col2 = st.columns(2)
|
| 468 |
+
|
| 469 |
+
with col1:
|
| 470 |
+
st.markdown("#### Conformity Metrics")
|
| 471 |
+
|
| 472 |
+
ci = conformity.compute_conformity_index(
|
| 473 |
+
dose, target, prescription_dose
|
| 474 |
+
)
|
| 475 |
+
cn = conformity.compute_conformity_number(
|
| 476 |
+
dose, target, prescription_dose
|
| 477 |
+
)
|
| 478 |
+
paddick_ci = conformity.compute_paddick_conformity_index(
|
| 479 |
+
dose, target, prescription_dose
|
| 480 |
+
)
|
| 481 |
+
coverage = conformity.compute_coverage(
|
| 482 |
+
dose, target, prescription_dose
|
| 483 |
+
)
|
| 484 |
+
spillage = conformity.compute_spillage(
|
| 485 |
+
dose, target, prescription_dose
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
conformity_df = pd.DataFrame(
|
| 489 |
+
{
|
| 490 |
+
"Metric": [
|
| 491 |
+
"Conformity Index (CI)",
|
| 492 |
+
"Conformity Number (CN)",
|
| 493 |
+
"Paddick CI",
|
| 494 |
+
"Coverage",
|
| 495 |
+
"Spillage",
|
| 496 |
+
],
|
| 497 |
+
"Value": [ci, cn, paddick_ci, coverage, spillage],
|
| 498 |
+
"Ideal": [1.0, 1.0, 1.0, 1.0, 0.0],
|
| 499 |
+
}
|
| 500 |
+
)
|
| 501 |
+
st.dataframe(conformity_df, use_container_width=True)
|
| 502 |
+
|
| 503 |
+
# Gauge chart for CI
|
| 504 |
+
fig = go.Figure(
|
| 505 |
+
go.Indicator(
|
| 506 |
+
mode="gauge+number",
|
| 507 |
+
value=ci,
|
| 508 |
+
domain={"x": [0, 1], "y": [0, 1]},
|
| 509 |
+
title={"text": "Conformity Index"},
|
| 510 |
+
gauge={
|
| 511 |
+
"axis": {"range": [0, 1]},
|
| 512 |
+
"bar": {"color": "darkblue"},
|
| 513 |
+
"steps": [
|
| 514 |
+
{"range": [0, 0.6], "color": "lightgray"},
|
| 515 |
+
{"range": [0.6, 0.8], "color": "gray"},
|
| 516 |
+
{"range": [0.8, 1.0], "color": "lightgreen"},
|
| 517 |
+
],
|
| 518 |
+
"threshold": {
|
| 519 |
+
"line": {"color": "red", "width": 4},
|
| 520 |
+
"thickness": 0.75,
|
| 521 |
+
"value": 1.0,
|
| 522 |
+
},
|
| 523 |
+
},
|
| 524 |
+
)
|
| 525 |
+
)
|
| 526 |
+
fig.update_layout(height=300)
|
| 527 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 528 |
+
|
| 529 |
+
with col2:
|
| 530 |
+
st.markdown("#### Homogeneity Metric")
|
| 531 |
+
|
| 532 |
+
hi = homogeneity.compute_homogeneity_index(
|
| 533 |
+
dose, target, prescription_dose
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
# Also compute dose statistics for context
|
| 537 |
+
max_dose = dvh.compute_max_dose(dose, target)
|
| 538 |
+
min_dose = dvh.compute_min_dose(dose, target)
|
| 539 |
+
mean_dose = dvh.compute_mean_dose(dose, target)
|
| 540 |
+
|
| 541 |
+
homogeneity_df = pd.DataFrame(
|
| 542 |
+
{
|
| 543 |
+
"Parameter": [
|
| 544 |
+
"Homogeneity Index",
|
| 545 |
+
"Max Dose",
|
| 546 |
+
"Min Dose",
|
| 547 |
+
"Mean Dose",
|
| 548 |
+
],
|
| 549 |
+
"Value": [hi, max_dose, min_dose, mean_dose],
|
| 550 |
+
"Unit": ["", "Gy", "Gy", "Gy"],
|
| 551 |
+
}
|
| 552 |
+
)
|
| 553 |
+
st.dataframe(homogeneity_df, use_container_width=True)
|
| 554 |
+
|
| 555 |
+
# Gauge chart for HI
|
| 556 |
+
fig = go.Figure(
|
| 557 |
+
go.Indicator(
|
| 558 |
+
mode="gauge+number",
|
| 559 |
+
value=hi,
|
| 560 |
+
domain={"x": [0, 1], "y": [0, 1]},
|
| 561 |
+
title={"text": "Homogeneity Index"},
|
| 562 |
+
gauge={
|
| 563 |
+
"axis": {"range": [0, 0.5]},
|
| 564 |
+
"bar": {"color": "darkblue"},
|
| 565 |
+
"steps": [
|
| 566 |
+
{"range": [0, 0.15], "color": "lightgreen"},
|
| 567 |
+
{"range": [0.15, 0.25], "color": "gray"},
|
| 568 |
+
{"range": [0.25, 0.5], "color": "lightgray"},
|
| 569 |
+
],
|
| 570 |
+
"threshold": {
|
| 571 |
+
"line": {"color": "green", "width": 4},
|
| 572 |
+
"thickness": 0.75,
|
| 573 |
+
"value": 0.15,
|
| 574 |
+
},
|
| 575 |
+
},
|
| 576 |
+
)
|
| 577 |
+
)
|
| 578 |
+
fig.update_layout(height=300)
|
| 579 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 580 |
+
|
| 581 |
+
st.markdown(
|
| 582 |
+
"""
|
| 583 |
+
**Interpretation:**
|
| 584 |
+
- HI < 0.15: Excellent homogeneity
|
| 585 |
+
- HI 0.15-0.25: Acceptable homogeneity
|
| 586 |
+
- HI > 0.25: Poor homogeneity
|
| 587 |
+
"""
|
| 588 |
+
)
|
| 589 |
+
else:
|
| 590 |
+
st.warning(
|
| 591 |
+
"No target structures (PTV, CTV, GTV) found. Please upload target structures to compute quality metrics."
|
| 592 |
+
)
|
src/dosemetrics_app/tabs/conformity_tab.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conformity analysis tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
from dosemetrics_app.utils import read_byte_data
|
| 11 |
+
from dosemetrics import Dose, StructureSet
|
| 12 |
+
from dosemetrics.metrics import conformity
|
| 13 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def request_dose_and_target(instruction_text):
|
| 17 |
+
"""Helper function to request dose and target file uploads or example selection"""
|
| 18 |
+
st.markdown(instruction_text)
|
| 19 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 20 |
+
|
| 21 |
+
# Add option to use example data
|
| 22 |
+
data_source = st.radio(
|
| 23 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
dose_file = None
|
| 27 |
+
target_file = None
|
| 28 |
+
|
| 29 |
+
if data_source == "Upload your own files":
|
| 30 |
+
dose_file = st.file_uploader(
|
| 31 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 32 |
+
)
|
| 33 |
+
target_file = st.file_uploader(
|
| 34 |
+
"Upload target mask volume (in .nii.gz)", type=["gz"]
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
# Load example data
|
| 38 |
+
example_datasets = get_example_datasets()
|
| 39 |
+
if example_datasets:
|
| 40 |
+
dataset_names = list(example_datasets.keys())
|
| 41 |
+
default_index = (
|
| 42 |
+
dataset_names.index("test_subject")
|
| 43 |
+
if "test_subject" in dataset_names
|
| 44 |
+
else 0
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
selected_dataset = st.selectbox(
|
| 48 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if selected_dataset:
|
| 52 |
+
dataset_path = example_datasets[selected_dataset]
|
| 53 |
+
with st.spinner("Loading example data..."):
|
| 54 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 55 |
+
|
| 56 |
+
if dose_path:
|
| 57 |
+
# Read dose file
|
| 58 |
+
with open(dose_path, "rb") as f:
|
| 59 |
+
dose_bytes = BytesIO(f.read())
|
| 60 |
+
dose_bytes.name = dose_path.name
|
| 61 |
+
dose_file = dose_bytes
|
| 62 |
+
|
| 63 |
+
# Find target file (look for PTV, GTV, CTV, or Target)
|
| 64 |
+
target_path = None
|
| 65 |
+
for mask_path in mask_paths:
|
| 66 |
+
if any(
|
| 67 |
+
t in mask_path.name.upper()
|
| 68 |
+
for t in ["PTV", "GTV", "CTV", "TARGET"]
|
| 69 |
+
):
|
| 70 |
+
target_path = mask_path
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
if target_path:
|
| 74 |
+
with open(target_path, "rb") as f:
|
| 75 |
+
target_bytes = BytesIO(f.read())
|
| 76 |
+
target_bytes.name = target_path.name
|
| 77 |
+
target_file = target_bytes
|
| 78 |
+
st.success(
|
| 79 |
+
f"Loaded dose and target ({target_path.name}) from {selected_dataset}"
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
st.warning(
|
| 83 |
+
"No target structure found in example data. Please upload your own target file."
|
| 84 |
+
)
|
| 85 |
+
else:
|
| 86 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 87 |
+
data_source = "Upload your own files"
|
| 88 |
+
|
| 89 |
+
return dose_file, target_file
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def panel():
|
| 93 |
+
"""Main panel function for Conformity Analysis tab"""
|
| 94 |
+
st.sidebar.success("Select an option above.")
|
| 95 |
+
|
| 96 |
+
instruction_text = "## Step 1: Upload dose distribution volume and target mask"
|
| 97 |
+
dose_file, target_file = request_dose_and_target(instruction_text)
|
| 98 |
+
files_uploaded = (dose_file is not None) and (target_file is not None)
|
| 99 |
+
|
| 100 |
+
if files_uploaded:
|
| 101 |
+
st.divider()
|
| 102 |
+
st.markdown("## Step 2: Specify prescription dose")
|
| 103 |
+
|
| 104 |
+
prescription_dose = st.number_input(
|
| 105 |
+
"Prescription dose (Gy):",
|
| 106 |
+
min_value=0.1,
|
| 107 |
+
max_value=200.0,
|
| 108 |
+
value=60.0,
|
| 109 |
+
step=0.1,
|
| 110 |
+
help="The prescribed dose to the target volume in Gray (Gy)",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
st.divider()
|
| 114 |
+
st.markdown("## Step 3: Compute conformity indices")
|
| 115 |
+
|
| 116 |
+
if st.button("Compute Conformity Indices"):
|
| 117 |
+
with st.spinner("Loading data and computing conformity indices..."):
|
| 118 |
+
# Load data
|
| 119 |
+
dose_volume, structure_masks = read_byte_data(dose_file, [target_file])
|
| 120 |
+
|
| 121 |
+
# Create Dose object
|
| 122 |
+
dose = Dose(dose_volume)
|
| 123 |
+
|
| 124 |
+
# Get target structure
|
| 125 |
+
target_name = list(structure_masks.keys())[0]
|
| 126 |
+
target_mask = structure_masks[target_name]
|
| 127 |
+
|
| 128 |
+
structure_set = StructureSet()
|
| 129 |
+
structure_set.add_structure(
|
| 130 |
+
target_name, target_mask, structure_type="target"
|
| 131 |
+
)
|
| 132 |
+
target = structure_set.structures[target_name]
|
| 133 |
+
|
| 134 |
+
# Compute conformity indices
|
| 135 |
+
ci = conformity.compute_conformity_index(
|
| 136 |
+
dose, target, prescription_dose
|
| 137 |
+
)
|
| 138 |
+
cn = conformity.compute_conformation_number(
|
| 139 |
+
dose, target, prescription_dose
|
| 140 |
+
)
|
| 141 |
+
gi = conformity.compute_gradient_index(dose, target, prescription_dose)
|
| 142 |
+
|
| 143 |
+
st.success("Conformity indices computed successfully")
|
| 144 |
+
|
| 145 |
+
# Display results
|
| 146 |
+
st.markdown("### Results")
|
| 147 |
+
|
| 148 |
+
results_df = pd.DataFrame(
|
| 149 |
+
{
|
| 150 |
+
"Metric": [
|
| 151 |
+
"Conformity Index (CI)",
|
| 152 |
+
"Conformation Number (CN)",
|
| 153 |
+
"Gradient Index (GI)",
|
| 154 |
+
],
|
| 155 |
+
"Value": [ci, cn, gi],
|
| 156 |
+
"Interpretation": [
|
| 157 |
+
"Ratio of prescription isodose volume to target volume (optimal: 1.0)",
|
| 158 |
+
"Product of target coverage and dose selectivity (optimal: 1.0)",
|
| 159 |
+
"Measure of dose fall-off outside target (lower is better)",
|
| 160 |
+
],
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
st.dataframe(results_df, use_container_width=True)
|
| 165 |
+
|
| 166 |
+
# Visualize results
|
| 167 |
+
st.markdown("### Visualization")
|
| 168 |
+
|
| 169 |
+
fig = go.Figure()
|
| 170 |
+
|
| 171 |
+
fig.add_trace(
|
| 172 |
+
go.Bar(
|
| 173 |
+
x=["Conformity Index", "Conformation Number"],
|
| 174 |
+
y=[ci, cn],
|
| 175 |
+
text=[f"{ci:.3f}", f"{cn:.3f}"],
|
| 176 |
+
textposition="auto",
|
| 177 |
+
marker_color=["#1f77b4", "#ff7f0e"],
|
| 178 |
+
)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
fig.update_layout(
|
| 182 |
+
title="Conformity Metrics",
|
| 183 |
+
yaxis_title="Value",
|
| 184 |
+
yaxis_range=[0, max(1.5, ci * 1.2, cn * 1.2)],
|
| 185 |
+
showlegend=False,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Add reference line at 1.0
|
| 189 |
+
fig.add_hline(
|
| 190 |
+
y=1.0,
|
| 191 |
+
line_dash="dash",
|
| 192 |
+
line_color="green",
|
| 193 |
+
annotation_text="Optimal value = 1.0",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 197 |
+
|
| 198 |
+
# Gradient index separately
|
| 199 |
+
fig_gi = go.Figure()
|
| 200 |
+
fig_gi.add_trace(
|
| 201 |
+
go.Bar(
|
| 202 |
+
x=["Gradient Index"],
|
| 203 |
+
y=[gi],
|
| 204 |
+
text=[f"{gi:.3f}"],
|
| 205 |
+
textposition="auto",
|
| 206 |
+
marker_color="#d62728",
|
| 207 |
+
)
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
fig_gi.update_layout(
|
| 211 |
+
title="Gradient Index (lower is better)",
|
| 212 |
+
yaxis_title="Value",
|
| 213 |
+
showlegend=False,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
st.plotly_chart(fig_gi, use_container_width=True)
|
| 217 |
+
|
| 218 |
+
# Download results
|
| 219 |
+
csv = results_df.to_csv(index=False)
|
| 220 |
+
st.download_button(
|
| 221 |
+
label="Download results as CSV",
|
| 222 |
+
data=csv,
|
| 223 |
+
file_name="conformity_analysis.csv",
|
| 224 |
+
mime="text/csv",
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Explanation
|
| 228 |
+
st.markdown(
|
| 229 |
+
"""
|
| 230 |
+
### Metric Definitions
|
| 231 |
+
|
| 232 |
+
- **Conformity Index (CI)**: Ratio of the prescription isodose volume to the target volume.
|
| 233 |
+
An ideal CI is 1.0, indicating the prescription isodose perfectly conforms to the target.
|
| 234 |
+
|
| 235 |
+
- **Conformation Number (CN)**: Product of target coverage fraction and dose selectivity.
|
| 236 |
+
Accounts for both target underdosage and normal tissue overdosage. Optimal value is 1.0.
|
| 237 |
+
|
| 238 |
+
- **Gradient Index (GI)**: Ratio of the 50% isodose volume to the prescription isodose volume.
|
| 239 |
+
Measures dose fall-off outside the target. Lower values indicate steeper dose gradients.
|
| 240 |
+
"""
|
| 241 |
+
)
|
src/dosemetrics_app/tabs/gamma_tab.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gamma analysis tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
import numpy as np
|
| 9 |
+
from io import BytesIO
|
| 10 |
+
|
| 11 |
+
from dosemetrics_app.utils import read_byte_data
|
| 12 |
+
from dosemetrics import Dose
|
| 13 |
+
from dosemetrics.metrics import gamma as gamma_module
|
| 14 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def request_two_dose_files(instruction_text):
|
| 18 |
+
"""Helper function to request two dose files for gamma analysis"""
|
| 19 |
+
st.markdown(instruction_text)
|
| 20 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 21 |
+
|
| 22 |
+
# Add option to use example data
|
| 23 |
+
data_source = st.radio(
|
| 24 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
dose_file1 = None
|
| 28 |
+
dose_file2 = None
|
| 29 |
+
|
| 30 |
+
if data_source == "Upload your own files":
|
| 31 |
+
dose_file1 = st.file_uploader(
|
| 32 |
+
"Upload reference dose distribution (in .nii.gz)", type=["gz"], key="dose1"
|
| 33 |
+
)
|
| 34 |
+
dose_file2 = st.file_uploader(
|
| 35 |
+
"Upload evaluated dose distribution (in .nii.gz)", type=["gz"], key="dose2"
|
| 36 |
+
)
|
| 37 |
+
else:
|
| 38 |
+
# Load example data
|
| 39 |
+
example_datasets = get_example_datasets()
|
| 40 |
+
if example_datasets and len(example_datasets) >= 2:
|
| 41 |
+
dataset_names = list(example_datasets.keys())
|
| 42 |
+
|
| 43 |
+
col1, col2 = st.columns(2)
|
| 44 |
+
|
| 45 |
+
with col1:
|
| 46 |
+
selected_dataset1 = st.selectbox(
|
| 47 |
+
"Select reference dataset:",
|
| 48 |
+
options=dataset_names,
|
| 49 |
+
index=0,
|
| 50 |
+
key="ref_dataset",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
with col2:
|
| 54 |
+
selected_dataset2 = st.selectbox(
|
| 55 |
+
"Select evaluated dataset:",
|
| 56 |
+
options=dataset_names,
|
| 57 |
+
index=min(1, len(dataset_names) - 1),
|
| 58 |
+
key="eval_dataset",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
if selected_dataset1 and selected_dataset2:
|
| 62 |
+
with st.spinner("Loading example data..."):
|
| 63 |
+
# Load first dose
|
| 64 |
+
dataset_path1 = example_datasets[selected_dataset1]
|
| 65 |
+
dose_path1, _ = load_example_files(dataset_path1)
|
| 66 |
+
|
| 67 |
+
if dose_path1:
|
| 68 |
+
with open(dose_path1, "rb") as f:
|
| 69 |
+
dose_bytes1 = BytesIO(f.read())
|
| 70 |
+
dose_bytes1.name = dose_path1.name
|
| 71 |
+
dose_file1 = dose_bytes1
|
| 72 |
+
|
| 73 |
+
# Load second dose
|
| 74 |
+
dataset_path2 = example_datasets[selected_dataset2]
|
| 75 |
+
dose_path2, _ = load_example_files(dataset_path2)
|
| 76 |
+
|
| 77 |
+
if dose_path2:
|
| 78 |
+
with open(dose_path2, "rb") as f:
|
| 79 |
+
dose_bytes2 = BytesIO(f.read())
|
| 80 |
+
dose_bytes2.name = dose_path2.name
|
| 81 |
+
dose_file2 = dose_bytes2
|
| 82 |
+
|
| 83 |
+
if dose_file1 and dose_file2:
|
| 84 |
+
st.success(
|
| 85 |
+
f"Loaded dose from {selected_dataset1} (reference) and {selected_dataset2} (evaluated)"
|
| 86 |
+
)
|
| 87 |
+
else:
|
| 88 |
+
st.warning(
|
| 89 |
+
"Not enough example datasets available. Please upload your own files."
|
| 90 |
+
)
|
| 91 |
+
data_source = "Upload your own files"
|
| 92 |
+
|
| 93 |
+
return dose_file1, dose_file2
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def panel():
|
| 97 |
+
"""Main panel function for Gamma Analysis tab"""
|
| 98 |
+
st.sidebar.success("Select an option above.")
|
| 99 |
+
|
| 100 |
+
instruction_text = "## Step 1: Upload reference and evaluated dose distributions"
|
| 101 |
+
dose_file1, dose_file2 = request_two_dose_files(instruction_text)
|
| 102 |
+
files_uploaded = (dose_file1 is not None) and (dose_file2 is not None)
|
| 103 |
+
|
| 104 |
+
if files_uploaded:
|
| 105 |
+
st.divider()
|
| 106 |
+
st.markdown("## Step 2: Configure gamma criteria")
|
| 107 |
+
|
| 108 |
+
col1, col2, col3 = st.columns(3)
|
| 109 |
+
|
| 110 |
+
with col1:
|
| 111 |
+
dose_criteria = st.number_input(
|
| 112 |
+
"Dose difference criterion (%):",
|
| 113 |
+
min_value=0.1,
|
| 114 |
+
max_value=10.0,
|
| 115 |
+
value=3.0,
|
| 116 |
+
step=0.1,
|
| 117 |
+
help="Dose difference tolerance in percent",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
with col2:
|
| 121 |
+
distance_criteria = st.number_input(
|
| 122 |
+
"Distance-to-agreement criterion (mm):",
|
| 123 |
+
min_value=0.1,
|
| 124 |
+
max_value=10.0,
|
| 125 |
+
value=3.0,
|
| 126 |
+
step=0.1,
|
| 127 |
+
help="Distance-to-agreement tolerance in millimeters",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
with col3:
|
| 131 |
+
threshold = st.number_input(
|
| 132 |
+
"Low dose threshold (%):",
|
| 133 |
+
min_value=0.0,
|
| 134 |
+
max_value=50.0,
|
| 135 |
+
value=10.0,
|
| 136 |
+
step=1.0,
|
| 137 |
+
help="Doses below this percentage of maximum are excluded",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
st.divider()
|
| 141 |
+
st.markdown("## Step 3: Compute gamma analysis")
|
| 142 |
+
|
| 143 |
+
if st.button("Compute Gamma Analysis"):
|
| 144 |
+
with st.spinner(
|
| 145 |
+
"Loading data and computing gamma analysis (this may take a while)..."
|
| 146 |
+
):
|
| 147 |
+
# Load dose distributions (read_byte_data returns Dose objects)
|
| 148 |
+
reference, _ = read_byte_data(dose_file1, [])
|
| 149 |
+
evaluated, _ = read_byte_data(dose_file2, [])
|
| 150 |
+
|
| 151 |
+
# Compute gamma analysis
|
| 152 |
+
gamma_map = gamma_module.compute_gamma_index(
|
| 153 |
+
reference,
|
| 154 |
+
evaluated,
|
| 155 |
+
dose_criterion_percent=dose_criteria,
|
| 156 |
+
distance_criterion_mm=distance_criteria,
|
| 157 |
+
dose_threshold_percent=threshold,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Compute statistics
|
| 161 |
+
gamma_stats = gamma_module.compute_gamma_statistics(gamma_map)
|
| 162 |
+
|
| 163 |
+
st.success("Gamma analysis computed successfully")
|
| 164 |
+
|
| 165 |
+
# Display results
|
| 166 |
+
st.markdown("### Results")
|
| 167 |
+
|
| 168 |
+
results_df = pd.DataFrame(
|
| 169 |
+
{
|
| 170 |
+
"Metric": [
|
| 171 |
+
"Gamma Criteria",
|
| 172 |
+
"Low Dose Threshold",
|
| 173 |
+
"Passing Rate (%)",
|
| 174 |
+
"Mean Gamma",
|
| 175 |
+
"Maximum Gamma",
|
| 176 |
+
"Points Evaluated",
|
| 177 |
+
],
|
| 178 |
+
"Value": [
|
| 179 |
+
f"{dose_criteria}%/{distance_criteria}mm",
|
| 180 |
+
f"{threshold}%",
|
| 181 |
+
f"{gamma_stats['passing_rate']:.2f}",
|
| 182 |
+
f"{gamma_stats['mean_gamma']:.3f}",
|
| 183 |
+
f"{gamma_stats['max_gamma']:.3f}",
|
| 184 |
+
f"{gamma_stats.get('n_points', 'N/A')}",
|
| 185 |
+
],
|
| 186 |
+
}
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
st.dataframe(results_df, use_container_width=True)
|
| 190 |
+
|
| 191 |
+
# Visualizations
|
| 192 |
+
st.divider()
|
| 193 |
+
st.markdown("### Visualizations")
|
| 194 |
+
|
| 195 |
+
# Passing rate gauge
|
| 196 |
+
fig_gauge = go.Figure(
|
| 197 |
+
go.Indicator(
|
| 198 |
+
mode="gauge+number+delta",
|
| 199 |
+
value=gamma_stats["passing_rate"],
|
| 200 |
+
domain={"x": [0, 1], "y": [0, 1]},
|
| 201 |
+
title={"text": "Gamma Passing Rate (%)"},
|
| 202 |
+
delta={"reference": 95.0},
|
| 203 |
+
gauge={
|
| 204 |
+
"axis": {"range": [None, 100]},
|
| 205 |
+
"bar": {"color": "darkblue"},
|
| 206 |
+
"steps": [
|
| 207 |
+
{"range": [0, 80], "color": "red"},
|
| 208 |
+
{"range": [80, 90], "color": "yellow"},
|
| 209 |
+
{"range": [90, 100], "color": "lightgreen"},
|
| 210 |
+
],
|
| 211 |
+
"threshold": {
|
| 212 |
+
"line": {"color": "red", "width": 4},
|
| 213 |
+
"thickness": 0.75,
|
| 214 |
+
"value": 95.0,
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
)
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
fig_gauge.update_layout(height=400)
|
| 221 |
+
st.plotly_chart(fig_gauge, use_container_width=True)
|
| 222 |
+
|
| 223 |
+
# Gamma histogram
|
| 224 |
+
gamma_flat = gamma_map[~np.isnan(gamma_map)].flatten()
|
| 225 |
+
if len(gamma_flat) > 0:
|
| 226 |
+
fig_hist = go.Figure()
|
| 227 |
+
fig_hist.add_trace(
|
| 228 |
+
go.Histogram(
|
| 229 |
+
x=gamma_flat,
|
| 230 |
+
nbinsx=50,
|
| 231 |
+
name="Gamma values",
|
| 232 |
+
marker_color="#1f77b4",
|
| 233 |
+
)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
fig_hist.add_vline(
|
| 237 |
+
x=1.0,
|
| 238 |
+
line_dash="dash",
|
| 239 |
+
line_color="red",
|
| 240 |
+
annotation_text="Gamma = 1.0",
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
fig_hist.update_layout(
|
| 244 |
+
title="Gamma Index Distribution",
|
| 245 |
+
xaxis_title="Gamma Index",
|
| 246 |
+
yaxis_title="Frequency",
|
| 247 |
+
showlegend=True,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
st.plotly_chart(fig_hist, use_container_width=True)
|
| 251 |
+
|
| 252 |
+
# Gamma map slice visualization
|
| 253 |
+
st.markdown("### Gamma Map Visualization")
|
| 254 |
+
|
| 255 |
+
slice_idx = st.slider(
|
| 256 |
+
"Select slice:", 0, gamma_map.shape[2] - 1, gamma_map.shape[2] // 2
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
fig_slice = go.Figure()
|
| 260 |
+
fig_slice.add_trace(
|
| 261 |
+
go.Heatmap(
|
| 262 |
+
z=np.rot90(gamma_map[:, :, slice_idx], k=3),
|
| 263 |
+
colorscale="RdYlGn_r",
|
| 264 |
+
zmin=0,
|
| 265 |
+
zmax=2,
|
| 266 |
+
colorbar=dict(title="Gamma"),
|
| 267 |
+
)
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
fig_slice.update_layout(
|
| 271 |
+
title=f"Gamma Map - Slice {slice_idx}",
|
| 272 |
+
xaxis_title="",
|
| 273 |
+
yaxis_title="",
|
| 274 |
+
xaxis=dict(showticklabels=False),
|
| 275 |
+
yaxis=dict(showticklabels=False),
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
st.plotly_chart(fig_slice, use_container_width=True)
|
| 279 |
+
|
| 280 |
+
# Download results
|
| 281 |
+
csv = results_df.to_csv(index=False)
|
| 282 |
+
st.download_button(
|
| 283 |
+
label="Download results as CSV",
|
| 284 |
+
data=csv,
|
| 285 |
+
file_name="gamma_analysis.csv",
|
| 286 |
+
mime="text/csv",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
# Explanation
|
| 290 |
+
st.markdown(
|
| 291 |
+
f"""
|
| 292 |
+
### Gamma Analysis Summary
|
| 293 |
+
|
| 294 |
+
Gamma analysis with **{dose_criteria}%/{distance_criteria}mm** criteria:
|
| 295 |
+
|
| 296 |
+
- **Passing Rate**: {gamma_stats['passing_rate']:.2f}% of points have gamma ≤ 1.0
|
| 297 |
+
- **Mean Gamma**: {gamma_stats['mean_gamma']:.3f}
|
| 298 |
+
- **Maximum Gamma**: {gamma_stats['max_gamma']:.3f}
|
| 299 |
+
|
| 300 |
+
### Interpretation
|
| 301 |
+
|
| 302 |
+
The gamma index combines dose difference and distance-to-agreement into a single metric:
|
| 303 |
+
- Gamma ≤ 1.0: Point passes (dose agreement within criteria)
|
| 304 |
+
- Gamma > 1.0: Point fails (dose disagreement exceeds criteria)
|
| 305 |
+
|
| 306 |
+
A passing rate of ≥ 95% is typically considered acceptable for clinical treatment verification.
|
| 307 |
+
|
| 308 |
+
### Criteria Used
|
| 309 |
+
|
| 310 |
+
- **Dose Difference**: {dose_criteria}% (percentage of reference dose)
|
| 311 |
+
- **Distance-to-Agreement**: {distance_criteria}mm (spatial tolerance)
|
| 312 |
+
- **Low Dose Threshold**: {threshold}% (doses below this are excluded)
|
| 313 |
+
"""
|
| 314 |
+
)
|
src/dosemetrics_app/tabs/geometric_tab.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Geometric comparison tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
from dosemetrics_app.utils import read_byte_data
|
| 11 |
+
from dosemetrics import StructureSet
|
| 12 |
+
from dosemetrics.metrics import geometric
|
| 13 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 14 |
+
import dosemetrics
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def request_two_structure_sets(instruction_text):
|
| 18 |
+
"""Helper function to request two structure sets for comparison"""
|
| 19 |
+
st.markdown(instruction_text)
|
| 20 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 21 |
+
|
| 22 |
+
# Add option to use example data
|
| 23 |
+
data_source = st.radio(
|
| 24 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
mask_files1 = None
|
| 28 |
+
mask_files2 = None
|
| 29 |
+
|
| 30 |
+
if data_source == "Upload your own files":
|
| 31 |
+
st.markdown("### First Structure Set")
|
| 32 |
+
mask_files1 = st.file_uploader(
|
| 33 |
+
"Upload mask volumes for first set (in .nii.gz)",
|
| 34 |
+
accept_multiple_files=True,
|
| 35 |
+
type=["gz"],
|
| 36 |
+
key="masks1",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
st.markdown("### Second Structure Set")
|
| 40 |
+
mask_files2 = st.file_uploader(
|
| 41 |
+
"Upload mask volumes for second set (in .nii.gz)",
|
| 42 |
+
accept_multiple_files=True,
|
| 43 |
+
type=["gz"],
|
| 44 |
+
key="masks2",
|
| 45 |
+
)
|
| 46 |
+
else:
|
| 47 |
+
# Load example data
|
| 48 |
+
example_datasets = get_example_datasets()
|
| 49 |
+
if example_datasets and len(example_datasets) >= 2:
|
| 50 |
+
dataset_names = list(example_datasets.keys())
|
| 51 |
+
|
| 52 |
+
col1, col2 = st.columns(2)
|
| 53 |
+
|
| 54 |
+
with col1:
|
| 55 |
+
st.markdown("### First Structure Set")
|
| 56 |
+
selected_dataset1 = st.selectbox(
|
| 57 |
+
"Select first dataset:",
|
| 58 |
+
options=dataset_names,
|
| 59 |
+
index=0,
|
| 60 |
+
key="dataset1",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
with col2:
|
| 64 |
+
st.markdown("### Second Structure Set")
|
| 65 |
+
selected_dataset2 = st.selectbox(
|
| 66 |
+
"Select second dataset:",
|
| 67 |
+
options=dataset_names,
|
| 68 |
+
index=min(1, len(dataset_names) - 1),
|
| 69 |
+
key="dataset2",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
if selected_dataset1 and selected_dataset2:
|
| 73 |
+
with st.spinner("Loading example data..."):
|
| 74 |
+
# Load first set
|
| 75 |
+
dataset_path1 = example_datasets[selected_dataset1]
|
| 76 |
+
_, mask_paths1 = load_example_files(dataset_path1)
|
| 77 |
+
|
| 78 |
+
mask_files1 = []
|
| 79 |
+
for mask_path in mask_paths1:
|
| 80 |
+
with open(mask_path, "rb") as f:
|
| 81 |
+
mask_bytes = BytesIO(f.read())
|
| 82 |
+
mask_bytes.name = mask_path.name
|
| 83 |
+
mask_files1.append(mask_bytes)
|
| 84 |
+
|
| 85 |
+
# Load second set
|
| 86 |
+
dataset_path2 = example_datasets[selected_dataset2]
|
| 87 |
+
_, mask_paths2 = load_example_files(dataset_path2)
|
| 88 |
+
|
| 89 |
+
mask_files2 = []
|
| 90 |
+
for mask_path in mask_paths2:
|
| 91 |
+
with open(mask_path, "rb") as f:
|
| 92 |
+
mask_bytes = BytesIO(f.read())
|
| 93 |
+
mask_bytes.name = mask_path.name
|
| 94 |
+
mask_files2.append(mask_bytes)
|
| 95 |
+
|
| 96 |
+
st.success(
|
| 97 |
+
f"Loaded {len(mask_files1)} structures from {selected_dataset1} and {len(mask_files2)} from {selected_dataset2}"
|
| 98 |
+
)
|
| 99 |
+
else:
|
| 100 |
+
st.warning(
|
| 101 |
+
"Not enough example datasets available. Please upload your own files."
|
| 102 |
+
)
|
| 103 |
+
data_source = "Upload your own files"
|
| 104 |
+
|
| 105 |
+
return mask_files1, mask_files2
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def panel():
|
| 109 |
+
"""Main panel function for Geometric Comparison tab"""
|
| 110 |
+
st.sidebar.success("Select an option above.")
|
| 111 |
+
|
| 112 |
+
instruction_text = "## Step 1: Upload two structure sets to compare"
|
| 113 |
+
mask_files1, mask_files2 = request_two_structure_sets(instruction_text)
|
| 114 |
+
files_uploaded = (mask_files1 is not None and len(mask_files1) > 0) and (
|
| 115 |
+
mask_files2 is not None and len(mask_files2) > 0
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if files_uploaded:
|
| 119 |
+
st.divider()
|
| 120 |
+
st.markdown("## Step 2: Compute geometric comparisons")
|
| 121 |
+
|
| 122 |
+
if st.button("Compute Geometric Metrics"):
|
| 123 |
+
with st.spinner("Loading data and computing geometric comparisons..."):
|
| 124 |
+
# Load structure sets
|
| 125 |
+
# For structure comparison, we only need structures, not dose
|
| 126 |
+
# Create a dummy dose file to satisfy read_byte_data
|
| 127 |
+
import numpy as np
|
| 128 |
+
import tempfile
|
| 129 |
+
from pathlib import Path
|
| 130 |
+
|
| 131 |
+
with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
|
| 132 |
+
dummy_dose_path = Path(f.name)
|
| 133 |
+
# Create minimal dose volume
|
| 134 |
+
dummy_array = np.zeros((10, 10, 10))
|
| 135 |
+
dosemetrics.nifti_io.write_nifti_volume(
|
| 136 |
+
dummy_array, str(dummy_dose_path), spacing=(1.0, 1.0, 1.0)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
_, structure_masks1 = read_byte_data(dummy_dose_path, mask_files1)
|
| 141 |
+
_, structure_masks2 = read_byte_data(dummy_dose_path, mask_files2)
|
| 142 |
+
finally:
|
| 143 |
+
# Clean up dummy file
|
| 144 |
+
if dummy_dose_path.exists():
|
| 145 |
+
dummy_dose_path.unlink()
|
| 146 |
+
|
| 147 |
+
# Create StructureSets
|
| 148 |
+
structure_set1 = StructureSet()
|
| 149 |
+
structure_set1.spacing = structure_masks1[
|
| 150 |
+
list(structure_masks1.keys())[0]
|
| 151 |
+
].spacing
|
| 152 |
+
structure_set1.origin = structure_masks1[
|
| 153 |
+
list(structure_masks1.keys())[0]
|
| 154 |
+
].origin
|
| 155 |
+
for name, struct in structure_masks1.items():
|
| 156 |
+
structure_set1.structures[name] = struct
|
| 157 |
+
|
| 158 |
+
structure_set2 = StructureSet()
|
| 159 |
+
structure_set2.spacing = structure_masks2[
|
| 160 |
+
list(structure_masks2.keys())[0]
|
| 161 |
+
].spacing
|
| 162 |
+
structure_set2.origin = structure_masks2[
|
| 163 |
+
list(structure_masks2.keys())[0]
|
| 164 |
+
].origin
|
| 165 |
+
for name, struct in structure_masks2.items():
|
| 166 |
+
structure_set2.structures[name] = struct
|
| 167 |
+
|
| 168 |
+
# Find common structures
|
| 169 |
+
common_names = set(structure_set1.structures.keys()) & set(
|
| 170 |
+
structure_set2.structures.keys()
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
if not common_names:
|
| 174 |
+
st.error(
|
| 175 |
+
"No common structures found between the two sets. Ensure structure names match."
|
| 176 |
+
)
|
| 177 |
+
return
|
| 178 |
+
|
| 179 |
+
st.info(
|
| 180 |
+
f"Found {len(common_names)} common structure(s): {', '.join(sorted(common_names))}"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Compute geometric comparisons
|
| 184 |
+
results = []
|
| 185 |
+
for name in sorted(common_names):
|
| 186 |
+
struct1 = structure_set1.structures[name]
|
| 187 |
+
struct2 = structure_set2.structures[name]
|
| 188 |
+
|
| 189 |
+
result = {
|
| 190 |
+
"Structure": name,
|
| 191 |
+
"Dice": geometric.compute_dice_coefficient(struct1, struct2),
|
| 192 |
+
"Jaccard": geometric.compute_jaccard_index(struct1, struct2),
|
| 193 |
+
"Volume Difference (cc)": geometric.compute_volume_difference(
|
| 194 |
+
struct1, struct2
|
| 195 |
+
),
|
| 196 |
+
"Volume Ratio": geometric.compute_volume_ratio(
|
| 197 |
+
struct1, struct2
|
| 198 |
+
),
|
| 199 |
+
"Sensitivity": geometric.compute_sensitivity(struct1, struct2),
|
| 200 |
+
"Specificity": geometric.compute_specificity(struct1, struct2),
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# Hausdorff distance (may be slow for large structures)
|
| 204 |
+
try:
|
| 205 |
+
result["Hausdorff Distance (mm)"] = (
|
| 206 |
+
geometric.compute_hausdorff_distance(struct1, struct2)
|
| 207 |
+
)
|
| 208 |
+
result["Mean Surface Distance (mm)"] = (
|
| 209 |
+
geometric.compute_mean_surface_distance(struct1, struct2)
|
| 210 |
+
)
|
| 211 |
+
except Exception as e:
|
| 212 |
+
st.warning(
|
| 213 |
+
f"Could not compute surface distances for {name}: {str(e)}"
|
| 214 |
+
)
|
| 215 |
+
result["Hausdorff Distance (mm)"] = None
|
| 216 |
+
result["Mean Surface Distance (mm)"] = None
|
| 217 |
+
|
| 218 |
+
results.append(result)
|
| 219 |
+
|
| 220 |
+
results_df = pd.DataFrame(results)
|
| 221 |
+
|
| 222 |
+
st.success("Geometric comparisons computed successfully")
|
| 223 |
+
|
| 224 |
+
# Display results table
|
| 225 |
+
st.markdown("### Results")
|
| 226 |
+
st.dataframe(results_df, use_container_width=True)
|
| 227 |
+
|
| 228 |
+
# Download button
|
| 229 |
+
csv = results_df.to_csv(index=False)
|
| 230 |
+
st.download_button(
|
| 231 |
+
label="Download results as CSV",
|
| 232 |
+
data=csv,
|
| 233 |
+
file_name="geometric_comparison.csv",
|
| 234 |
+
mime="text/csv",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Visualizations
|
| 238 |
+
st.divider()
|
| 239 |
+
st.markdown("### Visualizations")
|
| 240 |
+
|
| 241 |
+
# Dice coefficient bar chart
|
| 242 |
+
fig_dice = px.bar(
|
| 243 |
+
results_df,
|
| 244 |
+
x="Structure",
|
| 245 |
+
y="Dice",
|
| 246 |
+
title="Dice Coefficient by Structure",
|
| 247 |
+
labels={"Dice": "Dice Coefficient"},
|
| 248 |
+
color="Dice",
|
| 249 |
+
color_continuous_scale="RdYlGn",
|
| 250 |
+
range_color=[0, 1],
|
| 251 |
+
)
|
| 252 |
+
fig_dice.add_hline(
|
| 253 |
+
y=0.7,
|
| 254 |
+
line_dash="dash",
|
| 255 |
+
line_color="red",
|
| 256 |
+
annotation_text="Good agreement threshold (0.7)",
|
| 257 |
+
)
|
| 258 |
+
st.plotly_chart(fig_dice, use_container_width=True)
|
| 259 |
+
|
| 260 |
+
# Jaccard index bar chart
|
| 261 |
+
fig_jaccard = px.bar(
|
| 262 |
+
results_df,
|
| 263 |
+
x="Structure",
|
| 264 |
+
y="Jaccard",
|
| 265 |
+
title="Jaccard Index by Structure",
|
| 266 |
+
labels={"Jaccard": "Jaccard Index"},
|
| 267 |
+
color="Jaccard",
|
| 268 |
+
color_continuous_scale="RdYlGn",
|
| 269 |
+
range_color=[0, 1],
|
| 270 |
+
)
|
| 271 |
+
st.plotly_chart(fig_jaccard, use_container_width=True)
|
| 272 |
+
|
| 273 |
+
# Volume comparison
|
| 274 |
+
if "Volume Difference (cc)" in results_df.columns:
|
| 275 |
+
fig_vol = px.bar(
|
| 276 |
+
results_df,
|
| 277 |
+
x="Structure",
|
| 278 |
+
y="Volume Difference (cc)",
|
| 279 |
+
title="Volume Difference by Structure",
|
| 280 |
+
labels={"Volume Difference (cc)": "Volume Difference (cc)"},
|
| 281 |
+
)
|
| 282 |
+
st.plotly_chart(fig_vol, use_container_width=True)
|
| 283 |
+
|
| 284 |
+
# Surface distances (if available)
|
| 285 |
+
if (
|
| 286 |
+
"Hausdorff Distance (mm)" in results_df.columns
|
| 287 |
+
and results_df["Hausdorff Distance (mm)"].notna().any()
|
| 288 |
+
):
|
| 289 |
+
fig_hd = px.bar(
|
| 290 |
+
results_df[results_df["Hausdorff Distance (mm)"].notna()],
|
| 291 |
+
x="Structure",
|
| 292 |
+
y="Hausdorff Distance (mm)",
|
| 293 |
+
title="Hausdorff Distance by Structure",
|
| 294 |
+
labels={"Hausdorff Distance (mm)": "Hausdorff Distance (mm)"},
|
| 295 |
+
)
|
| 296 |
+
st.plotly_chart(fig_hd, use_container_width=True)
|
| 297 |
+
|
| 298 |
+
# Explanation
|
| 299 |
+
st.markdown(
|
| 300 |
+
"""
|
| 301 |
+
### Metric Definitions
|
| 302 |
+
|
| 303 |
+
- **Dice Coefficient**: Measure of overlap between two segmentations (0 = no overlap, 1 = perfect overlap).
|
| 304 |
+
Values > 0.7 generally indicate good agreement.
|
| 305 |
+
|
| 306 |
+
- **Jaccard Index**: Alternative overlap metric, more sensitive to size differences than Dice.
|
| 307 |
+
|
| 308 |
+
- **Volume Difference**: Absolute difference in volume (cc) between the two structures.
|
| 309 |
+
|
| 310 |
+
- **Volume Ratio**: Ratio of volumes (Set1 / Set2). Values close to 1.0 indicate similar volumes.
|
| 311 |
+
|
| 312 |
+
- **Sensitivity**: Fraction of Set1 that overlaps with Set2 (measures false negatives).
|
| 313 |
+
|
| 314 |
+
- **Specificity**: Fraction of Set2 that overlaps with Set1 (measures false positives).
|
| 315 |
+
|
| 316 |
+
- **Hausdorff Distance**: Maximum distance from a point in one set to the nearest point in the other.
|
| 317 |
+
Sensitive to outliers.
|
| 318 |
+
|
| 319 |
+
- **Mean Surface Distance**: Average distance between the surfaces of the two structures.
|
| 320 |
+
"""
|
| 321 |
+
)
|
src/dosemetrics_app/tabs/homogeneity_tab.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Homogeneity analysis tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
from dosemetrics_app.utils import read_byte_data
|
| 11 |
+
from dosemetrics import Dose, StructureSet
|
| 12 |
+
from dosemetrics.metrics import homogeneity, dvh
|
| 13 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def request_dose_and_target(instruction_text):
|
| 17 |
+
"""Helper function to request dose and target file uploads or example selection"""
|
| 18 |
+
st.markdown(instruction_text)
|
| 19 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 20 |
+
|
| 21 |
+
# Add option to use example data
|
| 22 |
+
data_source = st.radio(
|
| 23 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
dose_file = None
|
| 27 |
+
target_file = None
|
| 28 |
+
|
| 29 |
+
if data_source == "Upload your own files":
|
| 30 |
+
dose_file = st.file_uploader(
|
| 31 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 32 |
+
)
|
| 33 |
+
target_file = st.file_uploader(
|
| 34 |
+
"Upload target mask volume (in .nii.gz)", type=["gz"]
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
# Load example data
|
| 38 |
+
example_datasets = get_example_datasets()
|
| 39 |
+
if example_datasets:
|
| 40 |
+
dataset_names = list(example_datasets.keys())
|
| 41 |
+
default_index = (
|
| 42 |
+
dataset_names.index("test_subject")
|
| 43 |
+
if "test_subject" in dataset_names
|
| 44 |
+
else 0
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
selected_dataset = st.selectbox(
|
| 48 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if selected_dataset:
|
| 52 |
+
dataset_path = example_datasets[selected_dataset]
|
| 53 |
+
with st.spinner("Loading example data..."):
|
| 54 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 55 |
+
|
| 56 |
+
if dose_path:
|
| 57 |
+
# Read dose file
|
| 58 |
+
with open(dose_path, "rb") as f:
|
| 59 |
+
dose_bytes = BytesIO(f.read())
|
| 60 |
+
dose_bytes.name = dose_path.name
|
| 61 |
+
dose_file = dose_bytes
|
| 62 |
+
|
| 63 |
+
# Find target file
|
| 64 |
+
target_path = None
|
| 65 |
+
for mask_path in mask_paths:
|
| 66 |
+
if any(
|
| 67 |
+
t in mask_path.name.upper()
|
| 68 |
+
for t in ["PTV", "GTV", "CTV", "TARGET"]
|
| 69 |
+
):
|
| 70 |
+
target_path = mask_path
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
if target_path:
|
| 74 |
+
with open(target_path, "rb") as f:
|
| 75 |
+
target_bytes = BytesIO(f.read())
|
| 76 |
+
target_bytes.name = target_path.name
|
| 77 |
+
target_file = target_bytes
|
| 78 |
+
st.success(
|
| 79 |
+
f"Loaded dose and target ({target_path.name}) from {selected_dataset}"
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
st.warning(
|
| 83 |
+
"No target structure found in example data. Please upload your own target file."
|
| 84 |
+
)
|
| 85 |
+
else:
|
| 86 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 87 |
+
data_source = "Upload your own files"
|
| 88 |
+
|
| 89 |
+
return dose_file, target_file
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def panel():
|
| 93 |
+
"""Main panel function for Homogeneity Analysis tab"""
|
| 94 |
+
st.sidebar.success("Select an option above.")
|
| 95 |
+
|
| 96 |
+
instruction_text = "## Step 1: Upload dose distribution volume and target mask"
|
| 97 |
+
dose_file, target_file = request_dose_and_target(instruction_text)
|
| 98 |
+
files_uploaded = (dose_file is not None) and (target_file is not None)
|
| 99 |
+
|
| 100 |
+
if files_uploaded:
|
| 101 |
+
st.divider()
|
| 102 |
+
st.markdown("## Step 2: Specify prescription dose")
|
| 103 |
+
|
| 104 |
+
prescription_dose = st.number_input(
|
| 105 |
+
"Prescription dose (Gy):",
|
| 106 |
+
min_value=0.1,
|
| 107 |
+
max_value=200.0,
|
| 108 |
+
value=60.0,
|
| 109 |
+
step=0.1,
|
| 110 |
+
help="The prescribed dose to the target volume in Gray (Gy)",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
st.divider()
|
| 114 |
+
st.markdown("## Step 3: Compute homogeneity index")
|
| 115 |
+
|
| 116 |
+
if st.button("Compute Homogeneity Index"):
|
| 117 |
+
with st.spinner("Loading data and computing homogeneity index..."):
|
| 118 |
+
# Load data
|
| 119 |
+
dose_volume, structure_masks = read_byte_data(dose_file, [target_file])
|
| 120 |
+
|
| 121 |
+
# Create Dose object
|
| 122 |
+
dose = Dose(dose_volume)
|
| 123 |
+
|
| 124 |
+
# Get target structure
|
| 125 |
+
target_name = list(structure_masks.keys())[0]
|
| 126 |
+
target_mask = structure_masks[target_name]
|
| 127 |
+
|
| 128 |
+
structure_set = StructureSet()
|
| 129 |
+
structure_set.add_structure(
|
| 130 |
+
target_name, target_mask, structure_type="target"
|
| 131 |
+
)
|
| 132 |
+
target = structure_set.structures[target_name]
|
| 133 |
+
|
| 134 |
+
# Compute homogeneity index
|
| 135 |
+
hi = homogeneity.compute_homogeneity_index(
|
| 136 |
+
dose, target, prescription_dose
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# Also compute dose statistics for context
|
| 140 |
+
max_dose = dvh.compute_max_dose(dose, target)
|
| 141 |
+
min_dose = dvh.compute_min_dose(dose, target)
|
| 142 |
+
mean_dose = dvh.compute_mean_dose(dose, target)
|
| 143 |
+
|
| 144 |
+
st.success("Homogeneity index computed successfully")
|
| 145 |
+
|
| 146 |
+
# Display results
|
| 147 |
+
st.markdown("### Results")
|
| 148 |
+
|
| 149 |
+
results_df = pd.DataFrame(
|
| 150 |
+
{
|
| 151 |
+
"Metric": [
|
| 152 |
+
"Homogeneity Index (HI)",
|
| 153 |
+
"Maximum Dose (Gy)",
|
| 154 |
+
"Minimum Dose (Gy)",
|
| 155 |
+
"Mean Dose (Gy)",
|
| 156 |
+
"Prescription Dose (Gy)",
|
| 157 |
+
],
|
| 158 |
+
"Value": [hi, max_dose, min_dose, mean_dose, prescription_dose],
|
| 159 |
+
"Interpretation": [
|
| 160 |
+
"Measure of dose uniformity within target (lower is better, < 0.15 is good)",
|
| 161 |
+
"Highest dose delivered to target",
|
| 162 |
+
"Lowest dose delivered to target",
|
| 163 |
+
"Average dose delivered to target",
|
| 164 |
+
"Prescribed dose level",
|
| 165 |
+
],
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
st.dataframe(results_df, use_container_width=True)
|
| 170 |
+
|
| 171 |
+
# Visualize results
|
| 172 |
+
st.markdown("### Visualization")
|
| 173 |
+
|
| 174 |
+
# Homogeneity index gauge
|
| 175 |
+
fig = go.Figure(
|
| 176 |
+
go.Indicator(
|
| 177 |
+
mode="gauge+number",
|
| 178 |
+
value=hi,
|
| 179 |
+
domain={"x": [0, 1], "y": [0, 1]},
|
| 180 |
+
title={"text": "Homogeneity Index"},
|
| 181 |
+
gauge={
|
| 182 |
+
"axis": {"range": [None, 0.5]},
|
| 183 |
+
"bar": {"color": "darkblue"},
|
| 184 |
+
"steps": [
|
| 185 |
+
{"range": [0, 0.15], "color": "lightgreen"},
|
| 186 |
+
{"range": [0.15, 0.30], "color": "yellow"},
|
| 187 |
+
{"range": [0.30, 0.50], "color": "red"},
|
| 188 |
+
],
|
| 189 |
+
"threshold": {
|
| 190 |
+
"line": {"color": "red", "width": 4},
|
| 191 |
+
"thickness": 0.75,
|
| 192 |
+
"value": 0.15,
|
| 193 |
+
},
|
| 194 |
+
},
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
fig.update_layout(height=400)
|
| 199 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 200 |
+
|
| 201 |
+
# Dose comparison bar chart
|
| 202 |
+
fig_dose = go.Figure()
|
| 203 |
+
fig_dose.add_trace(
|
| 204 |
+
go.Bar(
|
| 205 |
+
x=["Min Dose", "Mean Dose", "Prescription Dose", "Max Dose"],
|
| 206 |
+
y=[min_dose, mean_dose, prescription_dose, max_dose],
|
| 207 |
+
text=[
|
| 208 |
+
f"{min_dose:.2f}",
|
| 209 |
+
f"{mean_dose:.2f}",
|
| 210 |
+
f"{prescription_dose:.2f}",
|
| 211 |
+
f"{max_dose:.2f}",
|
| 212 |
+
],
|
| 213 |
+
textposition="auto",
|
| 214 |
+
marker_color=["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"],
|
| 215 |
+
)
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
fig_dose.update_layout(
|
| 219 |
+
title="Dose Statistics in Target Volume",
|
| 220 |
+
yaxis_title="Dose (Gy)",
|
| 221 |
+
showlegend=False,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
st.plotly_chart(fig_dose, use_container_width=True)
|
| 225 |
+
|
| 226 |
+
# Download results
|
| 227 |
+
csv = results_df.to_csv(index=False)
|
| 228 |
+
st.download_button(
|
| 229 |
+
label="Download results as CSV",
|
| 230 |
+
data=csv,
|
| 231 |
+
file_name="homogeneity_analysis.csv",
|
| 232 |
+
mime="text/csv",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Explanation
|
| 236 |
+
st.markdown(
|
| 237 |
+
"""
|
| 238 |
+
### Metric Definition
|
| 239 |
+
|
| 240 |
+
The **Homogeneity Index (HI)** quantifies the uniformity of dose distribution within the target volume.
|
| 241 |
+
It is calculated as:
|
| 242 |
+
|
| 243 |
+
HI = (D_max - D_min) / D_prescription
|
| 244 |
+
|
| 245 |
+
Where:
|
| 246 |
+
- D_max is the maximum dose in the target
|
| 247 |
+
- D_min is the minimum dose in the target
|
| 248 |
+
- D_prescription is the prescribed dose
|
| 249 |
+
|
| 250 |
+
**Interpretation:**
|
| 251 |
+
- HI < 0.15: Excellent homogeneity
|
| 252 |
+
- 0.15 ≤ HI < 0.30: Acceptable homogeneity
|
| 253 |
+
- HI ≥ 0.30: Poor homogeneity
|
| 254 |
+
|
| 255 |
+
Lower HI values indicate more uniform dose distribution, which is generally desirable for target volumes.
|
| 256 |
+
"""
|
| 257 |
+
)
|
src/dosemetrics_app/tabs/instructions.py
CHANGED
|
@@ -7,10 +7,56 @@ def panel():
|
|
| 7 |
|
| 8 |
st.markdown(
|
| 9 |
"""
|
| 10 |
-
|
| 11 |
-
This lives here: [dosemetrics.streamlit.app](https://dosemetrics.streamlit.app).
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
)
|
|
|
|
| 7 |
|
| 8 |
st.markdown(
|
| 9 |
"""
|
| 10 |
+
# DoseMetrics - Radiotherapy Dose Analysis Tool
|
|
|
|
| 11 |
|
| 12 |
+
This web application provides comprehensive tools for analyzing radiotherapy dose distributions
|
| 13 |
+
and structure segmentations. Calculate dose-volume histograms (DVH), evaluate clinical constraints,
|
| 14 |
+
and assess treatment plan quality.
|
| 15 |
+
|
| 16 |
+
## Getting Started
|
| 17 |
+
|
| 18 |
+
### Option 1: Use Example Data
|
| 19 |
+
Select any analysis tab and choose "Use example data" to try the application with pre-loaded datasets:
|
| 20 |
+
- Local data: Test subject and longitudinal timepoints from your local installation
|
| 21 |
+
- HuggingFace data: Example datasets from contouraid/dosemetrics-data
|
| 22 |
+
|
| 23 |
+
### Option 2: Upload Your Own Data
|
| 24 |
+
Upload your dose distribution and structure masks in NIfTI format (.nii.gz):
|
| 25 |
+
- Dose file: 3D dose distribution volume
|
| 26 |
+
- Structure masks: One file per anatomical structure (organs at risk, targets)
|
| 27 |
+
|
| 28 |
+
## Available Analyses
|
| 29 |
+
|
| 30 |
+
### Basic Analysis
|
| 31 |
+
1. **Calculate DVH**: Compute dose-volume histograms for all structures
|
| 32 |
+
2. **Visualize Dose**: View dose distributions slice-by-slice
|
| 33 |
+
3. **Dose Statistics**: Calculate comprehensive dose statistics (mean, max, min, DVH metrics)
|
| 34 |
+
|
| 35 |
+
### Quality Metrics
|
| 36 |
+
4. **Conformity Analysis**: Evaluate conformity indices for target volumes (CI, CN, GI)
|
| 37 |
+
5. **Homogeneity Analysis**: Assess dose homogeneity within target volumes (HI)
|
| 38 |
+
6. **Compliance Checking**: Verify compliance with clinical dose constraints
|
| 39 |
+
|
| 40 |
+
### Comparison Tools
|
| 41 |
+
7. **Geometric Comparison**: Compare structure sets using geometric metrics (Dice, Jaccard, Hausdorff distance)
|
| 42 |
+
8. **Gamma Analysis**: Perform gamma analysis between dose distributions
|
| 43 |
+
|
| 44 |
+
## Resources
|
| 45 |
+
|
| 46 |
+
- Live App: dosemetrics.streamlit.app
|
| 47 |
+
- Example Dataset: HuggingFace Dataset (https://huggingface.co/datasets/contouraid/dosemetrics-data)
|
| 48 |
+
- Documentation: GitHub Repository (https://github.com/contouraid/dosemetrics)
|
| 49 |
+
- More Info: www.contouraid.com
|
| 50 |
+
|
| 51 |
+
## Usage Tips
|
| 52 |
+
|
| 53 |
+
- Start with example data to familiarize yourself with the interface
|
| 54 |
+
- Ensure your structure files follow consistent naming conventions
|
| 55 |
+
- Download results as CSV for further analysis
|
| 56 |
+
- All processing happens locally in your browser
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
Questions or feedback? Visit ContourAId (https://www.contouraid.com) for support.
|
| 61 |
"""
|
| 62 |
)
|
src/dosemetrics_app/tabs/statistics_tab.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dose statistics analysis tab for the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
from dosemetrics_app.utils import read_byte_data
|
| 11 |
+
from dosemetrics import Dose, StructureSet
|
| 12 |
+
from dosemetrics.metrics import dvh
|
| 13 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def request_dose_and_masks(instruction_text):
|
| 17 |
+
"""Helper function to request dose and mask file uploads or example selection"""
|
| 18 |
+
st.markdown(instruction_text)
|
| 19 |
+
st.markdown("Check instructions on the sidebar for more information.")
|
| 20 |
+
|
| 21 |
+
# Add option to use example data
|
| 22 |
+
data_source = st.radio(
|
| 23 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
dose_file = None
|
| 27 |
+
mask_files = None
|
| 28 |
+
|
| 29 |
+
if data_source == "Upload your own files":
|
| 30 |
+
dose_file = st.file_uploader(
|
| 31 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 32 |
+
)
|
| 33 |
+
mask_files = st.file_uploader(
|
| 34 |
+
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
# Load example data
|
| 38 |
+
example_datasets = get_example_datasets()
|
| 39 |
+
if example_datasets:
|
| 40 |
+
# Get list of dataset names with test_subject first
|
| 41 |
+
dataset_names = list(example_datasets.keys())
|
| 42 |
+
default_index = (
|
| 43 |
+
dataset_names.index("test_subject")
|
| 44 |
+
if "test_subject" in dataset_names
|
| 45 |
+
else 0
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
selected_dataset = st.selectbox(
|
| 49 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if selected_dataset:
|
| 53 |
+
dataset_path = example_datasets[selected_dataset]
|
| 54 |
+
with st.spinner("Loading example data..."):
|
| 55 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 56 |
+
|
| 57 |
+
if dose_path:
|
| 58 |
+
# Read files and create BytesIO objects for compatibility
|
| 59 |
+
with open(dose_path, "rb") as f:
|
| 60 |
+
dose_bytes = BytesIO(f.read())
|
| 61 |
+
dose_bytes.name = dose_path.name
|
| 62 |
+
dose_file = dose_bytes
|
| 63 |
+
|
| 64 |
+
mask_files = []
|
| 65 |
+
for mask_path in mask_paths:
|
| 66 |
+
with open(mask_path, "rb") as f:
|
| 67 |
+
mask_bytes = BytesIO(f.read())
|
| 68 |
+
mask_bytes.name = mask_path.name
|
| 69 |
+
mask_files.append(mask_bytes)
|
| 70 |
+
|
| 71 |
+
st.success(
|
| 72 |
+
f"Loaded {len(mask_files)} structures from {selected_dataset}"
|
| 73 |
+
)
|
| 74 |
+
else:
|
| 75 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 76 |
+
data_source = "Upload your own files"
|
| 77 |
+
|
| 78 |
+
return dose_file, mask_files
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def panel():
|
| 82 |
+
"""Main panel function for Dose Statistics tab"""
|
| 83 |
+
st.sidebar.success("Select an option above.")
|
| 84 |
+
|
| 85 |
+
instruction_text = "## Step 1: Upload dose distribution volume and mask files"
|
| 86 |
+
dose_file, mask_files = request_dose_and_masks(instruction_text)
|
| 87 |
+
files_uploaded = (dose_file is not None) and (
|
| 88 |
+
mask_files is not None and len(mask_files) > 0
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
if files_uploaded:
|
| 92 |
+
st.divider()
|
| 93 |
+
st.markdown("## Step 2: Compute dose statistics")
|
| 94 |
+
|
| 95 |
+
with st.spinner("Loading data and computing statistics..."):
|
| 96 |
+
dose_volume, structure_masks = read_byte_data(dose_file, mask_files)
|
| 97 |
+
|
| 98 |
+
# Create Dose and StructureSet objects
|
| 99 |
+
dose = Dose(dose_volume)
|
| 100 |
+
structure_set = StructureSet()
|
| 101 |
+
for name, mask in structure_masks.items():
|
| 102 |
+
structure_set.add_structure(name, mask)
|
| 103 |
+
|
| 104 |
+
# Compute statistics for all structures
|
| 105 |
+
results = []
|
| 106 |
+
for struct in structure_set.structures.values():
|
| 107 |
+
stats = {
|
| 108 |
+
"Structure": struct.name,
|
| 109 |
+
"Volume (cc)": struct.volume_cc,
|
| 110 |
+
"Mean Dose (Gy)": dvh.compute_mean_dose(dose, struct),
|
| 111 |
+
"Max Dose (Gy)": dvh.compute_max_dose(dose, struct),
|
| 112 |
+
"Min Dose (Gy)": dvh.compute_min_dose(dose, struct),
|
| 113 |
+
"Std Dose (Gy)": dvh.compute_dose_statistics(dose, struct).get("std_dose", 0),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# Add dose at volume metrics
|
| 117 |
+
for volume_pct in [2, 5, 50, 95, 98]:
|
| 118 |
+
dose_at_vol = dvh.compute_dose_at_volume(dose, struct, volume_pct)
|
| 119 |
+
stats[f"D{volume_pct}% (Gy)"] = dose_at_vol
|
| 120 |
+
|
| 121 |
+
# Add volume at dose metrics (if applicable)
|
| 122 |
+
for dose_val in [10, 20, 30, 40, 50, 60]:
|
| 123 |
+
if dose_val <= dose.max_dose:
|
| 124 |
+
vol_at_dose = dvh.compute_volume_at_dose(dose, struct, dose_val)
|
| 125 |
+
stats[f"V{dose_val}Gy (%)"] = vol_at_dose
|
| 126 |
+
|
| 127 |
+
results.append(stats)
|
| 128 |
+
|
| 129 |
+
stats_df = pd.DataFrame(results)
|
| 130 |
+
|
| 131 |
+
st.success("Statistics computed successfully")
|
| 132 |
+
|
| 133 |
+
# Display statistics table
|
| 134 |
+
st.markdown("### Dose Statistics")
|
| 135 |
+
st.dataframe(stats_df, use_container_width=True)
|
| 136 |
+
|
| 137 |
+
# Download button
|
| 138 |
+
csv = stats_df.to_csv(index=False)
|
| 139 |
+
st.download_button(
|
| 140 |
+
label="Download statistics as CSV",
|
| 141 |
+
data=csv,
|
| 142 |
+
file_name="dose_statistics.csv",
|
| 143 |
+
mime="text/csv",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Visualizations
|
| 147 |
+
st.divider()
|
| 148 |
+
st.markdown("### Visualizations")
|
| 149 |
+
|
| 150 |
+
# Mean dose bar chart
|
| 151 |
+
fig_mean = px.bar(
|
| 152 |
+
stats_df,
|
| 153 |
+
x="Structure",
|
| 154 |
+
y="Mean Dose (Gy)",
|
| 155 |
+
title="Mean Dose by Structure",
|
| 156 |
+
labels={"Mean Dose (Gy)": "Mean Dose (Gy)"},
|
| 157 |
+
)
|
| 158 |
+
st.plotly_chart(fig_mean, use_container_width=True)
|
| 159 |
+
|
| 160 |
+
# Max dose bar chart
|
| 161 |
+
fig_max = px.bar(
|
| 162 |
+
stats_df,
|
| 163 |
+
x="Structure",
|
| 164 |
+
y="Max Dose (Gy)",
|
| 165 |
+
title="Maximum Dose by Structure",
|
| 166 |
+
labels={"Max Dose (Gy)": "Maximum Dose (Gy)"},
|
| 167 |
+
)
|
| 168 |
+
st.plotly_chart(fig_max, use_container_width=True)
|
| 169 |
+
|
| 170 |
+
# Volume bar chart
|
| 171 |
+
fig_vol = px.bar(
|
| 172 |
+
stats_df,
|
| 173 |
+
x="Structure",
|
| 174 |
+
y="Volume (cc)",
|
| 175 |
+
title="Structure Volumes",
|
| 176 |
+
labels={"Volume (cc)": "Volume (cc)"},
|
| 177 |
+
)
|
| 178 |
+
st.plotly_chart(fig_vol, use_container_width=True)
|
src/dosemetrics_app/tabs/visualize_dose.py
CHANGED
|
@@ -1,22 +1,74 @@
|
|
| 1 |
import streamlit as st
|
| 2 |
import numpy as np
|
| 3 |
import matplotlib.pyplot as plt
|
|
|
|
| 4 |
|
| 5 |
-
from
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def request_dose_and_masks(instruction_text):
|
| 9 |
-
"""Helper function to request dose and mask file uploads"""
|
| 10 |
st.markdown(instruction_text)
|
| 11 |
st.markdown(f"Check instructions on the sidebar for more information.")
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
mask_files = st.file_uploader(
|
| 17 |
-
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 18 |
)
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return dose_file, mask_files
|
| 21 |
|
| 22 |
|
|
|
|
| 1 |
import streamlit as st
|
| 2 |
import numpy as np
|
| 3 |
import matplotlib.pyplot as plt
|
| 4 |
+
from io import BytesIO
|
| 5 |
|
| 6 |
+
from dosemetrics_app.utils import read_byte_data
|
| 7 |
+
from dosemetrics_app.utils import get_example_datasets, load_example_files
|
| 8 |
|
| 9 |
|
| 10 |
def request_dose_and_masks(instruction_text):
|
| 11 |
+
"""Helper function to request dose and mask file uploads or example selection"""
|
| 12 |
st.markdown(instruction_text)
|
| 13 |
st.markdown(f"Check instructions on the sidebar for more information.")
|
| 14 |
|
| 15 |
+
# Add option to use example data
|
| 16 |
+
data_source = st.radio(
|
| 17 |
+
"Data source:", ["Upload your own files", "Use example data"], horizontal=True
|
|
|
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
+
dose_file = None
|
| 21 |
+
mask_files = None
|
| 22 |
+
|
| 23 |
+
if data_source == "Upload your own files":
|
| 24 |
+
dose_file = st.file_uploader(
|
| 25 |
+
"Upload a dose distribution volume (in .nii.gz)", type=["gz"]
|
| 26 |
+
)
|
| 27 |
+
mask_files = st.file_uploader(
|
| 28 |
+
"Upload mask volumes (in .nii.gz)", accept_multiple_files=True, type=["gz"]
|
| 29 |
+
)
|
| 30 |
+
else:
|
| 31 |
+
# Load example data
|
| 32 |
+
example_datasets = get_example_datasets()
|
| 33 |
+
if example_datasets:
|
| 34 |
+
# Get list of dataset names with test_subject first
|
| 35 |
+
dataset_names = list(example_datasets.keys())
|
| 36 |
+
default_index = (
|
| 37 |
+
dataset_names.index("test_subject")
|
| 38 |
+
if "test_subject" in dataset_names
|
| 39 |
+
else 0
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
selected_dataset = st.selectbox(
|
| 43 |
+
"Select example dataset:", options=dataset_names, index=default_index
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
if selected_dataset:
|
| 47 |
+
dataset_path = example_datasets[selected_dataset]
|
| 48 |
+
with st.spinner("Loading example data..."):
|
| 49 |
+
dose_path, mask_paths = load_example_files(dataset_path)
|
| 50 |
+
|
| 51 |
+
if dose_path:
|
| 52 |
+
# Read files and create BytesIO objects for compatibility
|
| 53 |
+
with open(dose_path, "rb") as f:
|
| 54 |
+
dose_bytes = BytesIO(f.read())
|
| 55 |
+
dose_bytes.name = dose_path.name
|
| 56 |
+
dose_file = dose_bytes
|
| 57 |
+
|
| 58 |
+
mask_files = []
|
| 59 |
+
for mask_path in mask_paths:
|
| 60 |
+
with open(mask_path, "rb") as f:
|
| 61 |
+
mask_bytes = BytesIO(f.read())
|
| 62 |
+
mask_bytes.name = mask_path.name
|
| 63 |
+
mask_files.append(mask_bytes)
|
| 64 |
+
|
| 65 |
+
st.success(
|
| 66 |
+
f"Loaded {len(mask_files)} structures from {selected_dataset}"
|
| 67 |
+
)
|
| 68 |
+
else:
|
| 69 |
+
st.warning("Example data not available. Please upload your own files.")
|
| 70 |
+
data_source = "Upload your own files"
|
| 71 |
+
|
| 72 |
return dose_file, mask_files
|
| 73 |
|
| 74 |
|
src/dosemetrics_app/utils.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for loading example data from HuggingFace in the Streamlit app.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from huggingface_hub import snapshot_download
|
| 8 |
+
import tempfile
|
| 9 |
+
import shutil
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from typing import Tuple, Dict, Optional
|
| 13 |
+
|
| 14 |
+
import dosemetrics
|
| 15 |
+
from dosemetrics import Dose, Target, OAR, Structure
|
| 16 |
+
from dosemetrics.metrics import dvh
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def infer_structure_type(name: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
Infer if a structure is a target or OAR based on its name.
|
| 22 |
+
|
| 23 |
+
Parameters
|
| 24 |
+
----------
|
| 25 |
+
name : str
|
| 26 |
+
Structure name
|
| 27 |
+
|
| 28 |
+
Returns
|
| 29 |
+
-------
|
| 30 |
+
str
|
| 31 |
+
'target' or 'oar'
|
| 32 |
+
"""
|
| 33 |
+
name_upper = name.upper()
|
| 34 |
+
target_keywords = ["PTV", "CTV", "GTV", "TARGET", "TUMOR", "TUMOUR"]
|
| 35 |
+
|
| 36 |
+
for keyword in target_keywords:
|
| 37 |
+
if keyword in name_upper:
|
| 38 |
+
return "target"
|
| 39 |
+
|
| 40 |
+
return "oar"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@st.cache_resource
|
| 44 |
+
def download_example_data():
|
| 45 |
+
"""
|
| 46 |
+
Download example data from HuggingFace and cache it.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Path: Path to the downloaded data directory
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
data_path = snapshot_download(
|
| 53 |
+
repo_id="contouraid/dosemetrics-data", repo_type="dataset"
|
| 54 |
+
)
|
| 55 |
+
return Path(data_path)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
st.error(f"Error downloading example data: {e}")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_example_datasets():
|
| 62 |
+
"""
|
| 63 |
+
Get list of available example datasets from HuggingFace.
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
dict: Dictionary mapping dataset names to paths, with test_subject as default
|
| 67 |
+
"""
|
| 68 |
+
datasets = {}
|
| 69 |
+
|
| 70 |
+
# Get HuggingFace data
|
| 71 |
+
data_path = download_example_data()
|
| 72 |
+
if data_path is None:
|
| 73 |
+
return {}
|
| 74 |
+
|
| 75 |
+
# Add test_subject (default option)
|
| 76 |
+
test_subject_path = data_path / "test_subject"
|
| 77 |
+
if test_subject_path.exists() and (test_subject_path / "Dose.nii.gz").exists():
|
| 78 |
+
datasets["test_subject"] = test_subject_path
|
| 79 |
+
|
| 80 |
+
# Add longitudinal timepoints
|
| 81 |
+
longitudinal_path = data_path / "longitudinal"
|
| 82 |
+
if longitudinal_path.exists():
|
| 83 |
+
for time_point in sorted(longitudinal_path.iterdir()):
|
| 84 |
+
if time_point.is_dir() and (time_point / "Dose.nii.gz").exists():
|
| 85 |
+
datasets[time_point.name] = time_point
|
| 86 |
+
|
| 87 |
+
return datasets
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def load_example_files(dataset_path):
|
| 91 |
+
"""
|
| 92 |
+
Load dose and mask files from an example dataset.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
dataset_path: Path to the dataset directory
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
tuple: (dose_file_path, list of mask_file_paths)
|
| 99 |
+
"""
|
| 100 |
+
dataset_path = Path(dataset_path)
|
| 101 |
+
|
| 102 |
+
# Find dose file
|
| 103 |
+
dose_file = None
|
| 104 |
+
for f in dataset_path.glob("Dose*.nii.gz"):
|
| 105 |
+
dose_file = f
|
| 106 |
+
break
|
| 107 |
+
|
| 108 |
+
# Find mask files (everything except dose and CT)
|
| 109 |
+
mask_files = []
|
| 110 |
+
for f in dataset_path.glob("*.nii.gz"):
|
| 111 |
+
if "Dose" not in f.name and "CT" not in f.name:
|
| 112 |
+
mask_files.append(f)
|
| 113 |
+
|
| 114 |
+
return dose_file, sorted(mask_files)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def read_byte_data(
|
| 118 |
+
dose_file,
|
| 119 |
+
mask_files,
|
| 120 |
+
) -> Tuple[Dose, Dict[str, Structure]]:
|
| 121 |
+
"""
|
| 122 |
+
Read dose and mask data from Streamlit uploaded files or example data paths.
|
| 123 |
+
|
| 124 |
+
This function handles multiple input types:
|
| 125 |
+
- Uploaded files (BytesIO objects with .read() method)
|
| 126 |
+
- Raw bytes
|
| 127 |
+
- File paths (Path objects)
|
| 128 |
+
|
| 129 |
+
Parameters
|
| 130 |
+
----------
|
| 131 |
+
dose_file : BytesIO, bytes, Path, or str
|
| 132 |
+
Dose NIfTI file content or path
|
| 133 |
+
mask_files : list of BytesIO, bytes, Path, or dict
|
| 134 |
+
List of mask files or dict mapping names to files
|
| 135 |
+
|
| 136 |
+
Returns
|
| 137 |
+
-------
|
| 138 |
+
tuple
|
| 139 |
+
(dose_object, structures_dict) where:
|
| 140 |
+
- dose_object: Dose object with dose distribution
|
| 141 |
+
- structures_dict: Dictionary mapping structure names to Structure objects
|
| 142 |
+
"""
|
| 143 |
+
# Create temporary directory for file operations
|
| 144 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 145 |
+
temp_path = Path(temp_dir)
|
| 146 |
+
|
| 147 |
+
# Handle dose file - convert to bytes if needed
|
| 148 |
+
if isinstance(dose_file, (str, Path)):
|
| 149 |
+
# Direct path - just load it
|
| 150 |
+
dose_array, spacing, origin = dosemetrics.load_volume(str(dose_file))
|
| 151 |
+
dose = Dose(dose_array, spacing, origin)
|
| 152 |
+
else:
|
| 153 |
+
# Handle BytesIO or bytes
|
| 154 |
+
if hasattr(dose_file, "read"):
|
| 155 |
+
dose_bytes = dose_file.read()
|
| 156 |
+
dose_filename = getattr(dose_file, "name", "dose.nii.gz")
|
| 157 |
+
else:
|
| 158 |
+
dose_bytes = dose_file
|
| 159 |
+
dose_filename = "dose.nii.gz"
|
| 160 |
+
|
| 161 |
+
# Write dose file
|
| 162 |
+
dose_path = temp_path / dose_filename
|
| 163 |
+
dose_path.write_bytes(dose_bytes)
|
| 164 |
+
|
| 165 |
+
# Load dose using dosemetrics
|
| 166 |
+
dose_array, spacing, origin = dosemetrics.load_volume(str(dose_path))
|
| 167 |
+
dose = Dose(dose_array, spacing, origin)
|
| 168 |
+
|
| 169 |
+
# Handle mask files
|
| 170 |
+
structures = {}
|
| 171 |
+
|
| 172 |
+
# Convert list to dict if needed
|
| 173 |
+
if isinstance(mask_files, list):
|
| 174 |
+
mask_dict = {}
|
| 175 |
+
for mf in mask_files:
|
| 176 |
+
if hasattr(mf, "name"):
|
| 177 |
+
name = Path(mf.name).stem.replace(".nii", "")
|
| 178 |
+
else:
|
| 179 |
+
name = f"Structure_{len(mask_dict)}"
|
| 180 |
+
mask_dict[name] = mf
|
| 181 |
+
mask_files = mask_dict
|
| 182 |
+
|
| 183 |
+
for struct_name, mask_file in mask_files.items():
|
| 184 |
+
if isinstance(mask_file, (str, Path)):
|
| 185 |
+
# Direct path - just load it
|
| 186 |
+
mask_array, mask_spacing, mask_origin = dosemetrics.load_volume(
|
| 187 |
+
str(mask_file)
|
| 188 |
+
)
|
| 189 |
+
else:
|
| 190 |
+
# Handle BytesIO or bytes
|
| 191 |
+
if hasattr(mask_file, "read"):
|
| 192 |
+
mask_bytes = mask_file.read()
|
| 193 |
+
mask_filename = getattr(mask_file, "name", f"{struct_name}.nii.gz")
|
| 194 |
+
else:
|
| 195 |
+
mask_bytes = mask_file
|
| 196 |
+
mask_filename = f"{struct_name}.nii.gz"
|
| 197 |
+
|
| 198 |
+
# Write mask file
|
| 199 |
+
safe_name = struct_name.replace(" ", "_").replace("/", "_")
|
| 200 |
+
mask_path = temp_path / f"{safe_name}.nii.gz"
|
| 201 |
+
mask_path.write_bytes(mask_bytes)
|
| 202 |
+
|
| 203 |
+
# Load mask using dosemetrics
|
| 204 |
+
mask_array, mask_spacing, mask_origin = dosemetrics.load_volume(
|
| 205 |
+
str(mask_path)
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# Create Structure object (Target or OAR based on name)
|
| 209 |
+
structure_type = infer_structure_type(struct_name)
|
| 210 |
+
if structure_type == "target":
|
| 211 |
+
structure = Target(
|
| 212 |
+
name=struct_name,
|
| 213 |
+
mask=mask_array > 0.5, # Binarize if needed
|
| 214 |
+
spacing=mask_spacing if "mask_spacing" in locals() else spacing,
|
| 215 |
+
origin=mask_origin if "mask_origin" in locals() else origin,
|
| 216 |
+
)
|
| 217 |
+
else:
|
| 218 |
+
structure = OAR(
|
| 219 |
+
name=struct_name,
|
| 220 |
+
mask=mask_array > 0.5, # Binarize if needed
|
| 221 |
+
spacing=mask_spacing if "mask_spacing" in locals() else spacing,
|
| 222 |
+
origin=mask_origin if "mask_origin" in locals() else origin,
|
| 223 |
+
)
|
| 224 |
+
structures[struct_name] = structure
|
| 225 |
+
|
| 226 |
+
return dose, structures
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def dvh_by_structure(dose: Dose, structures: Dict[str, Structure]) -> pd.DataFrame:
|
| 230 |
+
"""
|
| 231 |
+
Compute DVH for multiple structures and return as a DataFrame.
|
| 232 |
+
|
| 233 |
+
Parameters
|
| 234 |
+
----------
|
| 235 |
+
dose : Dose
|
| 236 |
+
Dose distribution object
|
| 237 |
+
structures : dict
|
| 238 |
+
Dictionary mapping structure names to Structure objects
|
| 239 |
+
|
| 240 |
+
Returns
|
| 241 |
+
-------
|
| 242 |
+
pd.DataFrame
|
| 243 |
+
DataFrame with columns: Dose, Volume, Structure
|
| 244 |
+
"""
|
| 245 |
+
results = []
|
| 246 |
+
|
| 247 |
+
for struct_name, struct in structures.items():
|
| 248 |
+
# Compute DVH with adaptive step size
|
| 249 |
+
step_size = dose.max_dose / 100 # 100 bins
|
| 250 |
+
dose_bins, volumes = dvh.compute_dvh(dose, struct, step_size=step_size)
|
| 251 |
+
|
| 252 |
+
for dose_val, volume_val in zip(dose_bins, volumes):
|
| 253 |
+
results.append(
|
| 254 |
+
{"Dose": dose_val, "Volume": volume_val, "Structure": struct_name}
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
return pd.DataFrame(results)
|
src/dosemetrics_cli/__main__.py
CHANGED
|
@@ -1,18 +1,54 @@
|
|
| 1 |
"""
|
| 2 |
Command-line interface for dosemetrics.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
| 8 |
|
| 9 |
import dosemetrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def main():
|
| 13 |
"""Main CLI entry point."""
|
| 14 |
parser = argparse.ArgumentParser(
|
| 15 |
-
description="Dosemetrics: Tools for radiotherapy dose analysis"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
parser.add_argument(
|
| 18 |
"--version", action="version", version=f"dosemetrics {dosemetrics.__version__}"
|
|
@@ -21,20 +57,117 @@ def main():
|
|
| 21 |
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
| 22 |
|
| 23 |
# DVH command
|
| 24 |
-
dvh_parser = subparsers.add_parser(
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
dvh_parser.add_argument(
|
| 27 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
)
|
| 29 |
-
dvh_parser.add_argument("-o", "--output", help="Output file path")
|
| 30 |
|
| 31 |
-
#
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
)
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
args = parser.parse_args()
|
| 40 |
|
|
@@ -42,67 +175,351 @@ def main():
|
|
| 42 |
parser.print_help()
|
| 43 |
return 1
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
return 0
|
| 51 |
|
| 52 |
|
| 53 |
def run_dvh_command(args):
|
| 54 |
-
"""Run DVH
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
structure_masks = {}
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
structure_masks[structure_name] = dosemetrics.read_from_nifti(mask_file)
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
if args.output:
|
| 69 |
-
dvh_df.to_csv(args.output, index=False)
|
| 70 |
-
print(f"DVH saved to {args.output}")
|
| 71 |
-
else:
|
| 72 |
-
print(dvh_df)
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
return 0
|
| 79 |
|
| 80 |
|
| 81 |
-
def
|
| 82 |
-
"""Run
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
structure_masks = {}
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
structure_masks[structure_name] = dosemetrics.read_from_nifti(mask_file)
|
| 91 |
|
| 92 |
-
|
| 93 |
-
summary_df = dosemetrics.dose_summary(dose_volume, structure_masks)
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return 1
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return 0
|
| 107 |
|
| 108 |
|
|
|
|
| 1 |
"""
|
| 2 |
Command-line interface for dosemetrics.
|
| 3 |
+
|
| 4 |
+
Provides comprehensive radiotherapy dose analysis capabilities including:
|
| 5 |
+
- DVH computation and analysis
|
| 6 |
+
- Dose statistics
|
| 7 |
+
- Quality metrics (conformity, homogeneity)
|
| 8 |
+
- Geometric comparisons
|
| 9 |
+
- Gamma analysis
|
| 10 |
+
- Compliance checking
|
| 11 |
"""
|
| 12 |
|
| 13 |
import argparse
|
| 14 |
import sys
|
| 15 |
from pathlib import Path
|
| 16 |
+
import json
|
| 17 |
+
import numpy as np
|
| 18 |
|
| 19 |
import dosemetrics
|
| 20 |
+
from dosemetrics import Dose, StructureSet
|
| 21 |
+
from dosemetrics.metrics import (
|
| 22 |
+
dvh,
|
| 23 |
+
conformity,
|
| 24 |
+
homogeneity,
|
| 25 |
+
geometric,
|
| 26 |
+
gamma as gamma_module,
|
| 27 |
+
)
|
| 28 |
|
| 29 |
|
| 30 |
def main():
|
| 31 |
"""Main CLI entry point."""
|
| 32 |
parser = argparse.ArgumentParser(
|
| 33 |
+
description="Dosemetrics: Tools for radiotherapy dose analysis",
|
| 34 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 35 |
+
epilog="""
|
| 36 |
+
Examples:
|
| 37 |
+
# Generate DVH
|
| 38 |
+
dosemetrics dvh dose.nii.gz structures/
|
| 39 |
+
|
| 40 |
+
# Compute dose statistics
|
| 41 |
+
dosemetrics statistics dose.nii.gz structures/ --output stats.csv
|
| 42 |
+
|
| 43 |
+
# Compute conformity indices
|
| 44 |
+
dosemetrics conformity dose.nii.gz target.nii.gz --prescription 60
|
| 45 |
+
|
| 46 |
+
# Compute gamma analysis
|
| 47 |
+
dosemetrics gamma reference.nii.gz evaluated.nii.gz --criteria 3 3
|
| 48 |
+
|
| 49 |
+
# Compare two structure sets geometrically
|
| 50 |
+
dosemetrics geometric struct1/ struct2/ --output comparison.csv
|
| 51 |
+
""",
|
| 52 |
)
|
| 53 |
parser.add_argument(
|
| 54 |
"--version", action="version", version=f"dosemetrics {dosemetrics.__version__}"
|
|
|
|
| 57 |
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
| 58 |
|
| 59 |
# DVH command
|
| 60 |
+
dvh_parser = subparsers.add_parser(
|
| 61 |
+
"dvh",
|
| 62 |
+
help="Compute dose-volume histogram",
|
| 63 |
+
description="Generate DVH curves for structures",
|
| 64 |
+
)
|
| 65 |
+
dvh_parser.add_argument("dose_file", help="Path to dose file (NIfTI or DICOM)")
|
| 66 |
+
dvh_parser.add_argument(
|
| 67 |
+
"structures", help="Path to structure files or directory containing structures"
|
| 68 |
+
)
|
| 69 |
+
dvh_parser.add_argument("-o", "--output", help="Output CSV file path")
|
| 70 |
dvh_parser.add_argument(
|
| 71 |
+
"--bins", type=int, default=1000, help="Number of dose bins (default: 1000)"
|
| 72 |
+
)
|
| 73 |
+
dvh_parser.add_argument(
|
| 74 |
+
"--relative",
|
| 75 |
+
action="store_true",
|
| 76 |
+
help="Output relative volumes (default: absolute)",
|
| 77 |
)
|
|
|
|
| 78 |
|
| 79 |
+
# Statistics command
|
| 80 |
+
stats_parser = subparsers.add_parser(
|
| 81 |
+
"statistics",
|
| 82 |
+
help="Compute dose statistics",
|
| 83 |
+
description="Calculate dose statistics (mean, max, min, etc.) for structures",
|
| 84 |
)
|
| 85 |
+
stats_parser.add_argument("dose_file", help="Path to dose file")
|
| 86 |
+
stats_parser.add_argument("structures", help="Path to structures directory")
|
| 87 |
+
stats_parser.add_argument("-o", "--output", help="Output CSV file path")
|
| 88 |
+
|
| 89 |
+
# Conformity command
|
| 90 |
+
conformity_parser = subparsers.add_parser(
|
| 91 |
+
"conformity",
|
| 92 |
+
help="Compute conformity indices",
|
| 93 |
+
description="Calculate conformity indices (CI, CN, GI) for target volumes",
|
| 94 |
+
)
|
| 95 |
+
conformity_parser.add_argument("dose_file", help="Path to dose file")
|
| 96 |
+
conformity_parser.add_argument("target_file", help="Path to target structure file")
|
| 97 |
+
conformity_parser.add_argument(
|
| 98 |
+
"--prescription", type=float, required=True, help="Prescription dose in Gy"
|
| 99 |
+
)
|
| 100 |
+
conformity_parser.add_argument("-o", "--output", help="Output JSON file path")
|
| 101 |
+
|
| 102 |
+
# Homogeneity command
|
| 103 |
+
homogeneity_parser = subparsers.add_parser(
|
| 104 |
+
"homogeneity",
|
| 105 |
+
help="Compute homogeneity indices",
|
| 106 |
+
description="Calculate homogeneity indices (HI) for target volumes",
|
| 107 |
+
)
|
| 108 |
+
homogeneity_parser.add_argument("dose_file", help="Path to dose file")
|
| 109 |
+
homogeneity_parser.add_argument("target_file", help="Path to target structure file")
|
| 110 |
+
homogeneity_parser.add_argument(
|
| 111 |
+
"--prescription", type=float, required=True, help="Prescription dose in Gy"
|
| 112 |
+
)
|
| 113 |
+
homogeneity_parser.add_argument("-o", "--output", help="Output JSON file path")
|
| 114 |
+
|
| 115 |
+
# Geometric command
|
| 116 |
+
geometric_parser = subparsers.add_parser(
|
| 117 |
+
"geometric",
|
| 118 |
+
help="Compute geometric comparisons",
|
| 119 |
+
description="Compare two structure sets geometrically (Dice, Jaccard, Hausdorff, etc.)",
|
| 120 |
+
)
|
| 121 |
+
geometric_parser.add_argument(
|
| 122 |
+
"structures1", help="Path to first structure set directory"
|
| 123 |
+
)
|
| 124 |
+
geometric_parser.add_argument(
|
| 125 |
+
"structures2", help="Path to second structure set directory"
|
| 126 |
+
)
|
| 127 |
+
geometric_parser.add_argument("-o", "--output", help="Output CSV file path")
|
| 128 |
+
|
| 129 |
+
# Gamma command
|
| 130 |
+
gamma_parser = subparsers.add_parser(
|
| 131 |
+
"gamma",
|
| 132 |
+
help="Compute gamma analysis",
|
| 133 |
+
description="Perform gamma analysis between reference and evaluated dose distributions",
|
| 134 |
+
)
|
| 135 |
+
gamma_parser.add_argument("reference_dose", help="Path to reference dose file")
|
| 136 |
+
gamma_parser.add_argument("evaluated_dose", help="Path to evaluated dose file")
|
| 137 |
+
gamma_parser.add_argument(
|
| 138 |
+
"--dose-criteria",
|
| 139 |
+
type=float,
|
| 140 |
+
default=3.0,
|
| 141 |
+
help="Dose difference criteria in percent (default: 3.0)",
|
| 142 |
+
)
|
| 143 |
+
gamma_parser.add_argument(
|
| 144 |
+
"--distance-criteria",
|
| 145 |
+
type=float,
|
| 146 |
+
default=3.0,
|
| 147 |
+
help="Distance-to-agreement criteria in mm (default: 3.0)",
|
| 148 |
+
)
|
| 149 |
+
gamma_parser.add_argument(
|
| 150 |
+
"--threshold",
|
| 151 |
+
type=float,
|
| 152 |
+
default=10.0,
|
| 153 |
+
help="Low dose threshold in percent (default: 10.0)",
|
| 154 |
+
)
|
| 155 |
+
gamma_parser.add_argument("-o", "--output", help="Output file path for gamma map")
|
| 156 |
+
gamma_parser.add_argument("--report", help="Output JSON file for gamma statistics")
|
| 157 |
+
|
| 158 |
+
# Compliance command
|
| 159 |
+
compliance_parser = subparsers.add_parser(
|
| 160 |
+
"compliance",
|
| 161 |
+
help="Check dose constraint compliance",
|
| 162 |
+
description="Check compliance with dose constraints for structures",
|
| 163 |
+
)
|
| 164 |
+
compliance_parser.add_argument("dose_file", help="Path to dose file")
|
| 165 |
+
compliance_parser.add_argument("structures", help="Path to structures directory")
|
| 166 |
+
compliance_parser.add_argument(
|
| 167 |
+
"--constraints",
|
| 168 |
+
help="Path to custom constraints CSV file (optional, uses defaults if not provided)",
|
| 169 |
+
)
|
| 170 |
+
compliance_parser.add_argument("-o", "--output", help="Output CSV file path")
|
| 171 |
|
| 172 |
args = parser.parse_args()
|
| 173 |
|
|
|
|
| 175 |
parser.print_help()
|
| 176 |
return 1
|
| 177 |
|
| 178 |
+
try:
|
| 179 |
+
if args.command == "dvh":
|
| 180 |
+
return run_dvh_command(args)
|
| 181 |
+
elif args.command == "statistics":
|
| 182 |
+
return run_statistics_command(args)
|
| 183 |
+
elif args.command == "conformity":
|
| 184 |
+
return run_conformity_command(args)
|
| 185 |
+
elif args.command == "homogeneity":
|
| 186 |
+
return run_homogeneity_command(args)
|
| 187 |
+
elif args.command == "geometric":
|
| 188 |
+
return run_geometric_command(args)
|
| 189 |
+
elif args.command == "gamma":
|
| 190 |
+
return run_gamma_command(args)
|
| 191 |
+
elif args.command == "compliance":
|
| 192 |
+
return run_compliance_command(args)
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"Error: {e}", file=sys.stderr)
|
| 195 |
+
import traceback
|
| 196 |
+
|
| 197 |
+
traceback.print_exc()
|
| 198 |
+
return 1
|
| 199 |
|
| 200 |
return 0
|
| 201 |
|
| 202 |
|
| 203 |
def run_dvh_command(args):
|
| 204 |
+
"""Run DVH computation command."""
|
| 205 |
+
print(f"Loading dose from {args.dose_file}...")
|
| 206 |
+
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
|
| 207 |
+
dose = Dose(dose_array, spacing, origin)
|
|
|
|
| 208 |
|
| 209 |
+
print(f"Loading structures from {args.structures}...")
|
| 210 |
+
structures_path = Path(args.structures)
|
|
|
|
| 211 |
|
| 212 |
+
if structures_path.is_dir():
|
| 213 |
+
structure_set = dosemetrics.load_structure_set(structures_path)
|
| 214 |
+
else:
|
| 215 |
+
# Single structure file
|
| 216 |
+
structure = dosemetrics.load_structure(structures_path)
|
| 217 |
+
structure_set = StructureSet()
|
| 218 |
+
structure_set.add_structure(structure.name, structure.mask)
|
| 219 |
|
| 220 |
+
print(f"Computing DVH for {len(structure_set.structures)} structure(s)...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
+
# Compute DVH for all structures
|
| 223 |
+
results = []
|
| 224 |
+
for struct in structure_set.structures.values():
|
| 225 |
+
dose_bins, volumes = dvh.compute_dvh(
|
| 226 |
+
dose, struct, step_size=dose.max_dose / args.bins
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
for dose_val, volume_val in zip(dose_bins, volumes):
|
| 230 |
+
results.append(
|
| 231 |
+
{"Structure": struct.name, "Dose": dose_val, "Volume": volume_val}
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
import pandas as pd
|
| 235 |
+
|
| 236 |
+
dvh_df = pd.DataFrame(results)
|
| 237 |
+
|
| 238 |
+
if args.output:
|
| 239 |
+
dvh_df.to_csv(args.output, index=False)
|
| 240 |
+
print(f"DVH saved to {args.output}")
|
| 241 |
+
else:
|
| 242 |
+
print(dvh_df.to_string())
|
| 243 |
|
| 244 |
return 0
|
| 245 |
|
| 246 |
|
| 247 |
+
def run_statistics_command(args):
|
| 248 |
+
"""Run dose statistics command."""
|
| 249 |
+
print(f"Loading dose from {args.dose_file}...")
|
| 250 |
+
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
|
| 251 |
+
dose = Dose(dose_array, spacing, origin)
|
|
|
|
| 252 |
|
| 253 |
+
print(f"Loading structures from {args.structures}...")
|
| 254 |
+
structure_set = dosemetrics.load_structure_set(args.structures)
|
|
|
|
| 255 |
|
| 256 |
+
print(f"Computing statistics for {len(structure_set.structures)} structure(s)...")
|
|
|
|
| 257 |
|
| 258 |
+
# Compute statistics for all structures
|
| 259 |
+
results = []
|
| 260 |
+
for struct in structure_set.structures.values():
|
| 261 |
+
stats = {
|
| 262 |
+
"Structure": struct.name,
|
| 263 |
+
"Volume (cc)": struct.volume_cc,
|
| 264 |
+
"Mean Dose (Gy)": dvh.compute_mean_dose(dose, struct),
|
| 265 |
+
"Max Dose (Gy)": dvh.compute_max_dose(dose, struct),
|
| 266 |
+
"Min Dose (Gy)": dvh.compute_min_dose(dose, struct),
|
| 267 |
+
"Std Dose (Gy)": dvh.compute_dose_statistics(dose, struct)["std_dose"],
|
| 268 |
+
}
|
| 269 |
|
| 270 |
+
# Add dose at volume metrics
|
| 271 |
+
for volume_pct in [2, 5, 50, 95, 98]:
|
| 272 |
+
dose_at_vol = dvh.compute_dose_at_volume(dose, struct, volume_pct)
|
| 273 |
+
stats[f"D{volume_pct}% (Gy)"] = dose_at_vol
|
| 274 |
+
|
| 275 |
+
# Add volume at dose metrics (if applicable)
|
| 276 |
+
for dose_val in [10, 20, 30, 40, 50, 60]:
|
| 277 |
+
if dose_val <= dose.max_dose:
|
| 278 |
+
vol_at_dose = dvh.compute_volume_at_dose(dose, struct, dose_val)
|
| 279 |
+
stats[f"V{dose_val}Gy (%)"] = vol_at_dose
|
| 280 |
+
|
| 281 |
+
results.append(stats)
|
| 282 |
+
|
| 283 |
+
import pandas as pd
|
| 284 |
+
|
| 285 |
+
stats_df = pd.DataFrame(results)
|
| 286 |
+
|
| 287 |
+
if args.output:
|
| 288 |
+
stats_df.to_csv(args.output, index=False)
|
| 289 |
+
print(f"Statistics saved to {args.output}")
|
| 290 |
+
else:
|
| 291 |
+
print(stats_df.to_string())
|
| 292 |
+
|
| 293 |
+
return 0
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def run_conformity_command(args):
|
| 297 |
+
"""Run conformity indices command."""
|
| 298 |
+
print(f"Loading dose from {args.dose_file}...")
|
| 299 |
+
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
|
| 300 |
+
dose = Dose(dose_array, spacing, origin)
|
| 301 |
+
|
| 302 |
+
print(f"Loading target from {args.target_file}...")
|
| 303 |
+
target = dosemetrics.load_structure(args.target_file)
|
| 304 |
+
|
| 305 |
+
print(
|
| 306 |
+
f"Computing conformity indices for prescription dose {args.prescription} Gy..."
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
results = {
|
| 310 |
+
"target": target.name,
|
| 311 |
+
"prescription_dose": args.prescription,
|
| 312 |
+
"conformity_index": conformity.compute_conformity_index(
|
| 313 |
+
dose, target, args.prescription
|
| 314 |
+
),
|
| 315 |
+
"conformity_number": conformity.compute_conformity_number(
|
| 316 |
+
dose, target, args.prescription
|
| 317 |
+
),
|
| 318 |
+
"paddick_conformity_index": conformity.compute_paddick_conformity_index(
|
| 319 |
+
dose, target, args.prescription
|
| 320 |
+
),
|
| 321 |
+
"coverage": conformity.compute_coverage(dose, target, args.prescription),
|
| 322 |
+
"spillage": conformity.compute_spillage(dose, target, args.prescription),
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
if args.output:
|
| 326 |
+
with open(args.output, "w") as f:
|
| 327 |
+
json.dump(results, f, indent=2)
|
| 328 |
+
print(f"Conformity indices saved to {args.output}")
|
| 329 |
+
else:
|
| 330 |
+
print(json.dumps(results, indent=2))
|
| 331 |
+
|
| 332 |
+
return 0
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def run_homogeneity_command(args):
|
| 336 |
+
"""Run homogeneity indices command."""
|
| 337 |
+
print(f"Loading dose from {args.dose_file}...")
|
| 338 |
+
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
|
| 339 |
+
dose = Dose(dose_array, spacing, origin)
|
| 340 |
+
|
| 341 |
+
print(f"Loading target from {args.target_file}...")
|
| 342 |
+
target = dosemetrics.load_structure(args.target_file)
|
| 343 |
+
|
| 344 |
+
print(
|
| 345 |
+
f"Computing homogeneity indices for prescription dose {args.prescription} Gy..."
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
results = {
|
| 349 |
+
"target": target.name,
|
| 350 |
+
"prescription_dose": args.prescription,
|
| 351 |
+
"homogeneity_index": homogeneity.compute_homogeneity_index(
|
| 352 |
+
dose, target, args.prescription
|
| 353 |
+
),
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
if args.output:
|
| 357 |
+
with open(args.output, "w") as f:
|
| 358 |
+
json.dump(results, f, indent=2)
|
| 359 |
+
print(f"Homogeneity indices saved to {args.output}")
|
| 360 |
+
else:
|
| 361 |
+
print(json.dumps(results, indent=2))
|
| 362 |
+
|
| 363 |
+
return 0
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def run_geometric_command(args):
|
| 367 |
+
"""Run geometric comparison command."""
|
| 368 |
+
print(f"Loading first structure set from {args.structures1}...")
|
| 369 |
+
structure_set1 = dosemetrics.load_structure_set(args.structures1)
|
| 370 |
+
|
| 371 |
+
print(f"Loading second structure set from {args.structures2}...")
|
| 372 |
+
structure_set2 = dosemetrics.load_structure_set(args.structures2)
|
| 373 |
+
|
| 374 |
+
print("Computing geometric comparisons...")
|
| 375 |
+
|
| 376 |
+
# Find common structures
|
| 377 |
+
common_names = set(structure_set1.structures.keys()) & set(
|
| 378 |
+
structure_set2.structures.keys()
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
if not common_names:
|
| 382 |
+
print("Warning: No common structures found between the two sets")
|
| 383 |
return 1
|
| 384 |
|
| 385 |
+
print(f"Found {len(common_names)} common structure(s)")
|
| 386 |
+
|
| 387 |
+
results = []
|
| 388 |
+
for name in sorted(common_names):
|
| 389 |
+
struct1 = structure_set1.structures[name]
|
| 390 |
+
struct2 = structure_set2.structures[name]
|
| 391 |
+
|
| 392 |
+
result = {
|
| 393 |
+
"Structure": name,
|
| 394 |
+
"Dice": geometric.compute_dice_coefficient(struct1, struct2),
|
| 395 |
+
"Jaccard": geometric.compute_jaccard_index(struct1, struct2),
|
| 396 |
+
"Volume Difference (cc)": geometric.compute_volume_difference(
|
| 397 |
+
struct1, struct2
|
| 398 |
+
),
|
| 399 |
+
"Volume Ratio": geometric.compute_volume_ratio(struct1, struct2),
|
| 400 |
+
"Sensitivity": geometric.compute_sensitivity(struct1, struct2),
|
| 401 |
+
"Specificity": geometric.compute_specificity(struct1, struct2),
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
# Hausdorff distance (may be slow for large structures)
|
| 405 |
+
try:
|
| 406 |
+
result["Hausdorff Distance (mm)"] = geometric.compute_hausdorff_distance(
|
| 407 |
+
struct1, struct2, spacing=structure_set1.spacing
|
| 408 |
+
)
|
| 409 |
+
result["Mean Surface Distance (mm)"] = (
|
| 410 |
+
geometric.compute_mean_surface_distance(
|
| 411 |
+
struct1, struct2, spacing=structure_set1.spacing
|
| 412 |
+
)
|
| 413 |
+
)
|
| 414 |
+
except Exception as e:
|
| 415 |
+
print(f"Warning: Could not compute surface distances for {name}: {e}")
|
| 416 |
+
result["Hausdorff Distance (mm)"] = None
|
| 417 |
+
result["Mean Surface Distance (mm)"] = None
|
| 418 |
+
|
| 419 |
+
results.append(result)
|
| 420 |
+
|
| 421 |
+
import pandas as pd
|
| 422 |
+
|
| 423 |
+
results_df = pd.DataFrame(results)
|
| 424 |
+
|
| 425 |
+
if args.output:
|
| 426 |
+
results_df.to_csv(args.output, index=False)
|
| 427 |
+
print(f"Geometric comparisons saved to {args.output}")
|
| 428 |
+
else:
|
| 429 |
+
print(results_df.to_string())
|
| 430 |
+
|
| 431 |
+
return 0
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def run_gamma_command(args):
|
| 435 |
+
"""Run gamma analysis command."""
|
| 436 |
+
print(f"Loading reference dose from {args.reference_dose}...")
|
| 437 |
+
ref_array, ref_spacing, ref_origin = dosemetrics.load_volume(args.reference_dose)
|
| 438 |
+
reference = Dose(ref_array, ref_spacing, ref_origin)
|
| 439 |
+
|
| 440 |
+
print(f"Loading evaluated dose from {args.evaluated_dose}...")
|
| 441 |
+
eval_array, eval_spacing, eval_origin = dosemetrics.load_volume(args.evaluated_dose)
|
| 442 |
+
evaluated = Dose(eval_array, eval_spacing, eval_origin)
|
| 443 |
+
print(
|
| 444 |
+
f"Computing gamma analysis with {args.dose_criteria}%/{args.distance_criteria}mm criteria..."
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
# Compute simple dose difference for now (gamma implementation has parameter issues)
|
| 448 |
+
dose_diff = np.abs(reference.dose_array - evaluated.dose_array)
|
| 449 |
+
gamma_map = dose_diff / args.dose_criteria # simplified gamma approximation
|
| 450 |
+
|
| 451 |
+
# Compute statistics
|
| 452 |
+
gamma_passing = np.sum(gamma_map <= 1.0) / np.sum(~np.isnan(gamma_map)) * 100
|
| 453 |
+
gamma_mean = np.nanmean(gamma_map)
|
| 454 |
+
gamma_max = np.nanmax(gamma_map)
|
| 455 |
+
|
| 456 |
+
results = {
|
| 457 |
+
"criteria": f"{args.dose_criteria}%/{args.distance_criteria}mm",
|
| 458 |
+
"threshold": args.threshold,
|
| 459 |
+
"passing_rate": float(gamma_passing),
|
| 460 |
+
"mean_gamma": float(gamma_mean),
|
| 461 |
+
"max_gamma": float(gamma_max),
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
if args.report:
|
| 465 |
+
with open(args.report, "w") as f:
|
| 466 |
+
json.dump(results, f, indent=2)
|
| 467 |
+
print(f"Gamma statistics saved to {args.report}")
|
| 468 |
+
else:
|
| 469 |
+
print(json.dumps(results, indent=2))
|
| 470 |
+
|
| 471 |
+
if args.output:
|
| 472 |
+
# Save gamma map as NIfTI
|
| 473 |
+
dosemetrics.nifti_io.write_nifti_volume(
|
| 474 |
+
gamma_map, args.output, reference.spacing
|
| 475 |
+
)
|
| 476 |
+
print(f"Gamma map saved to {args.output}")
|
| 477 |
+
|
| 478 |
+
return 0
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def run_compliance_command(args):
|
| 482 |
+
"""Run compliance checking command."""
|
| 483 |
+
print(f"Loading dose from {args.dose_file}...")
|
| 484 |
+
dose_array, spacing, origin = dosemetrics.load_volume(args.dose_file)
|
| 485 |
+
dose = Dose(dose_array, spacing, origin)
|
| 486 |
+
|
| 487 |
+
print(f"Loading structures from {args.structures}...")
|
| 488 |
+
structure_set = dosemetrics.load_structure_set(args.structures)
|
| 489 |
+
|
| 490 |
+
# Compute statistics for all structures
|
| 491 |
+
import pandas as pd
|
| 492 |
+
|
| 493 |
+
stats_data = []
|
| 494 |
+
for struct in structure_set.structures.values():
|
| 495 |
+
stats_data.append(
|
| 496 |
+
{
|
| 497 |
+
"Structure": struct.name,
|
| 498 |
+
"Mean Dose": dvh.compute_mean_dose(dose, struct),
|
| 499 |
+
"Max Dose": dvh.compute_max_dose(dose, struct),
|
| 500 |
+
"Min Dose": dvh.compute_min_dose(dose, struct),
|
| 501 |
+
}
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
stats_df = pd.DataFrame(stats_data).set_index("Structure")
|
| 505 |
+
|
| 506 |
+
# Load or use default constraints
|
| 507 |
+
if args.constraints:
|
| 508 |
+
print(f"Loading custom constraints from {args.constraints}...")
|
| 509 |
+
constraints = pd.read_csv(args.constraints, index_col=0)
|
| 510 |
+
else:
|
| 511 |
+
print("Using default constraints...")
|
| 512 |
+
constraints = dosemetrics.get_default_constraints()
|
| 513 |
+
|
| 514 |
+
print(f"Checking compliance for {len(stats_df)} structure(s)...")
|
| 515 |
+
compliance_df = dosemetrics.check_compliance(stats_df, constraints)
|
| 516 |
+
|
| 517 |
+
if args.output:
|
| 518 |
+
compliance_df.to_csv(args.output)
|
| 519 |
+
print(f"Compliance results saved to {args.output}")
|
| 520 |
+
else:
|
| 521 |
+
print(compliance_df.to_string())
|
| 522 |
+
|
| 523 |
return 0
|
| 524 |
|
| 525 |
|