Source code for RANO
"""
RANO Module
This module is part of a 3D Slicer extension and provides tools for Response Assessment in Neuro-Oncology (RANO)
based on the RANO 2.0 guidelines. It includes functionality for segmentation, 2D measurements,
response classification, and report generation.
"""
import os
import shutil
import sys
import zipfile
from importlib import reload
import slicer, vtk
from slicer.ScriptedLoadableModule import *
from slicer.util import *
from qt import QMessageBox
# Reload utils on Reload button
for mod in [
'utils.enums',
'utils.config',
'utils.rano_utils',
'utils.RANOLogic',
'utils.ui_helper_utils',
'utils.segmentation_utils',
'utils.measurements2D_utils',
'utils.response_classification_utils',
'utils.report_creation_utils',
'utils.results_table_utils',
'utils.test_rano',
]:
if mod in sys.modules:
reload(sys.modules[mod])
from utils.config import debug, module_path, dynunet_pipeline_path, test_data_path
from utils.test_rano import RANOTest # tests run in developer mode
[docs]
class RANO(ScriptedLoadableModule):
"""
Required class for 3D Slicer module.
Uses ScriptedLoadableModule base class, available at:
https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py
"""
def __init__(self, parent):
ScriptedLoadableModule.__init__(self, parent)
self.parent.title = "RANO"
self.parent.categories = ["Tools"]
self.parent.dependencies = []
self.parent.contributors = ["Aaron Kujawa (King's College London)"]
self.parent.helpText = ""
self.parent.acknowledgementText = """
This file is based on a file developed by Jean-Christophe Fillion-Robin, Kitware Inc., Andras Lasso, PerkLab,
and Steve Pieper, Isomics, Inc. which was partially funded by NIH grant 3P41RR013218-12S1.
"""
[docs]
class RANOWidget(ScriptedLoadableModuleWidget, VTKObservationMixin):
"""
Required class for 3D Slicer module.
UI elements can be accessed as follows from the Slicer python console:
`slicer.modules.RANOWidget.ui`
For example, to access the text of the line edit widget:
`slicer.modules.RANOWidget.ui.lineEdit.text`
"""
def __init__(self, parent=None):
"""
Called when the user opens the module the first time and the widget is initialized.
"""
ScriptedLoadableModuleWidget.__init__(self, parent)
VTKObservationMixin.__init__(self) # needed for parameter node observation
self.ui = None
self.logic = None
self._parameterNode = None
self._updatingGUIFromParameterNode = False
[docs]
def setup(self):
ScriptedLoadableModuleWidget.setup(self)
uiWidget = slicer.util.loadUI(self.resourcePath('UI/RANO.ui'))
self.layout.addWidget(uiWidget)
self.ui = slicer.util.childWidgetVariables(uiWidget)
uiWidget.setMRMLScene(slicer.mrmlScene)
# Install deps ONLY now
installExtension(extensionName="PyTorch")
installAndImportDependencies()
# Safe imports AFTER deps
from utils.ui_helper_utils import UIHelperMixin
from utils.RANOLogic import RANOLogic
from utils.segmentation_utils import SegmentationMixin
from utils.measurements2D_utils import Measurements2DMixin, LineNodePairList
from utils.response_classification_utils import ResponseClassificationMixin
from utils.report_creation_utils import ReportCreationMixin
from utils.results_table_utils import ResultsTableMixin
# Create logic class. Logic implements all computations that should be possible to run
# in batch mode, without a graphical user interface.
self.logic = RANOLogic()
# Dynamically add mixins
self.__class__ = type(
"RANOWidgetWithMixins",
(SegmentationMixin,
UIHelperMixin,
Measurements2DMixin,
ResponseClassificationMixin,
ReportCreationMixin,
ResultsTableMixin,
self.__class__), # keep original class last
{}
)
# only create the lineNodePairs list if it does not exist yet
if hasattr(slicer.modules, 'RANOWidget') and hasattr(slicer.modules.RANOWidget, 'lineNodePairs'):
self.lineNodePairs = slicer.modules.RANOWidget.lineNodePairs
else:
self.lineNodePairs = LineNodePairList()
"""List of line node pairs used for 2D measurements."""
SegmentationMixin.__init__(self)
UIHelperMixin.__init__(self)
Measurements2DMixin.__init__(self)
ResponseClassificationMixin.__init__(self)
ReportCreationMixin.__init__(self)
ResultsTableMixin.__init__(self)
# Make sure parameter node is initialized (needed for module reload)
self.initializeParameterNode()
# Connections
# These connections ensure that we update parameter node when scene is closed
self.addObserver(slicer.mrmlScene, slicer.mrmlScene.StartCloseEvent, self.onSceneStartClose)
self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose)
self.setup_layout()
self.setup_add_data_box()
self.setup_input_box()
self.setup_autosegmentation_box()
self.setup_auto_2D_measurements()
self.setup_manual_2D_measurements()
self.setup_lesion_based_response_status_box()
self.setup_overall_response_status_box()
# create report button
self.ui.createReportPushButton.connect('clicked(bool)', self.onCreateReportButton)
# external python path line edit
self.ui.lineEdit_python_path.connect("textChanged(const QString &)", self.updateParameterNodeFromGUI)
self.updateParameterNodeFromGUI()
# center views on first volume
self.onShowChannelButton(True, timepoint='timepoint1', inputSelector=self.ui.inputSelector_channel1_t1)
self.onShowChannelButton(True, timepoint='timepoint2', inputSelector=self.ui.inputSelector_channel1_t2)
[docs]
def cleanup(self):
"""
Called when the application closes and the module widget is destroyed.
"""
self.removeObservers()
[docs]
def enter(self):
"""
Called each time the user opens this module.
"""
# Make sure parameter node exists and observed
self.initializeParameterNode()
[docs]
def exit(self):
"""
Called each time the user opens a different module.
"""
# Do not react to parameter node changes (GUI will be updated when the user enters into the module)
if self._parameterNode:
self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
[docs]
def onSceneStartClose(self, caller, event):
"""
Called just before the scene is closed.
Args:
caller: The object that triggered the event.
event: The event that occurred.
"""
# Parameter node will be reset, do not use it anymore
self.setParameterNode(None)
[docs]
def onSceneEndClose(self, caller, event):
"""
Called just after the scene is closed.
Args:
caller: The object that triggered the event.
event: The event that occurred.
"""
# If this module is shown while the scene is closed then recreate a new parameter node immediately
if self.parent.isEntered:
self.initializeParameterNode()
[docs]
def initializeParameterNode(self):
"""
Ensure parameter node exists and observed.
"""
# Parameter node stores all user choices in parameter values, node selections, etc.
# so that when the scene is saved and reloaded, these settings are restored.
self.setParameterNode(self.logic.getParameterNode())
[docs]
def setParameterNode(self, inputParameterNode):
"""
Set and observe parameter node.
Observation is needed because when the parameter node is changed then the GUI must be updated immediately.
Args:
inputParameterNode: The parameter node to set.
"""
if inputParameterNode:
if not inputParameterNode.GetParameter("DefaultParamsSet"): # set default parameters only if they are not set yet
self.logic.setDefaultParameters(inputParameterNode)
# Unobserve previously selected parameter node and add an observer to the newly selected.
# Changes of parameter node are observed so that whenever parameters are changed by a script or any other module
# those are reflected immediately in the GUI.
if self._parameterNode is not None and self.hasObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode):
self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
self._parameterNode = inputParameterNode
if self._parameterNode is not None:
self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
# Initial GUI update
self.updateGUIFromParameterNode()
[docs]
def updateGUIFromParameterNode(self, caller=None, event=None):
"""
This method is called whenever parameter node is changed.
The module GUI is updated to show the current state of the parameter node.
From slicer python interface, you can access the variables like this:
slicer.modules.RANOWidget.ui.radius_spinbox
Args:
caller: The object that triggered the event.
event: The event that occurred.
"""
if debug: print("updateGUIFromParameterNode")
if self._parameterNode is None or self._updatingGUIFromParameterNode:
return
# Make sure GUI changes do not call updateParameterNodeFromGUI (it could cause infinite loop)
self._updatingGUIFromParameterNode = True
# timepoint 1
# get the model info
model_key = self.ui.modelComboBox.currentText
task_dir = self.get_task_dir(model_key, self._parameterNode)
# load the modality info
modalities_path = os.path.abspath(os.path.join(task_dir, "config", "modalities.json"))
self.update_ui_input_channel_selectors(modalities_path, timepoint=1)
# timepoint 2
model_key_t2 = self.ui.modelComboBox_t2.currentText
task_dir_t2 = self.get_task_dir(model_key_t2, self._parameterNode)
# load the modality info
modalities_path_t2 = os.path.abspath(os.path.join(task_dir_t2, "config", "modalities.json"))
self.update_ui_input_channel_selectors(modalities_path_t2, timepoint=2)
# Update node selectors and sliders
# timepoint 1
self.ui.inputSelector_channel1_t1.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel1_t1"))
self.ui.inputSelector_channel2_t1.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel2_t1"))
self.ui.inputSelector_channel3_t1.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel3_t1"))
self.ui.inputSelector_channel4_t1.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel4_t1"))
self.ui.outputSelector.setCurrentNode(self._parameterNode.GetNodeReference("outputSegmentation"))
self.ui.affineregCheckBox.checked = (self._parameterNode.GetParameter("AffineReg") == "true")
self.ui.inputisbetCheckBox.checked = (self._parameterNode.GetParameter("InputIsBET") == "true")
# timepoint 2
self.ui.inputSelector_channel1_t2.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel1_t2"))
self.ui.inputSelector_channel2_t2.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel2_t2"))
self.ui.inputSelector_channel3_t2.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel3_t2"))
self.ui.inputSelector_channel4_t2.setCurrentNode(self._parameterNode.GetNodeReference("InputVolume_channel4_t2"))
self.ui.outputSelector_t2.setCurrentNode(self._parameterNode.GetNodeReference("outputSegmentation_t2"))
self.ui.affineregCheckBox_t2.checked = (self._parameterNode.GetParameter("AffineReg_t2") == "true")
self.ui.inputisbetCheckBox_t2.checked = (self._parameterNode.GetParameter("InputIsBET_t2") == "true")
# update the 2D measurements widgets
# update the segment selector widget current node with the segmentation node stored in the parameter node
self.ui.SegmentSelectorWidget.setCurrentNode(self._parameterNode.GetNodeReference("meas2D_segmentation"))
# update the same widget with the segmentID stored in the parameter node
self.ui.SegmentSelectorWidget.setCurrentSegmentID(self._parameterNode.GetParameter("segmentID"))
# toggle visibility of the opening radius label and spinbox depending on the selected method
if self.ui.method2DmeasComboBox.currentText in ["RANO_open2D", "RANO_open3D"]:
self.ui.radius_spinbox.show()
self.ui.radius_label.show()
else:
self.ui.radius_spinbox.hide()
self.ui.radius_label.hide()
# orientation checkboxes
self.ui.checkBox_axial.checked = (self._parameterNode.GetParameter("axial") == "true")
self.ui.checkBox_coronal.checked = (self._parameterNode.GetParameter("coronal") == "true")
self.ui.checkBox_sagittal.checked = (self._parameterNode.GetParameter("sagittal") == "true")
self.ui.checkBox_orient_cons_tp.checked = (self._parameterNode.GetParameter("orient_cons_tp") == "true")
self.ui.checkBox_same_slc_tp.checked = (self._parameterNode.GetParameter("same_slc_tp") == "true")
# Update buttons states and tooltips
if self._parameterNode.GetNodeReference("InputVolume_channel1_t1") and self._parameterNode.GetNodeReference(
"outputSegmentation"):
self.ui.applyButton.toolTip = "Compute output volume"
self.ui.applyButton.enabled = True
else:
self.ui.applyButton.toolTip = "Select input and output volume nodes"
self.ui.applyButton.enabled = False
if self._parameterNode.GetNodeReference("meas2D_segmentation") and self._parameterNode.GetParameter(
"segmentID"):
self.ui.calc2DButton.toolTip = "Compute 2D lines"
self.ui.calc2DButton.enabled = True
else:
self.ui.calc2DButton.toolTip = "Select segment"
self.ui.calc2DButton.enabled = False
# All the GUI updates are done
self._updatingGUIFromParameterNode = False
[docs]
def updateParameterNodeFromGUI(self, caller=None, event=None):
"""
This method is called when the user makes any change in the GUI.
The changes are saved into the parameter node (so that they are restored when the scene is saved and loaded).
Args:
caller: The object that triggered the event.
event: The event that occurred.
"""
if debug: print("updateParameterNodeFromGUI")
if self._parameterNode is None or self._updatingGUIFromParameterNode:
return
wasModified = self._parameterNode.StartModify() # Modify all properties in a single batch
# timepoint 1
self._parameterNode.SetParameter("model_key", str(self.ui.modelComboBox.currentText))
self._parameterNode.SetNodeReferenceID("InputVolume_channel1_t1", self.ui.inputSelector_channel1_t1.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel2_t1", self.ui.inputSelector_channel2_t1.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel3_t1", self.ui.inputSelector_channel3_t1.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel4_t1", self.ui.inputSelector_channel4_t1.currentNodeID)
self._parameterNode.SetNodeReferenceID("outputSegmentation", self.ui.outputSelector.currentNodeID)
self._parameterNode.SetParameter("AffineReg", "true" if self.ui.affineregCheckBox.checked else "false")
self._parameterNode.SetParameter("InputIsBET", "true" if self.ui.inputisbetCheckBox.checked else "false")
# timepoint 2
self._parameterNode.SetParameter("model_key_t2", str(self.ui.modelComboBox_t2.currentText))
self._parameterNode.SetNodeReferenceID("InputVolume_channel1_t2", self.ui.inputSelector_channel1_t2.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel2_t2", self.ui.inputSelector_channel2_t2.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel3_t2", self.ui.inputSelector_channel3_t2.currentNodeID)
self._parameterNode.SetNodeReferenceID("InputVolume_channel4_t2", self.ui.inputSelector_channel4_t2.currentNodeID)
self._parameterNode.SetNodeReferenceID("outputSegmentation_t2", self.ui.outputSelector_t2.currentNodeID)
self._parameterNode.SetParameter("AffineReg_t2", "true" if self.ui.affineregCheckBox_t2.checked else "false")
self._parameterNode.SetParameter("InputIsBET_t2", "true" if self.ui.inputisbetCheckBox_t2.checked else "false")
# 2D measurements
self._parameterNode.SetNodeReferenceID("meas2D_segmentation", self.ui.SegmentSelectorWidget.currentNodeID())
self._parameterNode.SetParameter("segmentID",
self.ui.SegmentSelectorWidget.currentSegmentID() if self.ui.SegmentSelectorWidget.currentNodeID() else "")
self._parameterNode.SetParameter("method2Dmeas", self.ui.method2DmeasComboBox.currentText)
# orientation checkboxes
self._parameterNode.SetParameter("axial", "true" if self.ui.checkBox_axial.checked else "false")
self._parameterNode.SetParameter("coronal", "true" if self.ui.checkBox_coronal.checked else "false")
self._parameterNode.SetParameter("sagittal", "true" if self.ui.checkBox_sagittal.checked else "false")
self._parameterNode.SetParameter("orient_cons_tp", "true" if self.ui.checkBox_orient_cons_tp.checked else "false")
self._parameterNode.SetParameter("same_slc_tp", "true" if self.ui.checkBox_same_slc_tp.checked else "false")
self._parameterNode.EndModify(wasModified)
[docs]
def installAndImportDependencies():
"""
Dependency handling
"""
slicer_stdout = sys.stdout
try:
import PyTorchUtils
except ModuleNotFoundError:
raise Exception("This module requires the PyTorch extension.")
def ensure_package(import_name, spec):
import importlib
try:
return importlib.import_module(import_name)
except ModuleNotFoundError:
pip_install(spec)
importlib.invalidate_caches()
return importlib.import_module(import_name)
numpy = ensure_package("numpy", "numpy==2.0.2")
skimage = ensure_package("skimage", "scikit-image==0.24.0")
numba = ensure_package("numba", "numba==0.60.0")
nibabel = ensure_package("nibabel", "nibabel==5.3.2")
tqdm = ensure_package("tqdm", "tqdm==4.67.1")
yaml = ensure_package("yaml", "pyyaml==6.0.2")
reportlib = ensure_package("reportlab", "reportlab==4.4.1")
# Torch via Slicer extension
try:
import torch
except Exception:
torchLogic = PyTorchUtils.PyTorchUtilsLogic()
torchLogic.installTorch(
askConfirmation=False,
torchVersionRequirement=">=2.6.0,<=2.8.0"
)
ignite = ensure_package("ignite", "pytorch-ignite==0.5.2")
tensorboard = ensure_package("tensorboard", "tensorboard==2.19.0")
HD_BET = ensure_package("HD_BET", "hd-bet==2.0.1")
def installANTsPyX():
"""
source: https://github.com/SlicerMorph/SlicerANTsPy/blob/main/ANTsRegistration/ANTsRegistration.py
"""
try:
import ants
except:
import platform
if platform.system() == 'Linux':
import urllib.request
import tempfile
# Download the wheel file from Box
download_url = 'https://app.box.com/shared/static/mu1gy26t80oopbtv3mndl5yveb6s4431.whl'
temp_dir = tempfile.gettempdir()
whl_filename = 'antspyx-0.6.2-cp312-cp312-linux_x86_64.whl'
whl_path = os.path.join(temp_dir, whl_filename)
print(f"Downloading antspyx wheel from {download_url}")
urllib.request.urlretrieve(download_url, whl_path)
print(f"Downloaded to {whl_path}")
pip_install(whl_path)
# Clean up the downloaded file
try:
os.remove(whl_path)
except:
pass
else:
pip_install('antspyx')
installANTsPyX()
import ants
monai = ensure_package("monai", "git+https://github.com/aaronkujawa/MONAI.git@rano")
sys.stdout = slicer_stdout
def download_model_weights_and_test_data_from_zenodo():
doi = "10.5281/zenodo.15411078"
fname = "rano2.0-assist.zip"
checksum = "md5:df95653320bee3182775cde51ffad298"
if os.path.isdir(test_data_path) and os.path.isdir(os.path.join(dynunet_pipeline_path, "data", "tasks")):
# model weights and test data already downloaded
return
print("Downloading model weights and test data.")
pooch = ensure_package("pooch", "pooch==1.8.2")
zip_path = pooch.retrieve(
url=f"doi:{doi}/{fname}",
known_hash=checksum,
progressbar=True,
)
print("Completed downloading model weights and test data.")
extract_dir = os.path.join("tmp_extract")
if not os.path.exists(extract_dir):
os.makedirs(extract_dir)
with zipfile.ZipFile(zip_path, 'r') as z:
z.extractall(extract_dir)
print("Extracted model weights and test data.")
src = os.path.join(extract_dir, "rano2.0-assist", "dynunet_pipeline")
dst = dynunet_pipeline_path
shutil.copytree(src, dst, dirs_exist_ok=True) # merges + overwrites files
print("Installed model weights.")
src = os.path.join(extract_dir, "rano2.0-assist", "data", "test_data")
dst = test_data_path
shutil.copytree(src, dst, dirs_exist_ok=True)
print("Installed test data.")
shutil.rmtree(extract_dir)
download_model_weights_and_test_data_from_zenodo()
[docs]
def installExtension(extensionName):
em = slicer.app.extensionsManagerModel()
# Check if the extension is already installed
if extensionName not in em.installedExtensions:
# Ask user whether to install the extension
msgBox = QMessageBox()
msgBox.setIcon(QMessageBox.Warning)
msgBox.setWindowTitle("Extension Installation")
msgBox.setText(f"The extension '{extensionName}' will be installed.")
msgBox.setInformativeText("This will trigger a restart of Slicer. Do you want to continue?")
msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msgBox.setDefaultButton(QMessageBox.No)
response = msgBox.exec_()
if response == QMessageBox.Yes:
em.interactive = False # prevent display of other popups
restart = True
if not em.installExtensionFromServer(extensionName, restart):
raise ValueError(f"Failed to install {extensionName} extension")
else:
print(f"Installation of '{extensionName}' cancelled by user.")