-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 3947b1e
Showing
10 changed files
with
1,046 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
cmake_minimum_required(VERSION 3.5) | ||
|
||
project(import3DDose) | ||
|
||
#----------------------------------------------------------------------------- | ||
# Extension meta-information | ||
set(EXTENSION_HOMEPAGE "https://github.com/keithoffer/3DSlicer-Import3DDose") | ||
set(EXTENSION_CATEGORY "Importer") | ||
set(EXTENSION_CONTRIBUTORS "Keith Offer (None)") | ||
set(EXTENSION_DESCRIPTION "A simple extension to import .3ddose files from DOSXYZnrc") | ||
set(EXTENSION_ICONURL "") | ||
set(EXTENSION_SCREENSHOTURLS "") | ||
set(EXTENSION_DEPENDS "NA") # Specified as a space separated string, a list or 'NA' if any | ||
|
||
#----------------------------------------------------------------------------- | ||
# Extension dependencies | ||
find_package(Slicer REQUIRED) | ||
include(${Slicer_USE_FILE}) | ||
|
||
#----------------------------------------------------------------------------- | ||
# Extension modules | ||
add_subdirectory(import3DDose) | ||
## NEXT_MODULE | ||
|
||
#----------------------------------------------------------------------------- | ||
include(${Slicer_EXTENSION_GENERATE_CONFIG}) | ||
include(${Slicer_EXTENSION_CPACK}) |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
Import 3DDose 1.0.0 | ||
=================== | ||
|
||
A simple plugin for 3D Slicer 4.8.0+ to import .3ddose files from DOSxyznrc. | ||
|
||
This was mainly an excuse for me to explore the 3D Slicer python plugin API, but it works pretty well for quickly checking results from simulations. | ||
|
||
![Animated picture showing operation of the plugin](Screenshots/preview.gif?raw=true) | ||
|
||
Installation | ||
------------ | ||
In 3D Slicer, load the Extension Wizard module and then click "Select Extension". Browse to this folder and click OK. You can also then add this folder to the module search path to auto-load the module on load. | ||
|
||
Usage | ||
----- | ||
Once the module is selected, press the "Import *.3ddose" button to browse and load a .3ddose file. There are a couple of options to choose before import, mainly whether to import the dose / uncertainties and if to import them into new volumes or overwrite existing ones. You can also choose to normalise the dose to the range [0,1] rather than the actual values in the .3ddose file. This can be useful as the very small numbers in .3ddose files aren't shown particularly well in 3D Slicer (data probe often just shows 0) and sometimes the colour lookup table can act strange for a similar reason. | ||
|
||
Known issues | ||
------------ | ||
|
||
As far as I understand, ITK (which 3D Slicer uses under the hood to store image volumes) dosen't support varying the voxel size along an axis (i.e. the voxels can be different sizes in the X/Y/Z directions, but only one size is supported along each axis). Often .3ddose files have voxels of varying size at different location. This plugin will import those files, but the spacing will be set to the first size found in each direction and will not be geometrically accurate. A warning message will be shown in this case. | ||
|
||
License | ||
------- | ||
|
||
Import 3DDose is copyrighted free software made available under the terms of the GPLv3 | ||
|
||
Copyright: (C) 2017 by Keith Offer. All Rights Reserved. |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
#----------------------------------------------------------------------------- | ||
set(MODULE_NAME import3DDose) | ||
|
||
#----------------------------------------------------------------------------- | ||
set(MODULE_PYTHON_SCRIPTS | ||
${MODULE_NAME}.py | ||
) | ||
|
||
set(MODULE_PYTHON_RESOURCES | ||
Resources/Icons/${MODULE_NAME}.png | ||
) | ||
|
||
#----------------------------------------------------------------------------- | ||
slicerMacroBuildScriptedModule( | ||
NAME ${MODULE_NAME} | ||
SCRIPTS ${MODULE_PYTHON_SCRIPTS} | ||
RESOURCES ${MODULE_PYTHON_RESOURCES} | ||
WITH_GENERIC_TESTS | ||
) | ||
|
||
#----------------------------------------------------------------------------- | ||
if(BUILD_TESTING) | ||
|
||
# Register the unittest subclass in the main script as a ctest. | ||
# Note that the test will also be available at runtime. | ||
slicer_add_python_unittest(SCRIPT ${MODULE_NAME}.py) | ||
|
||
# Additional build-time testing | ||
add_subdirectory(Testing) | ||
endif() |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
add_subdirectory(Python) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
|
||
#slicer_add_python_unittest(SCRIPT ${MODULE_NAME}ModuleTest.py) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,284 @@ | ||
import os | ||
import vtk, qt, ctk, slicer | ||
from slicer.ScriptedLoadableModule import * | ||
import numpy as np | ||
|
||
class import3DDose(ScriptedLoadableModule): | ||
def __init__(self, parent): | ||
ScriptedLoadableModule.__init__(self, parent) | ||
self.parent.title = "Import3DDose" | ||
self.parent.categories = ["Importer"] | ||
self.parent.dependencies = [] | ||
self.parent.contributors = ["Keith Offer (None)"] | ||
self.parent.helpText = """ | ||
A simple extension to import .3ddose files from DOSXYZnrc. See See <a href="https://github.com/keithoffer/3DSlicer-Import3DDose">GitHub</a> for more information. | ||
""" | ||
self.parent.acknowledgementText = "" | ||
|
||
class import3DDoseWidget(ScriptedLoadableModuleWidget): | ||
def setup(self): | ||
ScriptedLoadableModuleWidget.setup(self) | ||
|
||
# All the GUI components in the pane | ||
self.dose_options_collapsible_button = ctk.ctkCollapsibleButton() | ||
self.dose_options_collapsible_button.text = "Dose Options" | ||
self.layout.addWidget(self.dose_options_collapsible_button) | ||
self.dose_form_layout = qt.QFormLayout(self.dose_options_collapsible_button) | ||
|
||
self.import_dose_check_box = qt.QCheckBox() | ||
self.import_dose_check_box.setText("Import dose values") | ||
self.import_dose_check_box.checked = 1 | ||
self.import_dose_check_box.setToolTip("Import the dose values in a .3ddose file") | ||
self.dose_form_layout.addWidget(self.import_dose_check_box) | ||
|
||
self.normalise_dose_check_box = qt.QCheckBox() | ||
self.normalise_dose_check_box.setText("Normalise dose values") | ||
self.normalise_dose_check_box.checked = 0 | ||
self.normalise_dose_check_box.setToolTip( | ||
"Dose values in .3ddose files are typically very small (i.e. less than 1E-10). 3D Slicer isn't really designed to work with values this small, and it can lead to issues." | ||
"Turning this feature on normalises the results in the .3ddose file to a range of [0,1]. 3D Slicer handles these values much better, but you lose some information." | ||
"If this option is picked, the maximum value in the dose array before normalisation is printed to the console for use in un-normalising the data if needed.") | ||
self.dose_form_layout.addWidget(self.normalise_dose_check_box) | ||
|
||
self.overwrite_dose_volume_check_box = qt.QCheckBox() | ||
self.overwrite_dose_volume_check_box.setText("Overwrite existing volume") | ||
self.overwrite_dose_volume_check_box.checked = 0 | ||
self.overwrite_dose_volume_check_box.setToolTip("Import the dose values into an existing volume") | ||
self.dose_form_layout.addWidget(self.overwrite_dose_volume_check_box) | ||
|
||
self.dose_volume_combo_box = slicer.qMRMLNodeComboBox() | ||
self.dose_volume_combo_box.nodeTypes = ["vtkMRMLScalarVolumeNode"] | ||
self.dose_volume_combo_box.selectNodeUponCreation = True | ||
self.dose_volume_combo_box.addEnabled = True | ||
self.dose_volume_combo_box.removeEnabled = True | ||
self.dose_volume_combo_box.noneEnabled = True | ||
self.dose_volume_combo_box.showHidden = False | ||
self.dose_volume_combo_box.showChildNodeTypes = False | ||
self.dose_volume_combo_box.setMRMLScene(slicer.mrmlScene) | ||
self.dose_volume_combo_box.setToolTip("Pick the output volume to store the dose") | ||
self.dose_volume_combo_box.setEnabled(False) | ||
self.dose_form_layout.addWidget(self.dose_volume_combo_box) | ||
|
||
self.uncertainty_options_collapsible_button = ctk.ctkCollapsibleButton() | ||
self.uncertainty_options_collapsible_button.text = "Uncertainty Options" | ||
self.layout.addWidget(self.uncertainty_options_collapsible_button) | ||
self.uncertainty_form_layout = qt.QFormLayout(self.uncertainty_options_collapsible_button) | ||
|
||
self.import_uncertainty_check_box = qt.QCheckBox() | ||
self.import_uncertainty_check_box.setText("Import uncertainty values") | ||
self.import_uncertainty_check_box.checked = 1 | ||
self.import_uncertainty_check_box.setToolTip("Import the uncertainties in the .3ddose file") | ||
self.uncertainty_form_layout.addWidget(self.import_uncertainty_check_box) | ||
|
||
self.overwrite_uncertainty_volume_check_box = qt.QCheckBox() | ||
self.overwrite_uncertainty_volume_check_box.setText("Overwrite existing volume") | ||
self.overwrite_uncertainty_volume_check_box.checked = 0 | ||
self.overwrite_uncertainty_volume_check_box.setToolTip("Import the uncertainty values into an existing volume") | ||
self.uncertainty_form_layout.addWidget(self.overwrite_uncertainty_volume_check_box) | ||
|
||
self.uncertainty_volume_combo_box = slicer.qMRMLNodeComboBox() | ||
self.uncertainty_volume_combo_box.nodeTypes = ["vtkMRMLScalarVolumeNode"] | ||
self.uncertainty_volume_combo_box.selectNodeUponCreation = True | ||
self.uncertainty_volume_combo_box.addEnabled = True | ||
self.uncertainty_volume_combo_box.removeEnabled = True | ||
self.uncertainty_volume_combo_box.noneEnabled = True | ||
self.uncertainty_volume_combo_box.showHidden = False | ||
self.uncertainty_volume_combo_box.showChildNodeTypes = False | ||
self.uncertainty_volume_combo_box.setMRMLScene(slicer.mrmlScene) | ||
self.uncertainty_volume_combo_box.setToolTip("Pick the output volume to store the uncertainties") | ||
self.uncertainty_volume_combo_box.setEnabled(False) | ||
self.uncertainty_form_layout.addWidget(self.uncertainty_volume_combo_box) | ||
|
||
self.import_button = qt.QPushButton("Import .3ddose") | ||
self.import_button.toolTip = "Browse for a .3ddose file to import" | ||
self.layout.addWidget(self.import_button) | ||
|
||
# Signals + slot connections | ||
self.import_button.clicked.connect(self.extension_invoked) | ||
self.overwrite_uncertainty_volume_check_box.clicked.connect(lambda state : self.uncertainty_volume_combo_box.setEnabled(state)) | ||
self.overwrite_dose_volume_check_box.clicked.connect(lambda state: self.dose_volume_combo_box.setEnabled(state)) | ||
|
||
# Add vertical spacer | ||
self.layout.addStretch(1) | ||
|
||
def extension_invoked(self): | ||
""" | ||
Function called when the import button is pressed. Handles converting the GUI settings into parameters | ||
for the core logic function and calling it | ||
:return: | ||
""" | ||
extension_logic = import3DDoseLogic() | ||
filepath = qt.QFileDialog.getOpenFileName(None, 'Load .3ddose file', '.', '*.3ddose') | ||
print(filepath) | ||
if filepath != '' and (self.import_dose_check_box.checked or self.import_uncertainty_check_box.checked): | ||
if self.overwrite_dose_volume_check_box.checked: | ||
dose_volume_node_to_overwrite = self.dose_volume_combo_box.currentNode() | ||
|
||
if not slicer.util.confirmYesNoDisplay("Are you sure you want to overwrite volume " + dose_volume_node_to_overwrite.GetName() + "?"): | ||
return | ||
else: | ||
dose_volume_node_to_overwrite = None | ||
|
||
if self.overwrite_uncertainty_volume_check_box.checked: | ||
uncertainties_volume_node_to_overwrite = self.uncertainty_volume_combo_box.currentNode() | ||
|
||
if not slicer.util.confirmYesNoDisplay("Are you sure you want to overwrite volume " + uncertainties_volume_node_to_overwrite.GetName() + "?"): | ||
return | ||
else: | ||
uncertainties_volume_node_to_overwrite = None | ||
|
||
extension_logic.run(filepath, normalise_dose=self.normalise_dose_check_box.checked, | ||
import_dose = self.import_dose_check_box.checked, | ||
import_uncertainty=self.import_uncertainty_check_box.checked, | ||
dose_volume_node_to_overwrite=dose_volume_node_to_overwrite, | ||
uncertainty_volume_node_to_overwrite=uncertainties_volume_node_to_overwrite) | ||
|
||
class import3DDoseLogic(ScriptedLoadableModuleLogic): | ||
""" | ||
Wrapper class for the main logic of this extension. | ||
""" | ||
def run(self, filepath, import_dose = True, import_uncertainty = True, normalise_dose = False, | ||
dose_volume_node_to_overwrite=None,uncertainty_volume_node_to_overwrite=None): | ||
""" | ||
Main function that imports the .3ddose file to vtkMRMLScalarVolumeNodes | ||
:param filepath: Path to the .3ddose file to be imported | ||
:param import_dose: Whether to create a volume containing the dose values in the .3ddose file | ||
:param import_uncertainty: Whether to create a volume containing the dose values in the .3ddose file | ||
:param normalise_dose: Whether to normalise the dose values in the volume to the range [0,1] | ||
:param dose_volume_node_to_overwrite: The volume to overwrite with the dose values (None implies create a new volume) | ||
:param uncertainty_volume_node_to_overwrite: The volume to overwrite with the uncertainty values (None implies create a new volume) | ||
:return: True if completed successfully | ||
""" | ||
with open(filepath, 'r') as dosefile: | ||
try: | ||
# The code to read the .3ddose file mainly taken from https://gist.github.com/ftessier/086238152486749eab2f/ | ||
# get voxel counts on first line | ||
nx, ny, nz = map(int, dosefile.readline().split()) # number of voxels along x, y, z | ||
Ng = (nx + 1) + (ny + 1) + (nz + 1) # total number of voxel grid values (voxels+1) | ||
Nd = nx * ny * nz # total number of data points | ||
|
||
# get voxel grid, dose and relative uncertanties | ||
data = list(map(float, dosefile.read().split())) # read the rest of the file | ||
xgrid = data[:nx + 1] # voxel boundaries in x (nx+1 values, 0 to nx) | ||
ygrid = data[nx + 1:nx + 1 + ny + 1] # voxel boundaries in y (ny+1 values, nx+1 to nx+1+ny) | ||
zgrid = data[nx + 1 + ny + 1:Ng] # voxel boundaries in z (nz+1 values, rest up to Ng-1) | ||
dose = data[Ng:Nd + Ng] # flat array of Nd = nx*ny*nz dose values | ||
errs = data[Nd + Ng:] # flat array of Nd = nx*ny*nz relative uncertainty values | ||
|
||
dose_array = np.flip(np.reshape(dose,(nz,nx,ny)),axis=0) | ||
if normalise_dose: | ||
print("Max dose value before normalisation was " + str(dose_array.max())) | ||
dose_array /= dose_array.max() | ||
uncertainty_array = np.flip(np.reshape(errs, (nz, nx, ny)),axis=0) | ||
except ValueError as e: | ||
slicer.util.errorDisplay("An error occured reading in the .3ddose file (is it a valid file)?\n" | ||
"Exception: " + str(e)) | ||
return False | ||
|
||
del data, dose, errs # delete temp variables | ||
|
||
if not (all_equal_spacing(xgrid) and all_equal_spacing(ygrid) and all_equal_spacing(zgrid)): | ||
slicer.util.warningDisplay( | ||
"""Voxel spacing changes along atleast one axis. The voxel size must be constant along each axis for 3DSlicer to display it correctly. | ||
Proceeding with the import, but spatially some information will be incorrect.""") | ||
|
||
volume_base_name = os.path.splitext(os.path.basename(filepath))[0] | ||
dose_volume_name = volume_base_name + '_dose' | ||
uncertainty_volume_name = volume_base_name + '_uncertainty' | ||
|
||
# Voxel spacing in each dimension (converted from mm to cm) | ||
dx = (xgrid[1] - xgrid[0])*10 | ||
dy = (ygrid[1] - ygrid[0])*10 | ||
dz = (zgrid[1] - zgrid[0])*10 | ||
image_size = (nx,ny,nz) | ||
image_spacing = (dx, dy, dz) | ||
image_origin = (-0.5 * (nx - 1) * dx, -0.5 * (ny - 1) * dy, -0.5 * (nz - 1) * dz) | ||
|
||
app_logic = slicer.app.applicationLogic() | ||
selection_node = app_logic.GetSelectionNode() | ||
if import_dose: | ||
if dose_volume_node_to_overwrite is None: | ||
volume_node_dose = create_new_volume_from_array(dose_array, image_size, image_spacing, name=dose_volume_name) | ||
else: | ||
slicer.util.updateVolumeFromArray(dose_volume_node_to_overwrite, dose_array) | ||
update_visualisation_settings(dose_volume_node_to_overwrite, dose_array) | ||
dose_volume_node_to_overwrite.SetSpacing(image_spacing) | ||
dose_volume_node_to_overwrite.SetOrigin(image_origin) | ||
volume_node_dose = dose_volume_node_to_overwrite | ||
selection_node.SetActiveVolumeID(volume_node_dose.GetID()) | ||
|
||
if import_uncertainty: | ||
if uncertainty_volume_node_to_overwrite is None: | ||
volume_node_uncertainty = create_new_volume_from_array(uncertainty_array, image_size, image_spacing, name=uncertainty_volume_name) | ||
else: | ||
slicer.util.updateVolumeFromArray(uncertainty_volume_node_to_overwrite, uncertainty_array) | ||
update_visualisation_settings(uncertainty_volume_node_to_overwrite, uncertainty_array) | ||
uncertainty_volume_node_to_overwrite.SetSpacing(image_spacing) | ||
uncertainty_volume_node_to_overwrite.SetOrigin(image_origin) | ||
volume_node_uncertainty = dose_volume_node_to_overwrite | ||
if not import_dose: | ||
selection_node.SetActiveVolumeID(volume_node_uncertainty.GetID()) | ||
|
||
app_logic.PropagateVolumeSelection() | ||
return True | ||
|
||
def all_equal_spacing(array): | ||
""" | ||
Returns True if the 1D numpy array has a constant spacing between the values | ||
:param array: 1D array to be checked | ||
:return: If the spacing between each value in the array is constant | ||
""" | ||
differences = list(np.ediff1d(array)) | ||
return differences.count(differences[0]) == len(differences) | ||
|
||
def update_visualisation_settings(volumeNode,array): | ||
""" | ||
Updates the window, level and colormap for the volumeNode based on values in the array | ||
:param volumeNode: Volume node to be updated | ||
:param array: Array to derive optimal visualisation values | ||
:return: | ||
""" | ||
displayNode = slicer.vtkMRMLScalarVolumeDisplayNode() | ||
displayNode.SetAutoWindowLevel(0) | ||
max_val = array.max() | ||
min_val = array.min() | ||
displayNode.SetWindowLevel(max_val - min_val, (max_val - min_val) / 2) | ||
slicer.mrmlScene.AddNode(displayNode) | ||
colorNode = slicer.util.getNode('Viridis') | ||
displayNode.SetAndObserveColorNodeID(colorNode.GetID()) | ||
volumeNode.SetAndObserveDisplayNodeID(displayNode.GetID()) | ||
|
||
def create_new_volume_from_array(array,size,spacing,name=None): | ||
""" | ||
Creates a new slicer volume from a 3D numpy array | ||
:param array: Values in the new volume | ||
:param size: Physical size of the new image volume | ||
:param spacing: Spacing between the voxels in the image | ||
:param name: Name to be given to the created volume | ||
:return: vtkMRMLScalarVolumeNode created | ||
""" | ||
nx, ny, nz = size | ||
dx, dy, dz = spacing | ||
imageOrigin = (-0.5 * (nx - 1) * dx, -0.5 * (ny - 1) * dy, -0.5 * (nz - 1) * dz) | ||
voxelType = vtk.VTK_DOUBLE | ||
# Create an empty image volume | ||
imageData = vtk.vtkImageData() | ||
imageData.SetDimensions(size) | ||
imageData.AllocateScalars(voxelType, 1) | ||
# Create volume node | ||
volumeNode = slicer.vtkMRMLScalarVolumeNode() | ||
volumeNode.SetSpacing(spacing) | ||
volumeNode.SetOrigin(imageOrigin) | ||
# Add volume to scene | ||
slicer.mrmlScene.AddNode(volumeNode) | ||
update_visualisation_settings(volumeNode,array) | ||
volumeNode.CreateDefaultStorageNode() | ||
if name is not None: | ||
volumeNode.SetName(name) | ||
slicer.util.updateVolumeFromArray(volumeNode, array) | ||
return volumeNode | ||
|
||
class import3DDoseTest(ScriptedLoadableModuleTest): | ||
def runTest(self): | ||
# No tests :( | ||
print("No tests") |