--- a +++ b/Scripted/MusculoskeletalAnalysis/MusculoskeletalAnalysis.py @@ -0,0 +1,908 @@ +import logging +import os +import time +import vtk +import importlib + +import slicer +from slicer.ScriptedLoadableModule import * +from slicer.util import VTKObservationMixin + + +# +# MusculoskeletalAnalysis +# + +class MusculoskeletalAnalysis(ScriptedLoadableModule): + """Uses ScriptedLoadableModule base class, available at: + https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py + """ + + def __init__(self, parent): + ScriptedLoadableModule.__init__(self, parent) + self.parent.title = "Musculoskeletal Analysis" + self.parent.categories = ["Quantification"] + self.parent.dependencies = ["CorticalAnalysis", "CancellousAnalysis", "DensityAnalysis", "IntervertebralAnalysis"] # TODO: add here list of module names that this module requires + self.parent.contributors = ["Joseph Szatkowski (Washington University in St. Louis)"] + # TODO: update with short description of the module and a link to online module documentation + self.parent.helpText = """ + 1. Select a volume containing the image to analyze.\n + 2. Select a segmentation representing the area to analyze. For cortical analysis exclude the medullary cavity. For cancellous analysis exclude the cortical bone.\n + 3. Use the threshold slider to select a threshold identifying the bone.\n + 4. Select the function to perform. See <a href="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/blob/main/README.md">here</a> for more information.\n + 5. Select the directory to send the output files to. If files already exist they will be appended to.\n + 6. Click "Apply"\n + ADVANCED\n + 7. If the image volume is not the original DICOM, select the original DICOM node to get DICOM tags from. + """ + # TODO: replace with organization, grant and thanks + self.parent.acknowledgementText = """ + Developed by the Washington University in St. Louis Musculoskeletal Reseach Center with the assistance of Michael Brodt, Anish Jagannathan, Matthew Silva, and Simon Tang.\n + This file was partially funded by NIH grant P30 AR074992. + """ + + # Additional initialization step after application startup is complete + slicer.app.connect("startupCompleted()", registerSampleData) + + +# +# Register sample data sets in Sample Data module +# + +def registerSampleData(): + """ + Add data sets to Sample Data module. + """ + # It is always recommended to provide sample data for users to make it easy to try the module, + # but if no sample data is available then this method (and associated startupCompeted signal connection) can be removed. + + import SampleData + iconsPath = os.path.join(os.path.dirname(__file__), 'Resources/Icons') + + # To ensure that the source code repository remains small (can be downloaded and installed quickly) + # it is recommended to store data sets that are larger than a few MB in a Github release. + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='Cortical1', + # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder. + # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single". + thumbnailFileName=os.path.join(iconsPath, 'Cortical1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalSample1.nrrd", + fileNames='CorticalSample1.nrrd', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:740fcdbe9c7341ffeb2fb44e68e25098077281e11d541163379bf301db4b65b9', + # This node name will be used when the data set is loaded + nodeNames='Cortical1' + ) + + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='CorticalMask1', + # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder. + # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single". + thumbnailFileName=os.path.join(iconsPath, 'CorticalMask1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalMaskSample1.seg.nrrd", + fileNames='CorticalMaskSample1.seg.nrrd', + loadFileType='SegmentationFile', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:1cdd2ea240d848d3d5241eddffe2467f5bf49f80dab50937c8127eb511fa3b9a', + # This node name will be used when the data set is loaded + nodeNames='CorticalMask1' + ) + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='Cortical2', + # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder. + # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single". + thumbnailFileName=os.path.join(iconsPath, 'Cortical2.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalSample2.nrrd", + fileNames='CorticalSample2.nrrd', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:8e0869839abc008000d32d8ffef923a89ee7fd10fd974d63baa1daecb47154f5', + # This node name will be used when the data set is loaded + nodeNames='Cortical2' + ) + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='CorticalMask2', + # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder. + # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single". + thumbnailFileName=os.path.join(iconsPath, 'CorticalMask2.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalMaskSample2.seg.nrrd", + fileNames='CorticalMaskSample2.seg.nrrd', + loadFileType='SegmentationFile', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:509b7e5b16a838bf743ec083677ac7a1a2b1f7f3d2fb8ad93e75b38619150d7e', + # This node name will be used when the data set is loaded + nodeNames='CorticalMask2' + ) + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='Cancellous1', + thumbnailFileName=os.path.join(iconsPath, 'Cancellous1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousSample1.nrrd", + fileNames='CancellousSample1.nrrd', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:cb47e20fd9d4caf210a256db8317f2553f409399834b0bc15b28e57daf46ba89', + # This node name will be used when the data set is loaded + nodeNames='Cancellous1' + ) + + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='CancellousMask1', + thumbnailFileName=os.path.join(iconsPath, 'CancellousMask1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousMaskSample1.seg.nrrd", + fileNames='CancellousMaskSample1.seg.nrrd', + loadFileType='SegmentationFile', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:1d632557a415b0d84dfaca4bf743aa9319e5a5e805e7a997d76c6f7bd9e75160', + # This node name will be used when the data set is loaded + nodeNames='CancellousMask1' + ) + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='Cancellous2', + thumbnailFileName=os.path.join(iconsPath, 'Cancellous2.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousSample2.nrrd", + fileNames='CancellousSample2.nrrd', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:fd594a087700afcb3a1fc6c0ee4fa087f263eda86f93f0cfc317927876bda813', + # This node name will be used when the data set is loaded + nodeNames='Cancellous2' + ) + + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='CancellousMask2', + thumbnailFileName=os.path.join(iconsPath, 'CancellousMask2.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousMaskSample2.seg.nrrd", + fileNames='CancellousMaskSample2.seg.nrrd', + loadFileType='SegmentationFile', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:473cb8d1a2ccc8973eb7f0b69eb297d2d4119155b1a9940cb5e11d2ccd4e1315', + # This node name will be used when the data set is loaded + nodeNames='CancellousMask2' + ) + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='Intervertebral1', + thumbnailFileName=os.path.join(iconsPath, 'Intervertebral1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/IntervertebralSample1.nrrd", + fileNames='Intervertebral1.nrrd', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:26706cec367bded189182e8b3e02c804bdad5267cee25f95202ee240823841a9', + # This node name will be used when the data set is loaded + nodeNames='Intervertebral1' + ) + + + SampleData.SampleDataLogic.registerCustomSampleDataSource( + # Category and sample name displayed in Sample Data module + category='MusculoskeletalAnalysis', + sampleName='IntervertebralMask1', + thumbnailFileName=os.path.join(iconsPath, 'IntervertebralMask1.png'), + # Download URL and target file name + uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/IntervertebralMaskSample1.seg.nrrd", + fileNames='IntervertebralMaskSample1.seg.nrrd', + loadFileType='SegmentationFile', + # Checksum to ensure file integrity. Can be computed by this command: + # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) + checksums='SHA256:7c140faa9924dd47d14df1208d5dc130479a8c64bfba6b741e02362465f7183f', + # This node name will be used when the data set is loaded + nodeNames='IntervertebralMask1' + ) + +# +# MusculoskeletalAnalysisWidget +# + +class MusculoskeletalAnalysisWidget(ScriptedLoadableModuleWidget, VTKObservationMixin): + """Uses ScriptedLoadableModuleWidget base class, available at: + https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py + """ + + 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.logic = None + self._parameterNode = None + self._updatingGUIFromParameterNode = False + + def setup(self): + """ + Called when the user opens the module the first time and the widget is initialized. + """ + ScriptedLoadableModuleWidget.setup(self) + + # Load widget from .ui file (created by Qt Designer). + # Additional widgets can be instantiated manually and added to self.layout. + uiWidget = slicer.util.loadUI(self.resourcePath('UI/MusculoskeletalAnalysis.ui')) + self.layout.addWidget(uiWidget) + self.ui = slicer.util.childWidgetVariables(uiWidget) + + # Set scene in MRML widgets. Make sure that in Qt designer the top-level qMRMLWidget's + # "mrmlSceneChanged(vtkMRMLScene*)" signal in is connected to each MRML widget's. + # "setMRMLScene(vtkMRMLScene*)" slot. + uiWidget.setMRMLScene(slicer.mrmlScene) + + # Create logic class. Logic implements all computations that should be possible to run + # in batch mode, without a graphical user interface. + self.logic = MusculoskeletalAnalysisLogic() + + # 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) + + # These connections ensure that whenever user changes some settings on the GUI, that is saved in the MRML scene + # (in the selected parameter node). + self.ui.inputSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.inputVolumeChanged) + self.ui.segmentSelector.connect("currentNodeChanged(vtkMRMLNode*)",self.segmentNodeChanged) + self.ui.segmentSelector.connect("currentSegmentChanged(QString)",self.segmentChanged) + self.ui.segmentSelector.connect("segmentSelectionChanged(QStringList)", self.segmentChanged) + self.ui.thresholdSelector.connect("thresholdValuesChanged(double, double)", self.updateParameterNodeFromGUI) + self.ui.analysisSelector.connect("currentTextChanged(const QString)", self.updateParameterNodeFromGUI) + self.ui.DICOMOptions.connect("buttonClicked(QAbstractButton*)", self.updateParameterNodeFromGUI) + self.ui.DICOMSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.DICOMSeriesChanged) + self.ui.voxelSizeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.scalingLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.densitySlopeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.densityInterceptLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.rescaleSlopeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.rescaleInterceptLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI) + self.ui.outputDirectorySelector.connect("currentPathChanged(const QString)", self.updateParameterNodeFromGUI) + + # Buttons + self.ui.applyButton.connect('clicked(bool)', self.onApplyButton) + + # Hidden elements + self.ui.AnalysisProgress.hide() + # Make sure parameter node is initialized (needed for module reload) + self.initializeParameterNode() + + + + + def cleanup(self): + """ + Called when the application closes and the module widget is destroyed. + """ + self.removeObservers() + + def enter(self): + """ + Called each time the user opens this module. + """ + # Make sure parameter node exists and observed + self.initializeParameterNode() + + def exit(self): + """ + Called each time the user opens a different module. + """ + # Do not react to parameter node changes (GUI wlil be updated when the user enters into the module) + self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) + + def onSceneStartClose(self, caller, event): + """ + Called just before the scene is closed. + """ + # Parameter node will be reset, do not use it anymore + self.setParameterNode(None) + + def onSceneEndClose(self, caller, event): + """ + Called just after the scene is closed. + """ + # If this module is shown while the scene is closed then recreate a new parameter node immediately + if self.parent.isEntered: + self.initializeParameterNode() + + 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()) + + # Select default input nodes if nothing is selected yet to save a few clicks for the user + if not self._parameterNode.GetNodeReference("InputVolume"): + firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode") + if firstVolumeNode: + self._parameterNode.SetNodeReferenceID("InputVolume", firstVolumeNode.GetID()) + + if not self._parameterNode.GetNodeReference("SegmentNode"): + firstSegmentNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSegmentationNode") + if firstSegmentNode: + self._parameterNode.SetNodeReferenceID("SegmentNode", firstSegmentNode.GetID()) + self.ui.segmentSelector.setCurrentNode(firstSegmentNode) + if not self._parameterNode.GetParameter("SegmentID"): + if self._parameterNode.GetNodeReference("SegmentNode"): + segmentation = self._parameterNode.GetNodeReference("SegmentNode").GetSegmentation() + self._parameterNode.SetParameter("SegmentID", segmentation.GetNthSegmentID(0)) + + + + if not self._parameterNode.GetNodeReference("DICOMNode"): + firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode") + if firstVolumeNode: + self._parameterNode.SetNodeReferenceID("DICOMNode", firstVolumeNode.GetID()) + + # Set default state for flags + self._parameterNode.SetParameter("Analyzing", "False") + + + + + + + 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. + """ + + if inputParameterNode: + 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: + 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() + + 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. + """ + + 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 + + # Update node selectors and sliders + + self.ui.thresholdSelector.setMRMLVolumeNode(self._parameterNode.GetNodeReference("InputVolume")) + if self._parameterNode.GetParameter("LowerThreshold") and self._parameterNode.GetParameter("UpperThreshold"): + self.ui.thresholdSelector.setLowerThreshold(float(self._parameterNode.GetParameter("LowerThreshold"))) + self.ui.thresholdSelector.setUpperThreshold(float(self._parameterNode.GetParameter("UpperThreshold"))) + self.ui.analysisSelector.setCurrentText(str(self._parameterNode.GetParameter("Analysis"))) + self.ui.AlternateDICOMCheckBox.setChecked(self._parameterNode.GetParameter("UseAlt")=="True") + self.ui.ManualDICOMCheckBox.setChecked(self._parameterNode.GetParameter("UseMan")=="True") + self.ui.DICOMSelector.setCurrentNode(self._parameterNode.GetNodeReference("DICOMNode")) + self.ui.voxelSizeLineEdit.setText(self._parameterNode.GetParameter("0018,0050")) + self.ui.scalingLineEdit.setText(self._parameterNode.GetParameter("0029,1000")) + self.ui.densitySlopeLineEdit.setText(self._parameterNode.GetParameter("0029,1004")) + self.ui.densityInterceptLineEdit.setText(self._parameterNode.GetParameter("0029,1005")) + self.ui.rescaleSlopeLineEdit.setText(self._parameterNode.GetParameter("0028,1053")) + self.ui.rescaleInterceptLineEdit.setText(self._parameterNode.GetParameter("0028,1052")) + self.ui.outputDirectorySelector.setCurrentPath(str(self._parameterNode.GetParameter("OutputDirectory"))) + + + # Update buttons states and tooltips + if self._parameterNode.GetNodeReference("InputVolume"): + self.ui.thresholdSelector.enabled = True + else: + self.ui.thresholdSelector.enabled = False + + if self._parameterNode.GetParameter("MultiSelect") == 'True': + self.ui.segmentSelector.multiSelection = True + self.ui.thresholdSelector.enabled = False + self.ui.thresholdSelector.setVisible(False) + else: + self.ui.segmentSelector.multiSelection = False + self.ui.thresholdSelector.enabled = True + self.ui.thresholdSelector.setVisible(True) + + # Update advanced options + self.ui.DICOMSelector.enabled = (self._parameterNode.GetParameter("UseAlt")=="True") + manual = (self._parameterNode.GetParameter("UseMan")=="True") + self.ui.voxelSizeLineEdit.enabled=manual + self.ui.scalingLineEdit.enabled=manual + self.ui.densitySlopeLineEdit.enabled=manual + self.ui.densityInterceptLineEdit.enabled=manual + self.ui.rescaleSlopeLineEdit.enabled=manual + self.ui.rescaleInterceptLineEdit.enabled=manual + self.ui.voxelSizeLabel.enabled=manual + self.ui.scalingLabel.enabled=manual + self.ui.densitySlopeLabel.enabled=manual + self.ui.densityInterceptLabel.enabled=manual + self.ui.rescaleSlopeLabel.enabled=manual + self.ui.rescaleInterceptLabel.enabled=manual + # Update Apply Button + if self._parameterNode.GetParameter("Analyzing")=="True": + self.ui.applyButton.toolTip = "Currently running analysis" + self.ui.applyButton.enabled = False + elif self._parameterNode.GetNodeReference("InputVolume") and self._parameterNode.GetParameter("SegmentID") and (self._parameterNode.GetParameter("UseDICOM")=="False" or self._parameterNode.GetNodeReferenceID("DICOMNode")): + self.ui.applyButton.toolTip = "Perform the selected analysis" + self.ui.applyButton.enabled = True + else: + self.ui.applyButton.toolTip = "Select input volume node, input segment, and output directory" + self.ui.applyButton.enabled = False + + # All the GUI updates are done + self._updatingGUIFromParameterNode = False + + def inputVolumeChanged(self, event): + """ + Called when the input volume is changed in the selector. + Passes the caller information to updateParameterNode + """ + self.updateParameterNodeFromGUI(event, "InputVolume") + + def segmentNodeChanged(self, event): + """ + Called when the segment node is changed in the selector. + Passes the caller information to updateParameterNode + """ + self.updateParameterNodeFromGUI(event, "SegmentNode") + + + def segmentChanged(self, event): + """ + Called when the segment is changed in the selector. + Passes the caller information to updateParameterNode + """ + self.updateParameterNodeFromGUI(event, "Segment") + + def DICOMSeriesChanged(self, event): + self.updateParameterNodeFromGUI(event, "DICOM") + + def updateParameterNodeFromGUI(self, event=None, caller=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). + """ + if self._parameterNode is None or self._updatingGUIFromParameterNode: + return + + wasModified = self._parameterNode.StartModify() # Modify all properties in a single batch + self._parameterNode.SetNodeReferenceID("InputVolume", self.ui.inputSelector.currentNodeID) + if caller == "InputVolume": + if event is None: + self._parameterNode.SetNodeReferenceID("InputVolume", None) + else: + self._parameterNode.SetNodeReferenceID("InputVolume", event.GetID()) + elif caller == 'SegmentNode': + if event is None: + self._parameterNode.SetNodeReferenceID("SegmentNode", None) + else: + self._parameterNode.SetNodeReferenceID("SegmentNode", event.GetID()) + elif caller == 'Segment': + self._parameterNode.SetParameter("SegmentID", str(event)) + elif caller == 'DICOM' and event is not None: + self._parameterNode.SetNodeReferenceID("DICOMNode", event.GetID()) + self._parameterNode.SetParameter("LowerThreshold", str(self.ui.thresholdSelector.lowerThreshold)) + self._parameterNode.SetParameter("UpperThreshold", str(self.ui.thresholdSelector.upperThreshold)) + self._parameterNode.SetParameter("Analysis", str(self.ui.analysisSelector.currentText)) + self._parameterNode.SetParameter("UseAlt", str(self.ui.AlternateDICOMCheckBox.checked)) + self._parameterNode.SetParameter("UseMan", str(self.ui.ManualDICOMCheckBox.checked)) + self.setNumParameter("0018,0050", str(self.ui.voxelSizeLineEdit.text)) + self.setNumParameter("0029,1000", str(self.ui.scalingLineEdit.text)) + self.setNumParameter("0029,1004", str(self.ui.densitySlopeLineEdit.text)) + self.setNumParameter("0029,1005", str(self.ui.densityInterceptLineEdit.text)) + self.setNumParameter("0028,1053", str(self.ui.rescaleSlopeLineEdit.text)) + self.setNumParameter("0028,1052", str(self.ui.rescaleInterceptLineEdit.text)) + self._parameterNode.SetParameter("OutputDirectory", str(self.ui.outputDirectorySelector.currentPath)) + + if self._parameterNode.GetParameter("Analysis") == 'Intervertebral Disc': + self._parameterNode.SetParameter("MultiSelect", 'True') + else: + self._parameterNode.SetParameter("MultiSelect", 'False') + + self._parameterNode.EndModify(wasModified) + self.updateGUIFromParameterNode() + + + + # Sets a parameter to a value if the value can be converted to a float, otherwises sets it to blank + def setNumParameter(self, parameter, value): + try: + float(value) + self._parameterNode.SetParameter(parameter, value) + except ValueError: + self._parameterNode.SetParameter(parameter, "") + + + def onApplyButton(self): + """ + Run processing when user clicks "Apply" button. + """ + with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): + # Compute output + self.logic.process(self._parameterNode.GetNodeReference("InputVolume"), self._parameterNode.GetNodeReference("SegmentNode"), self._parameterNode.GetParameter("SegmentID"), + self.ui.thresholdSelector.lowerThreshold, self.ui.thresholdSelector.upperThreshold, self.ui.analysisSelector.currentText, self.ui.outputDirectorySelector.currentPath, + self.ui.AlternateDICOMCheckBox.checked, self._parameterNode.GetNodeReference("DICOMNode"), self.ui.ManualDICOMCheckBox.checked, + {'0018,0050':self.ui.voxelSizeLineEdit.text, '0029,1000':self.ui.scalingLineEdit.text, '0029,1004':self.ui.densitySlopeLineEdit.text, '0029,1005':self.ui.densityInterceptLineEdit.text, '0028,1053':self.ui.rescaleSlopeLineEdit.text, '0028,1052':self.ui.rescaleInterceptLineEdit.text}, self) + self.updateGUIFromParameterNode() + + + + + # Updates + def analysisUpdate(self, cliNode, event): + if cliNode.GetStatus() & cliNode.Completed: + self.ui.AnalysisProgress.setValue(100) + self.ui.AnalysisProgress.hide() + if cliNode.GetStatus() & cliNode.ErrorsMask: + # error + errorText = cliNode.GetErrorText() + print("CLI execution failed: " + errorText) + else: + # success + print("CLI execution succeeded.") + startTime=float(self._parameterNode.GetParameter("startTime")) + stopTime = time.time() + logging.info(f'Processing completed in {stopTime-startTime:.2f} seconds') + # Clean up temp nodes + slicer.mrmlScene.RemoveNode(self._parameterNode.GetNodeReference("labelmapNode")) + slicer.mrmlScene.RemoveNode(self._parameterNode.GetNodeReference("labelmapNode2")) + slicer.mrmlScene.RemoveNode(cliNode) + self._parameterNode.SetParameter("Analyzing", "False") + self.updateGUIFromParameterNode() + else: + self.ui.AnalysisProgress.setValue(cliNode.GetProgress()) + + + + + +# +# MusculoskeletalAnalysisLogic +# + +class MusculoskeletalAnalysisLogic(ScriptedLoadableModuleLogic): + """This class should implement all the actual + computation done by your module. The interface + should be such that other python code can import + this class and make use of the functionality without + requiring an instance of the Widget. + Uses ScriptedLoadableModuleLogic base class, available at: + https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py + """ + + def __init__(self): + """ + Called when the logic class is instantiated. Can be used for initializing member variables. + """ + ScriptedLoadableModuleLogic.__init__(self) + + def setDefaultParameters(self, parameterNode): + """ + Initialize parameter node with default settings. + """ + if not parameterNode.GetParameter("Analysis"): + parameterNode.SetParameter("Analysis", "Cortical Bone") + + def process(self, inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, altDICOM=False, DICOMNode=None, manDICOM=False, DICOMOptions=None, source=None, wait=False): + """ + Run the processing algorithm. + Can be used without GUI widget. + :param InputVolume: volume to be thresholded + :param Analysis: analysis function to perform + :param OutputDirectory: directory to write output files to + """ + + # Check inputs + if not inputVolume: + raise ValueError("Input volume is invalid") + if not mask or not maskLabel: + raise ValueError("Segment is invalid") + if altDICOM and not DICOMNode: + raise ValueError("No DICOM source") + if manDICOM and not all(DICOMOptions): + raise ValueError("Not all DICOM options are selected") + + if not os.access(outputDirectory, os.W_OK): + if not os.access(outputDirectory, os.F_OK): + # If directory doesn't exist try to create it + try: + os.makedirs(outputDirectory) + if not os.access(outputDirectory, os.W_OK): + raise ValueError("Output Directory is invalid") + except: + raise ValueError("Output Directory is invalid") + else: + # If directory is not writable for other reason + raise ValueError("Output Directory is invalid") + + startTime = time.time() + logging.info('Processing started') + + # Get mask segments + if analysis == 'Intervertebral Disc': + maskLabel = maskLabel.strip('()') + labels = maskLabel.split(', ') + maskID = mask.GetSegmentation().GetSegmentIdBySegmentName(labels[0].strip('\'')) + maskArray = vtk.vtkStringArray() + maskArray.InsertNextValue(maskID) + labelmap = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") + slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray, labelmap, inputVolume) + + maskID2 = mask.GetSegmentation().GetSegmentIdBySegmentName(labels[1].strip('\'')) + maskArray2 = vtk.vtkStringArray() + maskArray2.InsertNextValue(maskID2) + labelmap2 = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") + slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray2, labelmap2, inputVolume) + else: + maskID = mask.GetSegmentation().GetSegmentIdBySegmentName(maskLabel) + maskArray = vtk.vtkStringArray() + maskArray.InsertNextValue(maskID) + labelmap = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode") + slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray, labelmap, inputVolume) + + + # Get DICOM source + if altDICOM: + dSource = DICOMNode + elif manDICOM: + dSource = DICOMOptions + else: + dSource = inputVolume + if analysis != 'Intervertebral Disc': + # Get Density info + slope=float(self.getDICOMTag(dSource, '0029,1004'))/(float(self.getDICOMTag(dSource, '0029,1000'))*float(self.getDICOMTag(dSource, '0028,1053'))) + intercept=float(self.getDICOMTag(dSource, '0029,1005'))-(float(self.getDICOMTag(dSource, '0028,1052'))*slope) + + + voxelSize = self.getDICOMTag(dSource, '0018,0050') + + # Prepare parameters for the selected function + if analysis == "Cortical Bone": + parameters = {"image":inputVolume, "mask":labelmap, "lowerThreshold":lowerThreshold, "upperThreshold":upperThreshold, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory} + module=slicer.modules.corticalanalysis + requirements = [('scipy', 'scipy'), ('skimage', 'scikit-image'), ('nrrd', 'pynrrd')] + elif analysis == "Cancellous Bone": + parameters = {"image":inputVolume, "mask":labelmap, "lowerThreshold":lowerThreshold, "upperThreshold":upperThreshold, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory} + module=slicer.modules.cancellousanalysis + requirements = [('scipy', 'scipy'), ('skimage', 'scikit-image'), ('nrrd', 'pynrrd'), ('trimesh', 'trimesh')] + elif analysis == "Bone Density": + parameters = {"image":inputVolume, "mask":labelmap, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory} + module=slicer.modules.densityanalysis + requirements = [('nrrd', 'pynrrd')] + elif analysis == 'Intervertebral Disc': + parameters = {"image":inputVolume, "mask1":labelmap, "mask2":labelmap2, "voxelSize":voxelSize, "inputName":inputVolume.GetName(), "output":outputDirectory} + module = slicer.modules.intervertebralanalysis + requirements = [('scipy', 'scipy'), ('nrrd', 'pynrrd')] + + # Install required python modules + self.importRequest(requirements) + + node = slicer.cli.createNode(module, parameters=parameters) + # Set up source before running to avoid race conditions + if source: + source.ui.AnalysisProgress.setValue(0) + source.ui.AnalysisProgress.show() + source._parameterNode.SetParameter("Analyzing", "True") + source._parameterNode.SetNodeReferenceID("labelmapNode", labelmap.GetID()) + if 'labelmap2' in locals(): + source._parameterNode.SetNodeReferenceID("labelmapNode2", labelmap2.GetID()) + source._parameterNode.SetParameter("startTime", str(startTime)) + node.AddObserver('ModifiedEvent', source.analysisUpdate) + slicer.cli.run(module=module, node=node, wait_for_completion=wait) + + + # Used to get dicom metadata from the volume + # source: the volume node or DICOM dict + # tag: The DICOM tag number as a string ('####,####') + def getDICOMTag(self, source, tag): + if type(source) is dict: + data=source[tag] + else: + shNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) + volumeItemId = shNode.GetItemByDataNode(source) + seriesInstanceUID = shNode.GetItemUID(volumeItemId, 'DICOM') + + db = slicer.dicomDatabase + instanceList = db.instancesForSeries(seriesInstanceUID) + data = db.instanceValue(instanceList[0], tag) + return data + + # Check a list of modules to see if they are installed, request permission to install any missing. + # requested: A list of tuples in the format (moduleName, pipName) + def importRequest(self, requested): + missing = [] + for r in requested: + if not importlib.util.find_spec(r[0]): + missing.append(r) + if len(missing) > 0: + names, pips = zip(*missing) + if slicer.util.confirmOkCancelDisplay("The following python modules are required: " + ", ".join(names) + ". Do you want to install them?"): + for p in pips: + slicer.util.pip_install(p) + #pass + else: + raise ImportError("Required python modules do not have permission to install") + + + +# +# MusculoskeletalAnalysisTest +# + +class MusculoskeletalAnalysisTest(ScriptedLoadableModuleTest): + """ + This is the test case for your scripted module. + Uses ScriptedLoadableModuleTest base class, available at: + https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py + """ + + def setUp(self): + """ Do whatever is needed to reset the state - typically a scene clear will be enough. + """ + slicer.mrmlScene.Clear() + + def runTest(self): + """Run as few or as many tests as needed here. + """ + self.setUp() + self.test_MusculoskeletalAnalysis1() + + def test_MusculoskeletalAnalysis1(self): + """ Ideally you should have several levels of tests. At the lowest level + tests should exercise the functionality of the logic with different inputs + (both valid and invalid). At higher levels your tests should emulate the + way the user would interact with your code and confirm that it still works + the way you intended. + One of the most important features of the tests is that it should alert other + developers when their changes will have an impact on the behavior of your + module. For example, if a developer removes a feature that you depend on, + your test should break so they know that the feature is needed. + """ + + self.delayDisplay("Starting the test") + + + # Get/create input data + from datetime import date + import SampleData + # Make sure the scene is clear before starting + slicer.mrmlScene.Clear() + registerSampleData() + + + try: + # Set test parameters + inputVolume = SampleData.downloadSample('Cortical1') + mask = SampleData.downloadSample('CorticalMask1') + maskLabel = "Segment_1" + lowerThreshold = 4000 + upperThreshold = 10000 + analysis="Cortical Bone" + options = {"0018,0050":0.0073996, "0028,1052":-1000, "0028,1053":0.4943119, "0029,1000":4096,"0029,1004":365.712, "0029,1005":-199.725998, } + outputDirectory = os.path.expanduser("~\\Documents\\MusculoskeletalAnalysisTest") + + self.delayDisplay('Loaded test data set') + # Test the module logic + + logic = MusculoskeletalAnalysisLogic() + + + # Test cortical analysis + logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True) + self.testFile(os.path.join(outputDirectory, "cortical.txt"), [str(date.today()), "Cortical1", 0.21890675924688482, 0.06119903498019812, 1094.0291917644427, 0.08353005741605146, 1.6847633350543425, 0.9198784024224288, 0.7648849326319137, 0.47030180777724473, 0.0073996]) + finally: + # Clean up nodes + slicer.mrmlScene.Clear() + + try: + # Set test parameters + inputVolume = SampleData.downloadSample('Cancellous1') + mask = SampleData.downloadSample('CancellousMask1') + maskLabel = "Segment_1" + lowerThreshold = 1500 + upperThreshold = 10000 + analysis="Cancellous Bone" + self.delayDisplay('Loaded test data set') + # Test cancellous analysis + logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True) + self.testFile(os.path.join(outputDirectory, "cancellous.txt"), [str(date.today()), "Cancellous1", 1.333489381819056, 0.27215499848346064, 0.20409236263449296, 0.05182980579223419, 0.01560752819180971, 0.16437754493704732, 0.06443102087549339, 6.083556001417202, 1.6664854524662474, 365.2072574703726, 589.9997317002255, 0.0073996, 1500.0, 10000.0]) + + # Reuses cancellous parameters + analysis="Bone Density" + self.delayDisplay('Loaded test data set') + # Test density analysis + logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True) + self.testFile(os.path.join(outputDirectory, "density.txt"), ["", "", 2.32469398135312, 147.00866960658377, 309.78804391297064, -255.03709872604347, 1198.6849313585226, "", "", "", "", "", "", "", ""]) + finally: + # Clean up nodes + slicer.mrmlScene.Clear() + + try: + # Set test parameters + inputVolume = SampleData.downloadSample('Intervertebral1') + mask = SampleData.downloadSample('IntervertebralMask1') + maskLabel = "'Segment_1, Segment_2'" + lowerThreshold = 0 + upperThreshold = 0 + analysis="Intervertebral Disc" + self.delayDisplay('Loaded test data set') + # Test cancellous analysis + logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True) + self.testFile(os.path.join(outputDirectory, "intervertebral.txt"), [str(date.today()), "Intervertebral1", 0.28008754758301957, 0.0694562859207484, 0.24798062791478137, 1.4070918728304844, 1.0306450189585161, 0.1775904, 0.12621094857350137, 0.0073996]) + + finally: + # Clean up nodes + slicer.mrmlScene.Clear() + + + self.delayDisplay('Test passed') + + # testFile + # Tests that the newest line of an output file matches a specified input + # fileName: The path to the output file. Output file should be a tsv file + # data: A list of strings to compare the file to + # Throws an assertion error if the data does not match + def testFile(self, fileName, data): + # Confirm that file exist + assert os.path.exists(fileName), "Filename "+fileName+" does not exist." + # Get the header from the first line of the file and the most recent data as the last line + with open(fileName) as f: + lines = f.read().splitlines() + firstLine = lines[0] + lastLine = lines[-1] + header = firstLine.split("\t") + testData = lastLine.split("\t") + # Check that the number of rows is correct + assert len(data) == len(testData), "Expected "+ str(len(data)) + " lines, got " + str(len(testData)) + " instead." + for i in range(len(data)): + try: + # Checks that numeric data is within 5% + float(testData[i]) # If the data is not convertable to a float, throws a value error and compares data as a string + assert (float(data[i]) > float(testData[i])*.95 and float(data[i]) < float(testData[i])*1.05) or (float(data[i]) < float(testData[i])*.95 and float(data[i]) > float(testData[i])*1.05), "Value for " + header[i] + ", " + testData[i] + " outside of range of expected value " + str(data[i]) + "." + except ValueError: + # Checks that strings match + assert data[i] == testData[i], "Value for " + header[i] + ", " + testData[i] + ", doesn't match expected value " + str(data[i]) + "." +