From b3a8fc10bc439be11e03d3576d988d5f49e479d5 Mon Sep 17 00:00:00 2001 From: Anthony Lombardi Date: Wed, 8 May 2024 16:43:05 -0400 Subject: [PATCH] ENH: Hierarchical approach to 3d registration --- AutoscoperM/AutoscoperM.py | 66 ++- AutoscoperM/AutoscoperMLib/IO.py | 63 +-- CMakeLists.txt | 3 +- Tracking3D/CMakeLists.txt | 34 ++ Tracking3D/Resources/Icons/Tracking3D.png | Bin 0 -> 21024 bytes Tracking3D/Resources/ParameterFiles/rigid.txt | 58 +++ Tracking3D/Resources/UI/Tracking3D.ui | 229 ++++++++++ Tracking3D/Testing/CMakeLists.txt | 1 + Tracking3D/Testing/Python/CMakeLists.txt | 2 + Tracking3D/Tracking3D.py | 423 ++++++++++++++++++ Tracking3D/Tracking3DLib/TreeNode.py | 145 ++++++ Tracking3D/Tracking3DLib/__init__.py | 0 12 files changed, 962 insertions(+), 62 deletions(-) create mode 100644 Tracking3D/CMakeLists.txt create mode 100644 Tracking3D/Resources/Icons/Tracking3D.png create mode 100644 Tracking3D/Resources/ParameterFiles/rigid.txt create mode 100644 Tracking3D/Resources/UI/Tracking3D.ui create mode 100644 Tracking3D/Testing/CMakeLists.txt create mode 100644 Tracking3D/Testing/Python/CMakeLists.txt create mode 100644 Tracking3D/Tracking3D.py create mode 100644 Tracking3D/Tracking3DLib/TreeNode.py create mode 100644 Tracking3D/Tracking3DLib/__init__.py diff --git a/AutoscoperM/AutoscoperM.py b/AutoscoperM/AutoscoperM.py index 3848ad9..680f39f 100644 --- a/AutoscoperM/AutoscoperM.py +++ b/AutoscoperM/AutoscoperM.py @@ -5,11 +5,13 @@ import shutil import time import zipfile +from itertools import product from typing import Optional, Union import qt import slicer import vtk +from numpy.typing import NDArray from slicer.ScriptedLoadableModule import ( ScriptedLoadableModule, ScriptedLoadableModuleLogic, @@ -1071,15 +1073,12 @@ def progressCallback(x): tfm.SetElement(1, 3, origin[1]) tfm.SetElement(2, 3, origin[2]) - IO.createTRAFile( - volName=segmentName, - trialName=None, - outputDir=outputDir, - trackingsubDir=trackingSubDir, - volSize=segmentVolumeSize, - Origin2DicomTransformFile=origin2DicomTransformFile, - transform=tfm, - ) + if origin2DicomTransformFile is not None: + tfm = self.applyOrigin2DicomTransform(tfm, origin2DicomTransformFile) + tfm = self.applySlicer2AutoscoperTransform(tfm, segmentVolumeSize, segmentVolume.GetOrigin()) + filename = f"{segmentName}.tra" + filename = os.path.join(outputDir, trackingSubDir, filename) + IO.writeTRA(filename, [tfm]) # update progress bar progressCallback((idx + 1) / numSegments * 100) @@ -1427,3 +1426,52 @@ def IsVolumeCentered(node: Union[slicer.vtkMRMLVolumeNode, slicer.vtkMRMLSequenc if AutoscoperMLogic.IsSequenceVolume(node): return AutoscoperMLogic.getItemInSequence(node, 0)[0].IsCentered() return node.IsCentered() + + @staticmethod + def applyOrigin2DicomTransform(transform: vtk.vtkMatrix4x4, Origin2DicomTransformFile: str) -> vtk.vtkMatrix4x4: + transformNode = slicer.vtkMRMLLinearTransformNode() + transformNode.SetMatrixTransformToParent(transform) + slicer.mrmlScene.AddNode(transformNode) + origin2DicomTransformNode = slicer.util.loadNodeFromFile(Origin2DicomTransformFile) + origin2DicomTransformNode.Inverse() + transformNode.SetAndObserveTransformNodeID(origin2DicomTransformNode.GetID()) + transformNode.HardenTransform() + slicer.mrmlScene.RemoveNode(origin2DicomTransformNode) + + transformMatrix = vtk.vtkMatrix4x4() + transformNode.GetMatrixTransformToParent(transformMatrix) + return transformMatrix + + @staticmethod + def applySlicer2AutoscoperTransform( + transform: vtk.vtkMatrix4x4, volSize: list[float], origin: list[float] + ) -> vtk.vtkMatrix4x4: + """Utility function for converting a transform between the Slicer and Autoscoper coordinate systems.""" + # https://github.com/BrownBiomechanics/Autoscoper/issues/280 + transform.SetElement(1, 1, -transform.GetElement(1, 1)) # Flip Y + transform.SetElement(2, 2, -transform.GetElement(2, 2)) # Flip Z + + # Add the volume origin + for i in range(3): + transform.SetElement(i, 3, transform.GetElement(i, 3) + origin[i]) + + transform.SetElement(0, 3, transform.GetElement(0, 3) - volSize[0]) # Offset X + return transform + + @staticmethod + def vtkToNumpy(matrix: vtk.vtkMatrix4x4) -> NDArray: + """Utility function for converting a 4x4 vtk matrix to a 4x4 numpy array.""" + import numpy as np + + array = np.empty((4, 4)) + for i, j in product(range(4), range(4)): + array[i, j] = matrix.GetElement(i, j) + return array + + @staticmethod + def numpyToVtk(array: NDArray) -> vtk.vtkMatrix4x4: + """Utility function for converting a 4x4 numpy array to a 4x4 vtk matrix.""" + matrix = vtk.vtkMatrix4x4() + for i, j in product(range(4), range(4)): + matrix.SetElement(i, j, array[i, j]) + return matrix diff --git a/AutoscoperM/AutoscoperMLib/IO.py b/AutoscoperM/AutoscoperMLib/IO.py index 0d94c8c..831b614 100644 --- a/AutoscoperM/AutoscoperMLib/IO.py +++ b/AutoscoperM/AutoscoperMLib/IO.py @@ -255,58 +255,6 @@ def writeTFMFile(filename: str, spacing: list[float], origin: list[float]): slicer.mrmlScene.RemoveNode(transformNode) -def createTRAFile( - volName: str, - trialName: str, - outputDir: str, - trackingsubDir: str, - volSize: list[float], - Origin2DicomTransformFile: str, - transform: vtk.vtkMatrix4x4, -): - transformNode = slicer.vtkMRMLLinearTransformNode() - transformNode.SetMatrixTransformToParent(transform) - slicer.mrmlScene.AddNode(transformNode) - - if Origin2DicomTransformFile is not None: - origin2DicomTransformNode = slicer.util.loadNodeFromFile(Origin2DicomTransformFile) - origin2DicomTransformNode.Inverse() - transformNode.SetAndObserveTransformNodeID(origin2DicomTransformNode.GetID()) - transformNode.HardenTransform() - slicer.mrmlScene.RemoveNode(origin2DicomTransformNode) - - filename = f"{trialName}_{volName}.tra" if trialName is not None else f"{volName}.tra" - filename = os.path.join(outputDir, trackingsubDir, filename) - - if not os.path.exists(os.path.join(outputDir, trackingsubDir)): - os.mkdir(os.path.join(outputDir, trackingsubDir)) - - tfmMat = vtk.vtkMatrix4x4() - transformNode.GetMatrixTransformToParent(tfmMat) - - writeTRA(filename, volSize, tfmMat) - - slicer.mrmlScene.RemoveNode(transformNode) - - -def writeTRA(filename: str, volSize: list[float], transform: vtk.vtkMatrix4x4): - # Slicer 2 Autoscoper Transform - # https://github.com/BrownBiomechanics/Autoscoper/issues/280 - transform.SetElement(1, 1, -transform.GetElement(1, 1)) # Flip Y - transform.SetElement(2, 2, -transform.GetElement(2, 2)) # Flip Z - - transform.SetElement(0, 3, transform.GetElement(0, 3) - volSize[0]) # Offset X - - # Write TRA - rowwise = [] - for i in range(4): # Row - for j in range(4): # Col - rowwise.append(str(transform.GetElement(i, j))) - - with open(filename, "w+") as f: - f.write(",".join(rowwise)) - - def writeTemporyFile(filename: str, data: vtk.vtkImageData) -> str: """ Writes a temporary file to the slicer temp directory @@ -336,3 +284,14 @@ def removeTemporyFile(filename: str): slicerTempDirectory = slicer.app.temporaryPath os.remove(os.path.join(slicerTempDirectory, filename)) + + +def writeTRA(fileName: str, transforms: list[vtk.vtkMatrix4x4]) -> None: + from itertools import product + + rowWiseStrings = [] + for transform in transforms: + rowWiseStrings.append([str(transform.GetElement(i, j)) for i, j in product(range(4), range(4))]) + with open(fileName, "w") as traFile: + for row in rowWiseStrings: + traFile.write(",".join(row) + "\n") diff --git a/CMakeLists.txt b/CMakeLists.txt index 0db71e8..6f7ea40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ set(EXTENSION_CONTRIBUTORS "Anthony Lombardi (Kitware), Amy Morton (Brown Univer set(EXTENSION_DESCRIPTION "SlicerAutoscoperM is an extension for 3D Slicer for 2D-3D image registration integrating with Autoscoper for image-based 3D motion tracking of skeletal structures.") set(EXTENSION_ICONURL "https://raw.githubusercontent.com/BrownBiomechanics/SlicerAutoscoperM/main/SlicerAutoscoperM.png") set(EXTENSION_SCREENSHOTURLS "https://github.com/BrownBiomechanics/SlicerAutoscoperM/releases/download/docs-resources/AutoscoperMainWindow.png") -set(EXTENSION_DEPENDS Sandbox SegmentEditorExtraEffects) +set(EXTENSION_DEPENDS Sandbox SegmentEditorExtraEffects SlicerElastix) set(EXTENSION_BUILD_SUBDIRECTORY inner-build) set(SUPERBUILD_TOPLEVEL_PROJECT inner) @@ -44,6 +44,7 @@ add_subdirectory(AutoscoperM) add_subdirectory(TrackingEvaluation) add_subdirectory(CalculateDataIntensityDensity) add_subdirectory(VirtualRadiographGeneration) +add_subdirectory(Tracking3D) ## NEXT_MODULE #----------------------------------------------------------------------------- diff --git a/Tracking3D/CMakeLists.txt b/Tracking3D/CMakeLists.txt new file mode 100644 index 0000000..b412e28 --- /dev/null +++ b/Tracking3D/CMakeLists.txt @@ -0,0 +1,34 @@ +#----------------------------------------------------------------------------- +set(MODULE_NAME Tracking3D) + +#----------------------------------------------------------------------------- +set(MODULE_PYTHON_SCRIPTS + ${MODULE_NAME}.py + ${MODULE_NAME}Lib/__init__.py + ${MODULE_NAME}Lib/TreeNode.py + ) + +set(MODULE_PYTHON_RESOURCES + Resources/Icons/${MODULE_NAME}.png + Resources/UI/${MODULE_NAME}.ui + Resources/ParameterFiles/rigid.txt + ) + +#----------------------------------------------------------------------------- +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() diff --git a/Tracking3D/Resources/Icons/Tracking3D.png b/Tracking3D/Resources/Icons/Tracking3D.png new file mode 100644 index 0000000000000000000000000000000000000000..5d83ab4f05067d6d5e30808fe07df6b4ac035349 GIT binary patch literal 21024 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

gM5Oyb7$7#KJA2&j@3Tx-)UlJ%O+Q7&{q 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 f>+Cwaj(z + + Tracking3D + + + + 0 + 0 + 785 + 561 + + + + + + + Registration + + + + + + Hierarchy: + + + + + + + Input CT Sequence: + + + + + + + Initial Guess (Transform): + + + + + + + + + + + + + + + + + + + + + + + false + + + Pick the input to the algorithm. + + + + vtkMRMLTransformNode + + + + true + + + false + + + false + + + initialGuessTFM + + + + + + + false + + + Pick the input to the algorithm. + + + + vtkMRMLScalarVolumeNode + + + + false + + + false + + + false + + + inputVolumeSequence + + + + + + + + + + false + + + Run the algorithm. + + + Apply + + + + + + + Export Transforms + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + qMRMLNodeComboBox + QWidget +

qMRMLNodeComboBox.h
+ + + qMRMLWidget + QWidget +
qMRMLWidget.h
+ 1 +
+ + qMRMLSubjectHierarchyComboBox + ctkComboBox +
qMRMLSubjectHierarchyComboBox.h
+
+ + ctkCollapsibleButton + QWidget +
ctkCollapsibleButton.h
+ 1 +
+ + ctkComboBox + QComboBox +
ctkComboBox.h
+
+ + + + + Tracking3D + mrmlSceneChanged(vtkMRMLScene*) + inputSelectorCT + setMRMLScene(vtkMRMLScene*) + + + 392 + 280 + + + 468 + 89 + + + + + Tracking3D + mrmlSceneChanged(vtkMRMLScene*) + inputSelectorInitGuessTFM + setMRMLScene(vtkMRMLScene*) + + + 392 + 280 + + + 468 + 118 + + + + + Tracking3D + mrmlSceneChanged(vtkMRMLScene*) + SubjectHierarchyComboBox + setMRMLScene(vtkMRMLScene*) + + + 392 + 280 + + + 392 + 412 + + + + + diff --git a/Tracking3D/Testing/CMakeLists.txt b/Tracking3D/Testing/CMakeLists.txt new file mode 100644 index 0000000..655007a --- /dev/null +++ b/Tracking3D/Testing/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(Python) diff --git a/Tracking3D/Testing/Python/CMakeLists.txt b/Tracking3D/Testing/Python/CMakeLists.txt new file mode 100644 index 0000000..5658d8b --- /dev/null +++ b/Tracking3D/Testing/Python/CMakeLists.txt @@ -0,0 +1,2 @@ + +#slicer_add_python_unittest(SCRIPT ${MODULE_NAME}ModuleTest.py) diff --git a/Tracking3D/Tracking3D.py b/Tracking3D/Tracking3D.py new file mode 100644 index 0000000..84fd421 --- /dev/null +++ b/Tracking3D/Tracking3D.py @@ -0,0 +1,423 @@ +from typing import Optional + +import Elastix +import slicer +import vtk +from slicer import vtkMRMLMarkupsROINode, vtkMRMLScalarVolumeNode, vtkMRMLSequenceNode, vtkMRMLTransformNode +from slicer.i18n import tr as _ +from slicer.parameterNodeWrapper import parameterNodeWrapper +from slicer.ScriptedLoadableModule import ( + ScriptedLoadableModule, + ScriptedLoadableModuleLogic, + ScriptedLoadableModuleWidget, +) +from slicer.util import VTKObservationMixin +from Tracking3DLib.TreeNode import TreeNode + +import AutoscoperM + + +# +# Tracking3D +# +class Tracking3D(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 = _("Tracking3D") + self.parent.categories = [ + "Tracking", + ] + self.parent.dependencies = [ + "CalculateDataIntensityDensity", + "VirtualRadiographGeneration", + ] + self.parent.contributors = [ + "Anthony Lombardi (Kitware)", + "Amy M Morton (Brown University)", + "Bardiya Akhbari (Brown University)", + "Beatriz Paniagua (Kitware)", + "Jean-Christophe Fillion-Robin (Kitware)", + ] + # TODO: update with short description of the module and a link to online module documentation + # _() function marks text as translatable to other languages + self.parent.helpText = _( + """ + This is an example of scripted loadable module bundled in an extension. + """ + ) + # TODO: replace with organization, grant and thanks + self.parent.acknowledgementText = _( + """ + This file was originally developed by Jean-Christophe Fillion-Robin, Kitware Inc., Andras Lasso, PerkLab, + and Steve Pieper, Isomics, Inc. and was partially funded by NIH grant 3P41RR013218-12S1. + """ + ) + + +# +# Tracking3DParameterNode +# +@parameterNodeWrapper +class Tracking3DParameterNode: + """ + The parameters needed by module. + + inputPartialVolume - The volume register and track throughout the sequence. + inputVolumeSequence - The volume sequence. + initialGuessTFM - Initial guess for the partial volume position. + outputTFM -The resulting transform + + """ + + # inputHierarchyRootID: str + inputVolumeSequence: vtkMRMLSequenceNode + initialGuessTFM: vtkMRMLTransformNode + + +# +# Tracking3DWidget +# +class Tracking3DWidget(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) -> None: + """ + Called when the user opens the module the first time and the widget is initialized. + """ + self.logic = None + self.inProgress = False + self._parameterNode = None + self._parameterNodeGuiTag = None + ScriptedLoadableModuleWidget.__init__(self, parent) + VTKObservationMixin.__init__(self) # needed for parameter node observation + + def setup(self) -> None: + """ + 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/Tracking3D.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 = Tracking3DLogic() + self.logic.parameterFile = self.resourcePath("ParameterFiles/rigid.txt") + + # 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) + + # Buttons + self.ui.applyButton.connect("clicked(bool)", self.onApplyButton) + self.ui.exportButton.connect("clicked(bool)", self.onExportButton) + + # Make sure parameter node is initialized (needed for module reload) + self.initializeParameterNode() + + def cleanup(self) -> None: + """ + Called when the application closes and the module widget is destroyed. + """ + self.removeObservers() + + def enter(self) -> None: + """ + Called each time the user opens this module. + """ + # Make sure parameter node exists and observed + self.initializeParameterNode() + + def exit(self) -> None: + """ + 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._parameterNode.disconnectGui(self._parameterNodeGuiTag) + self._parameterNodeGuiTag = None + self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateApplyButtonState) + + def onSceneStartClose(self, _caller, _event) -> None: + """ + 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) -> None: + """ + 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) -> None: + """ + Ensure parameter node exists and observed. + """ + self.setParameterNode(self.logic.getParameterNode()) + + if not self._parameterNode.inputVolumeSequence: + firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode") + if firstSequenceNode: + self._parameterNode.inputVolumeSequence = firstSequenceNode + + def setParameterNode(self, inputParameterNode: Optional[Tracking3DParameterNode]) -> None: + """ + Set and observe parameter node. + Observation is needed because when the parameter node is changed then the GUI must be updated immediately. + """ + + if self._parameterNode: + self._parameterNode.disconnectGui(self._parameterNodeGuiTag) + self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateApplyButtonState) + self._parameterNode = inputParameterNode + if self._parameterNode: + # Note: in the .ui file, a Qt dynamic property called "SlicerParameterName" is set on each + # ui element that needs connection. + self._parameterNodeGuiTag = self._parameterNode.connectGui(self.ui) + self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateApplyButtonState) + self.updateApplyButtonState() + + def updateApplyButtonState(self, _caller=None, _event=None): + """Sets the text and whether the button is enabled.""" + if self.inProgress or self.logic.isRunning: + if self.logic.cancelRequested: + self.ui.applyButton.text = "Cancelling..." + self.ui.applyButton.enabled = False + else: + self.ui.applyButton.text = "Cancel" + self.ui.applyButton.enabled = True + else: + currentCTStatus = self.ui.inputSelectorCT.currentNode() is not None + # currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0 + # Unsure of the type for the parameterNodeWrapper + if currentCTStatus: # and currentRootIDStatus: + self.ui.applyButton.text = "Apply" + self.ui.applyButton.enabled = True + elif not currentCTStatus: # or not currentRootIDStatus: + self.ui.applyButton.text = "Please select a Sequence and Hierarchy" + self.ui.applyButton.enabled = False + slicer.app.processEvents() + + def onApplyButton(self): + """UI button for running the hierarchical registration.""" + if self.inProgress: + self.logic.cancelRequested = True + self.inProgress = False + else: + with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): + currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0 + if not currentRootIDStatus: # TODO: Remove this once this is working with the parameterNodeWrapper + raise ValueError("Invalid hierarchy object selected!") + try: + self.inProgress = True + self.updateApplyButtonState() + + CT = self.ui.inputSelectorCT.currentNode() + initialGuess = self.ui.inputSelectorInitGuessTFM.currentNode() + rootID = self.ui.SubjectHierarchyComboBox.currentItem() + + self.logic.registerSequence(CT, rootID, initialGuess) + finally: + self.inProgress = False + self.updateApplyButtonState() + slicer.util.messageBox("Success!") + + def onExportButton(self): + """UI button for writing the sequences as TRA files.""" + with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): + currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0 + if not currentRootIDStatus: # TODO: Remove this once this is working with the parameterNodeWrapper + raise ValueError("Invalid hierarchy object selected!") + + rootID = self.ui.SubjectHierarchyComboBox.currentItem() + initialGuess = self.ui.inputSelectorInitGuessTFM.currentNode() + rootNode = TreeNode( + hierarchyID=rootID, ctSequence=None, isRoot=True, initialGuess=initialGuess, initializeTransforms=False + ) + + node_list = rootNode.childNodes.copy() + for child in node_list: + child.exportTransformsAsTRAFile() + node_list.extend(child.childNodes) + slicer.util.messageBox("Success!") + + +# +# Tracking3DLogic +# + + +class Tracking3DLogic(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) -> None: + """ + Called when the logic class is instantiated. Can be used for initializing member variables. + """ + ScriptedLoadableModuleLogic.__init__(self) + self.elastixLogic = Elastix.ElastixLogic() + self.autoscoperLogic = AutoscoperM.AutoscoperMLogic() + self.cancelRequested = False + self.isRunning = False + + def getParameterNode(self): + return Tracking3DParameterNode(super().getParameterNode()) + + def createROIFromPV(self, body: vtkMRMLScalarVolumeNode, padding: int = 0) -> vtkMRMLMarkupsROINode: + """Creates a ROI from a volume.""" + # TODO: Expose padding to GUI -> Maybe independent X,Y,Z values? + roiNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLMarkupsROINode") + cropVolumeParameters = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLCropVolumeParametersNode") + cropVolumeParameters.SetInputVolumeNodeID(body.GetID()) + cropVolumeParameters.SetROINodeID(roiNode.GetID()) + slicer.modules.cropvolume.logic().SnapROIToVoxelGrid(cropVolumeParameters) + slicer.modules.cropvolume.logic().FitROIToInputVolume(cropVolumeParameters) + slicer.mrmlScene.RemoveNode(cropVolumeParameters) + + size = list(roiNode.GetSize()) + size = [s + padding for s in size] + roiNode.SetSize(size) + return roiNode + + def cropCT(self, CT: vtkMRMLScalarVolumeNode, roi: vtkMRMLMarkupsROINode) -> vtkMRMLScalarVolumeNode: + """Crops a volume with a ROI.""" + cropVolumeLogic = slicer.modules.cropvolume.logic() + cropVolumeParameterNode = slicer.vtkMRMLCropVolumeParametersNode() + cropVolumeParameterNode.SetROINodeID(roi.GetID()) + cropVolumeParameterNode.SetInputVolumeNodeID(CT.GetID()) + cropVolumeParameterNode.SetVoxelBased(True) + cropVolumeLogic.Apply(cropVolumeParameterNode) + return slicer.mrmlScene.GetNodeByID(cropVolumeParameterNode.GetOutputVolumeNodeID()) + + def registerRigidBody( + self, + CT: vtkMRMLScalarVolumeNode, + body: vtkMRMLScalarVolumeNode, + outputTransform: vtkMRMLTransformNode, + ): + """Registers a partial volume to a CT scan, uses SlicerElastix.""" + roi = self.createROIFromPV(body) + body.SetAndObserveTransformNodeID(outputTransform.GetID()) + roi.SetAndObserveTransformNodeID(outputTransform.GetID()) + + croppedCT = self.cropCT(CT, roi) + + self.elastixLogic.registerVolumes( + fixedVolumeNode=croppedCT, + movingVolumeNode=body, + parameterFilenames=[self.parameterFile], + outputVolumeNode=None, + outputTransformNode=outputTransform, + fixedVolumeMaskNode=None, + movingVolumeMaskNode=None, + forceDisplacementFieldOutputTransform=False, + initialTransformNode=None, + ) + + # Clean up + slicer.mrmlScene.RemoveNode(croppedCT) + slicer.mrmlScene.RemoveNode(roi) + + def registerSequence( + self, ctSequence: vtkMRMLSequenceNode, rootID: int, initialGuess: Optional[vtkMRMLTransformNode] = None + ) -> None: + """Performs hierarchical registration on a ct sequence.""" + import logging + import time + + rootNode = TreeNode(hierarchyID=rootID, ctSequence=ctSequence, isRoot=True, initialGuess=initialGuess) + rootNode.applyTransformToChildren(1) # TODO: Make sure this is inline with the slider + + try: + self.isRunning = True + for idx in range(1, ctSequence.GetNumberOfDataNodes()): # TODO: add UI slider to set range + nodeList = rootNode.childNodes.copy() + for node in nodeList: + # Defiantly some opportunities to parallelize this (run each tier in sync) + node.dataNode.SetAndObserveTransformNodeID(None) + slicer.app.processEvents() + if self.cancelRequested: + logging.info("User canceled") + self.cancelRequested = False + self.isRunning = False + return + # register + logging.info(f"Registering: {node.name} for frame {idx}") + start = time.time() + self.registerRigidBody( + self.autoscoperLogic.getItemInSequence(ctSequence, idx)[0], + node.dataNode, + node.getTransform(idx), + ) + end = time.time() + logging.info(f"{node.name} took {end-start} for frame {idx}.") + + # Add children to node_list + node.applyTransformToChildren(idx) + nodeList.extend(node.childNodes) + + node.dataNode.SetAndObserveTransformNodeID(node.getTransform(idx).GetID()) + + # Use the output of the roots children as the initial guess for next frame + [node.copyTransformToNextFrame(idx) for node in rootNode.childNodes] + finally: + self.isRunning = False + self.cancelRequested = False + + def evalulateMetric( + self, fixedImage: vtkMRMLScalarVolumeNode, movingImage: vtkMRMLScalarVolumeNode + ) -> dict[str, float]: + """Computes several metrics for the similarity of the fixed and moving images.""" + import SimpleITK as sitk + import sitkUtils + + fixedSITKImg = sitkUtils.PullVolumeFromSlicer(fixedImage) + movingSITKImg = sitkUtils.PullVolumeFromSlicer(movingImage) + castFilter = sitk.CastImageFilter() + castFilter.SetOutputPixelType(sitk.sitkFloat64) + fixedSITKImg = castFilter.Execute(fixedSITKImg) + movingSITKImg = castFilter.Execute(movingSITKImg) + R = sitk.ImageRegistrationMethod() + results = {} + + R.SetMetricAsMattesMutualInformation(64) # 64 bins + results["mutualInformation"] = R.MetricEvaluate(fixedSITKImg, movingSITKImg) + + R.SetMetricAsMeanSquares() + results["meanSquares"] = R.MetricEvaluate(fixedSITKImg, movingSITKImg) + + R.SetMetricAsCorrelation() + results["correlation"] = R.MetricEvaluate(fixedSITKImg, movingSITKImg) + + R.SetMetricAsJointHistogramMutualInformation(64) + results["histogramMutualInformation"] = R.MetricEvaluate(fixedSITKImg, movingSITKImg) + + return results diff --git a/Tracking3D/Tracking3DLib/TreeNode.py b/Tracking3D/Tracking3DLib/TreeNode.py new file mode 100644 index 0000000..e8551e7 --- /dev/null +++ b/Tracking3D/Tracking3DLib/TreeNode.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import os + +import slicer +import vtk + +from AutoscoperM import IO, AutoscoperMLogic + + +class TreeNode: + """ + Data structure to store a basic tree hierarchy. + """ + + def __init__( + self, + hierarchyID: int, + ctSequence: slicer.vtkMRMLSequenceNode, + parent: TreeNode | None = None, + isRoot: bool = False, + initialGuess: slicer.vtkMRMLTransformNode | None = None, + initializeTransforms: bool = True, + ): + self.hierarchyID = hierarchyID + self.isRoot = isRoot + self.parent = parent + self.ctSequence = ctSequence + + if self.parent is not None and self.isRoot: + raise ValueError("Node cannot be root and have a parent") + + self.shNode = slicer.mrmlScene.GetSubjectHierarchyNode() + self.autoscoperLogic = AutoscoperMLogic() + + if self.isRoot: + if initialGuess is None: + raise ValueError("Root node must have initial guess.") + self.initial_guess = initialGuess + + if not self.isRoot: + self.name = self.shNode.GetItemName(self.hierarchyID) + self.dataNode = self.shNode.GetItemDataNode(self.hierarchyID) + if initializeTransforms: + self.transformSquence = self._initializeTransforms() + else: + self.transformSquence = slicer.util.getNode( + f"{self.name}_transform_sequence-{self.name}_transform_sequence-Seq" + ) + if not isinstance(self.transformSquence, slicer.vtkMRMLSequenceNode): + raise ValueError( + f"""Found transformNode {self.transformSquence.GetName()}, but it is not a sequence. + type(transformNode) == {type(self.transformSquence)}""" + ) + + children_ids = [] + self.shNode.GetItemChildren(self.hierarchyID, children_ids) + self.childNodes = [ + TreeNode( + hierarchyID=child_id, ctSequence=self.ctSequence, parent=self, initializeTransforms=initializeTransforms + ) + for child_id in children_ids + ] + + def _initializeTransforms(self) -> slicer.vtkMRMLSequenceNode: + """Creates a new transform sequence in the same browser as the CT sequence.""" + import logging + + logging.info(f"Initializing {self.name}") + newSequenceNode = self.autoscoperLogic.createSequenceNodeInBrowser( + f"{self.name}_transform_sequence", self.ctSequence + ) + [ # Populate the sequence node with blank transforms + newSequenceNode.SetDataNodeAtValue(slicer.vtkMRMLLinearTransformNode(), f"{i}") + for i in range(self.ctSequence.GetNumberOfDataNodes()) + ] + slicer.app.processEvents() + return newSequenceNode + + def _applyTransform(self, transform: slicer.vtkMRMLTransformNode, idx: int) -> None: + """Applies and hardends a transform node to the transform in the sequence at the provided index.""" + if idx < self.transformSquence.GetNumberOfDataNodes(): + current_transform = self.autoscoperLogic.getItemInSequence(self.transformSquence, idx)[0] + current_transform.SetAndObserveTransformNodeID(transform.GetID()) + current_transform.HardenTransform() + + def getTransform(self, idx: int) -> slicer.vtkMRMLTransformNode: + """Returns the transform at the provided index.""" + if idx < self.transformSquence.GetNumberOfDataNodes(): + return self.autoscoperLogic.getItemInSequence(self.transformSquence, idx)[0] + return None + + def applyTransformToChildren(self, idx: int) -> None: + """Applies the transform at the provided index to all children of this node.""" + applyTransform = None + if self.isRoot: + applyTransform = self.initial_guess + elif idx < self.transformSquence.GetNumberOfDataNodes(): + applyTransform = self.autoscoperLogic.getItemInSequence(self.transformSquence, idx)[0] + [childNode._applyTransform(applyTransform, idx) for childNode in self.childNodes] + + def copyTransformToNextFrame(self, currentIdx: int) -> None: + """Copies the transform at the provided index to the next frame.""" + import vtk + + currentTransform = self.getTransform(currentIdx) + transformMatrix = vtk.vtkMatrix4x4() + currentTransform.GetMatrixTransformToParent(transformMatrix) + nextTransform = self.getTransform(currentIdx + 1) + if nextTransform is not None: + nextTransform.SetMatrixTransformToParent(transformMatrix) + + def exportTransformsAsTRAFile(self): + """Exports the sequence as a TRA file for reading into Autoscoper.""" + # Convert the sequence to a list of vtkMatrices + transforms = [] + for idx in range(self.transformSquence.GetNumberOfDataNodes()): + mat = vtk.vtkMatrix4x4() + node = self.getTransform(idx) + node.GetMatrixTransformToParent(mat) + transforms.append(mat) + + # Apply the Slicer2Autoscoper Transform to the neutral frame + bounds = [0] * 6 + self.dataNode.GetRASBounds(bounds) + volSize = [abs(bounds[i + 1] - bounds[i]) for i in range(0, len(bounds), 2)] + origin = self.dataNode.GetOrigin() + transforms[0] = self.autoscoperLogic.applySlicer2AutoscoperTransform(transforms[0], volSize, origin) + + # Since each additional transform is the change from the neutral position + # we need to apply each additional transform to the neutral to get the final transform + neutralArray = self.autoscoperLogic.vtkToNumpy(transforms[0]) + for idx in range(1, self.transformSquence.GetNumberOfDataNodes()): + currentArray = self.autoscoperLogic.vtkToNumpy(transforms[idx]) + currentArray = currentArray @ neutralArray + transforms[idx] = self.autoscoperLogic.numpyToVtk(currentArray) + + # Write out tra + # TODO: Make the directory user defined + exportDir = r"AutoscoperM-Pre-Processing\Tracking" + exportDir = os.path.join(slicer.mrmlScene.GetCacheManager().GetRemoteCacheDirectory(), exportDir) + if not os.path.exists(exportDir): + os.mkdir(exportDir) + filename = os.path.join(exportDir, f"{self.name}.tra") + IO.writeTRA(filename, transforms) diff --git a/Tracking3D/Tracking3DLib/__init__.py b/Tracking3D/Tracking3DLib/__init__.py new file mode 100644 index 0000000..e69de29