Repository: BrownBiomechanics/SlicerAutoscoperM
Pull request #167: ENH: Reimplement Hierarchical 3D Registration with ROI cropped volumes
Author: login: sbelsk
Created: 2025-03-03T14:57:46Z | Merged: 2025-03-25T21:27:41Z

Reimplements https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/99 following the approach discussed in:
- https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/149
- https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/150
- https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/151
and using some of the logic proposed in https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/152.

Tested using https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/110.

TODO:
- [x] https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/165
- [x] https://github.com/BrownBiomechanics/SlicerAutoscoperM/issues/166
  - [x] Fix ROI cropping when transform is also applied
- [x] fix improper transform propagation  
- [x] improve status message visiblity
- [x] enable volume render visibility of current frame being registered
- [x] clean up code and add comments to better document it
- [x] hide intermediate result data nodes from the scene, store cropped volumes in sequence
- [x] https://github.com/BrownBiomechanics/Autoscoper/pull/311
- [x] revisit export of registration results (and import ) 

**Edit: This will be outsourced to another team member**
- [ ] test on a bigger variety of datasets

**Edit: Not to be tackled immediately**
- [x] quantify error in resulting transforms with respect to ground truth data




Diff:
diff --git a/AutoscoperM/AutoscoperM.py b/AutoscoperM/AutoscoperM.py
index c835ee2..48a9369 100644
--- a/AutoscoperM/AutoscoperM.py
+++ b/AutoscoperM/AutoscoperM.py
@@ -1290,3 +1290,94 @@ def createAndAddDicom2AutTransformNode(
         dicom2autNode.SetMatrixTransformToParent(dicom2aut)
         slicer.mrmlScene.AddNode(dicom2autNode)
         return dicom2autNode
+
+    @staticmethod
+    def getModelBoundingBox(model: slicer.vtkMRMLModelNode) -> list[float]:
+        """Utility function to return the center and size of model node"""
+        model_bounds = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
+        model.GetBounds(model_bounds)
+
+        import numpy as np
+
+        # construct min and max coordinates of bounding box
+        bb_min = np.array([model_bounds[0], model_bounds[2], model_bounds[4]])
+        bb_max = np.array([model_bounds[1], model_bounds[3], model_bounds[5]])
+
+        bb_center = (bb_min + bb_max) / 2
+        bb_size = bb_max - bb_min
+
+        return bb_center.tolist(), bb_size.tolist()
+
+    @staticmethod
+    def checkROIAndVolumeOverlap(
+        roiNode: slicer.vtkMRMLMarkupsROINode,
+        volumeNode: slicer.vtkMRMLScalarVolumeNode,
+    ) -> Optional[list[float]]:
+        """Utility function to check if the bounds of the ROI exceed those of
+        the target volume, and if so compute the bounds of their intersection"""
+        roi_transformed_bounds = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
+        roiNode.GetRASBounds(roi_transformed_bounds)
+
+        volume_bounds = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
+        volumeNode.GetBounds(volume_bounds)
+
+        # to find intersection of bbs, take the max of the mins and the min of the maxs
+        import numpy as np
+
+        bb_min = np.array(
+            [
+                max(roi_transformed_bounds[0], volume_bounds[0]),
+                max(roi_transformed_bounds[2], volume_bounds[2]),
+                max(roi_transformed_bounds[4], volume_bounds[4]),
+            ]
+        )
+        bb_max = np.array(
+            [
+                min(roi_transformed_bounds[1], volume_bounds[1]),
+                min(roi_transformed_bounds[3], volume_bounds[3]),
+                min(roi_transformed_bounds[5], volume_bounds[5]),
+            ]
+        )
+
+        # check if the bounds of the intersection are different than the original ROI
+        bb_bounds = np.ravel([bb_min, bb_max], "F")
+        if np.any(np.abs(bb_bounds - np.array(roi_transformed_bounds))):
+            # if there is any difference between the bounds of the transformed ROI
+            # and the bounds of the intersection, it must mean that the transformed
+            # ROI exceeds the bounds of the target volume, so we need to trim it.
+
+            # sanity check that there is any overlap at all
+            bb_size = bb_max - bb_min
+            if np.any(bb_size <= 0):
+                raise ValueError("Invalid transformation chosen, ROI and target volume node do not overlap!")
+
+            bb_center = (bb_min + bb_max) / 2
+
+            return bb_center.tolist(), bb_size.tolist()
+
+        return None, None
+
+    @staticmethod
+    def cropVolumeFromROI(
+        inputVolumeNode: slicer.vtkMRMLScalarVolumeNode,
+        roiNode: slicer.vtkMRMLMarkupsROINode,
+        outputVolumeNode: slicer.vtkMRMLScalarVolumeNode = None,
+    ) -> slicer.vtkMRMLScalarVolumeNode:
+        """Utility function to return a cropped copy of a volume from given ROI bounds"""
+        # set up cropping parameters
+        cvpn = slicer.vtkMRMLCropVolumeParametersNode()
+        cvpn.SetROINodeID(roiNode.GetID())
+        cvpn.SetInputVolumeNodeID(inputVolumeNode.GetID())
+        cvpn.SetVoxelBased(False)
+        if outputVolumeNode is not None:
+            cvpn.SetOutputVolumeNodeID(outputVolumeNode.GetID())
+
+        # apply the cropping
+        cropLogic = slicer.modules.cropvolume.logic()
+        cropLogic.Apply(cvpn)
+
+        if outputVolumeNode is not None:
+            return outputVolumeNode
+
+        outputVolumeNodeID = cvpn.GetOutputVolumeNodeID()
+        return slicer.mrmlScene.GetNodeByID(outputVolumeNodeID)
diff --git a/AutoscoperM/AutoscoperMLib/IO.py b/AutoscoperM/AutoscoperMLib/IO.py
index d1b55f6..890e7ba 100644
--- a/AutoscoperM/AutoscoperMLib/IO.py
+++ b/AutoscoperM/AutoscoperMLib/IO.py
@@ -1,5 +1,6 @@
 import logging
 import os
+from itertools import product
 
 import numpy as np
 import slicer
@@ -201,3 +202,12 @@ def writeTFMFile(filename: str, spacing: list[float], origin: list[float]):
     slicer.util.exportNode(transformNode, filename)
 
     slicer.mrmlScene.RemoveNode(transformNode)
+
+
+def writeTRA(fileName: str, transforms: list[vtk.vtkMatrix4x4]) -> None:
+    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/Hierarchical3DRegistration/Hierarchical3DRegistration.py b/Hierarchical3DRegistration/Hierarchical3DRegistration.py
index a26d288..2fa4cfb 100644
--- a/Hierarchical3DRegistration/Hierarchical3DRegistration.py
+++ b/Hierarchical3DRegistration/Hierarchical3DRegistration.py
@@ -1,8 +1,10 @@
+from contextlib import contextmanager
+from enum import Enum
 from typing import Optional
 
 import slicer
 import vtk
-from slicer import vtkMRMLScalarVolumeNode, vtkMRMLSequenceNode, vtkMRMLTransformNode
+from slicer import vtkMRMLLinearTransformNode, vtkMRMLScalarVolumeNode, vtkMRMLSequenceNode
 from slicer.i18n import tr as _
 from slicer.parameterNodeWrapper import parameterNodeWrapper
 from slicer.ScriptedLoadableModule import (
@@ -12,7 +14,8 @@
 )
 from slicer.util import VTKObservationMixin
 
-import AutoscoperM
+from AutoscoperM import AutoscoperMLogic
+from AutoscoperMLib import Validation
 from Hierarchical3DRegistrationLib.TreeNode import TreeNode
 
 
@@ -53,20 +56,52 @@ def __init__(self, parent):
         )
 
 
+class Hierarchical3DRegistrationRunStatus(Enum):
+    NOT_RUNNING = 0
+    INITIALIZING = 1
+    IN_PROGRESS = 2
+    CANCELING = 3
+
+
 #
 # Hierarchical3DRegistrationParameterNode
 #
 @parameterNodeWrapper
 class Hierarchical3DRegistrationParameterNode:
     """
-    The parameters needed by module.
-
-    inputVolumeSequence - The volume sequence.
-
+    The parameters needed by the module.
+
+    volumeSequence: The volume sequence (target) to be registered
+    sourceVolume: The scalar volume to register from (from which input hierarchy were generated)
+    hierarchyRootID: The ID associated with the root of the model hierarchy
+    startFrameIdx: The frame index in volumeSequence from which registration is to start
+    endFrameIdx: The frame index in volumeSequence up to which registration is to be performed
+    trackOnlyRoot: Whether to only register the root node in the hierarchy
+    skipManualTfmAdjustments: Whether to skip manual user intervention for the
+                              initial guess transform for each registration
+
+    totalBones: The total number of bones to track in each frame, saved for progress bar
+    currentFrameIdx: The current target frame being registered
+    currentBoneID: The current bone in the frame being registered
+    runSatus: The current state of the registration module
+    statusMsg: The message displayed to the user indicating the current workflow step
     """
 
-    inputHierarchyRootID: int
-    inputVolumeSequence: vtkMRMLSequenceNode
+    # UI fields
+    volumeSequence: vtkMRMLSequenceNode
+    sourceVolume: vtkMRMLScalarVolumeNode
+    hierarchyRootID: int
+    startFrameIdx: int
+    endFrameIdx: int
+    trackOnlyRoot: bool
+    skipManualTfmAdjustments: bool
+
+    # Registration parameters
+    totalBones: int
+    currentFrameIdx: int
+    currentBoneID: int  # TODO: use this to reconstruct TreeNode obj from scene?
+    runSatus: Hierarchical3DRegistrationRunStatus
+    statusMsg: str
 
 
 #
@@ -82,9 +117,12 @@ 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
+        self.rootBone = None
+        self.currentBone = None
+        self.bonesToTrack = None
+
         ScriptedLoadableModuleWidget.__init__(self, parent)
         VTKObservationMixin.__init__(self)  # needed for parameter node observation
 
@@ -117,13 +155,14 @@ def setup(self) -> None:
         self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose)
 
         # Sets the frame slider range to be the number of nodes within the sequence
-        self.ui.inputSelectorCT.connect("currentNodeChanged(vtkMRMLNode*)", self.updateFrameSlider)
+        self.ui.inputCTSequenceSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.updateFrameSlider)
 
         # Buttons
-        self.ui.applyButton.connect("clicked(bool)", self.onApplyButton)
+        self.ui.initializeButton.connect("clicked(bool)", self.onInitializeButton)
+        self.ui.registerButton.connect("clicked(bool)", self.onRegisterButton)
+        self.ui.abortButton.connect("clicked(bool)", self.onAbortButton)
         self.ui.importButton.connect("clicked(bool)", self.onImportButton)
         self.ui.exportButton.connect("clicked(bool)", self.onExportButton)
-        self.ui.initHierarchyButton.connect("clicked(bool)", self.onInitHierarchyButton)
 
         # Make sure parameter node is initialized (needed for module reload)
         self.initializeParameterNode()
@@ -132,24 +171,20 @@ def cleanup(self) -> None:
         """
         Called when the application closes and the module widget is destroyed.
         """
+        self.cleanupRegistrationProcess()
         self.removeObservers()
 
     def enter(self) -> None:
         """
         Called each time the user opens this module.
         """
-        # Make sure parameter node exists and observed
-        self.initializeParameterNode()
+        pass
 
     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)
+        pass
 
     def onSceneStartClose(self, _caller, _event) -> None:
         """
@@ -166,110 +201,307 @@ def onSceneEndClose(self, _caller, _event) -> None:
         if self.parent.isEntered:
             self.initializeParameterNode()
 
+    @contextmanager
+    def tryWithErrorDisplayAndCleanup(self, message=None, show=True, waitCursor=False):
+        """Wraps tryWithErrorDisplay to allow additional cleanup after showing the error display."""
+        try:
+            with slicer.util.tryWithErrorDisplay(message, show, waitCursor):
+                yield
+        except Exception:
+            # if an error occurs, the cleanup will be performed after the error display
+            self.onAbortButton()
+            raise
+
     def initializeParameterNode(self) -> None:
         """
         Ensure parameter node exists and observed.
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrameIdx = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.totalBones = 0
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
+            firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode")
+            if firstVolumeNode:
+                self._parameterNode.sourceVolume = firstVolumeNode
+            self.updateFrameSlider(self._parameterNode.volumeSequence)
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Set the button text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+            self.updateProgressBar(0)
         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()
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
 
-    def onApplyButton(self):
-        """UI button for running the hierarchical registration."""
-        if self.inProgress:
-            self.logic.cancelRequested = True
-            self.inProgress = False
+        # update the register button
+        if self._parameterNode.skipManualTfmAdjustments:
+            self.ui.registerButton.text = "Register"
         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()
+            self.ui.registerButton.text = "Set Initial Guess And Register"
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+        slicer.app.processEvents()
 
-                    startFrame = self.ui.startFrame.value
-                    endFrame = self.ui.endFrame.value
+    def updateProgressBar(self, value):
+        """
+        Update the progress bar to indicate the total bones in each frame registered so far
+        """
+        self.ui.progressBar.setValue(value)
+        slicer.app.processEvents()
 
-                    trackOnlyRoot = self.ui.onlyTrackRootNodeCheckBox.isChecked()
+    def onInitializeButton(self):
+        """Initializes a new registration process from the UI configuration parameters"""
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+        with self.tryWithErrorDisplayAndCleanup("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process..."
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError("The source volume must not be the same as the input sequence selected!")
+
+            # initialize representation of the bone hierarchy to be registered
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+
+            # calculate total number of bones and frames for the progress bar display
+            nodeCount = 1
+            if not self._parameterNode.trackOnlyRoot:
+                nodeCount += self.rootBone.shNode.GetNumberOfItemChildren(self._parameterNode.hierarchyRootID, True)
+            self._parameterNode.totalBones = nodeCount
+
+            # initialize the registration status variables
+            self._parameterNode.currentFrameIdx = self._parameterNode.startFrameIdx
+            self.bonesToTrack = [self.rootBone]
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            # prepare for the next step in the workflow
+            nextFrame = AutoscoperMLogic.getItemInSequence(
+                self._parameterNode.volumeSequence, self._parameterNode.currentFrameIdx
+            )[0]
+            volumeRenderingLogic = slicer.modules.volumerendering.logic()
+            nextFrameDisplayNode = volumeRenderingLogic.CreateDefaultVolumeRenderingNodes(nextFrame)
+            nextFrameDisplayNode.SetVisibility(1)
+            slicer.util.setSliceViewerLayers(background=nextFrame, fit=True)
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.enabled = False
+                slicer.app.processEvents()
+                self.doNextRegistrationStep()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrameIdx)
+                self._parameterNode.statusMsg = (
+                    "Adjust the initial guess transform for the bone "
+                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrameIdx}"
+                )
+                self.ui.registerButton.enabled = True
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with self.tryWithErrorDisplayAndCleanup("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+            self.ui.registerButton.enabled = False
+            slicer.app.processEvents()
+
+            self.doNextRegistrationStep()
+
+            self.ui.registerButton.enabled = True
+            return slicer.app.processEvents()
+
+    def doNextRegistrationStep(self):
+        """Sets up and performs the registration step for the next bone in frame"""
+        # get bone and target frame for the current step
+        targetFrameIdx = self._parameterNode.currentFrameIdx
+        self.currentBone = self.bonesToTrack.pop(0)
+
+        # update UI to prepare for automated registration
+        manual_tfm = self.currentBone.stopInteraction(targetFrameIdx)
+        slicer.util.forceRenderAllViews()
+        self._parameterNode.statusMsg = f"Registering bone '{self.currentBone.name}' in frame {targetFrameIdx}"
+
+        # crop the target frame based on the source ROI and initial guess transform
+        target_volume = AutoscoperMLogic.getItemInSequence(self._parameterNode.volumeSequence, targetFrameIdx)[0]
+        self.currentBone.setupFrame(targetFrameIdx, target_volume)
+
+        # perform the registration and update the hierarchy
+        self.logic.registerBoneInFrame(self.currentBone, targetFrameIdx, manual_tfm, self._parameterNode.trackOnlyRoot)
+
+        # If there is a next frame to register, copy the result transform as the next initial guess
+        if targetFrameIdx != self._parameterNode.endFrameIdx:
+            self.currentBone.copyTransformToNextFrame(targetFrameIdx)
+
+        # append all child bones to queue of bones to register next in this frame
+        if not self._parameterNode.trackOnlyRoot:
+            self.bonesToTrack.extend(self.currentBone.childNodes)
+
+        # report progress
+        totalFrames = self._parameterNode.endFrameIdx - self._parameterNode.startFrameIdx + 1
+        progress = self.ui.progressBar.value + 1 / (self._parameterNode.totalBones * totalFrames) * 100
+        self.updateProgressBar(progress)
+
+        # prepare for the next step in the workflow
+        if len(self.bonesToTrack) == 0:
+            if self._parameterNode.currentFrameIdx == self._parameterNode.endFrameIdx:
+                # we successfully finished the registration process
+                browserNode = slicer.modules.sequences.logic().GetFirstBrowserNodeForSequenceNode(
+                    self._parameterNode.volumeSequence
+                )
+                browserNode.SetSelectedItemNumber(self._parameterNode.endFrameIdx)
+                self.updateProgressBar(100)
+                return slicer.util.messageBox("Success! Registration Complete.")
+
+            # we just finished registering all the bones in the current
+            # frame, so now we can move on to the next frame
+            self._parameterNode.currentFrameIdx += 1
+            # set progress bar again to catch up on any roundoff
+            # error, since the progress bar saves value as int
+            total_frames_done = self._parameterNode.currentFrameIdx - self._parameterNode.startFrameIdx
+            self.updateProgressBar(total_frames_done / totalFrames * 100)
+
+            self.rootBone.setModelsVisibility(False)
+            nextFrame = AutoscoperMLogic.getItemInSequence(
+                self._parameterNode.volumeSequence, self._parameterNode.currentFrameIdx
+            )[0]
+            volumeRenderingLogic = slicer.modules.volumerendering.logic()
+            nextFrameDisplayNode = volumeRenderingLogic.CreateDefaultVolumeRenderingNodes(nextFrame)
+            nextFrameDisplayNode.SetVisibility(1)
+            slicer.util.setSliceViewerLayers(background=nextFrame, fit=True)
+            slicer.app.processEvents()
+            self.bonesToTrack = [self.rootBone]
+
+        if self._parameterNode.skipManualTfmAdjustments:
+            # if user doesn't need to adjust anything, continue to next step right away
+            return self.doNextRegistrationStep()
+
+        # prepare for user interaction of transform adjustments for the next bone
+        nextBone = self.bonesToTrack[0]
+        nextFrameIdx = self._parameterNode.currentFrameIdx
+        self._parameterNode.statusMsg = (
+            f"Adjust the initial guess transform for the bone '{nextBone.name}' in frame {nextFrameIdx}"
+        )
+        # advance the sequence browser to visualize the next frame
+        browserNode = slicer.modules.sequences.logic().GetFirstBrowserNodeForSequenceNode(
+            self._parameterNode.volumeSequence
+        )
+        browserNode.SetSelectedItemNumber(nextFrameIdx)
+        nextBone.startInteraction(nextFrameIdx)
+        return None
+
+    def onAbortButton(self):
+        """Stop ongoing registration process and restores all module parameters to default
+        Intermediate artifacts and partial output will not be deleted from the scene."""
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.CANCELING
+        self._parameterNode.statusMsg = "Initializing registration process..."
+        self.updateRegistrationButtonsState()
+        self.cleanupRegistrationProcess()
+
+    def cleanupRegistrationProcess(self):
+        """Reset all parameters and UI components at the end of a registration run"""
+        # remove all visual aids from the registration process
+        if self.rootBone and self._parameterNode:
+            self.rootBone.setModelsVisibility(True)
+            nodesList = [self.rootBone]
+            for node in nodesList:
+                node.stopInteraction(self._parameterNode.currentFrameIdx)
+                nodesList.extend(node.childNodes)
+
+        # reset current parameters, effectively wiping the current status saved
+        self.setParameterNode(None)
+        self.initializeParameterNode()
+        self.rootBone = None
+        self.currentBone = None
+        self.bonesToTrack = None
 
-                    self.logic.registerSequence(CT, rootID, startFrame, endFrame, trackOnlyRoot)
-                finally:
-                    self.inProgress = False
-            slicer.util.messageBox("Success!")
-        self.updateApplyButtonState()
+        # update UI to reflect process no longer in progress
+        self._parameterNode.statusMsg = ""
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+        self.updateRegistrationButtonsState()
 
     def onImportButton(self):
         """UI button for reading the TRA files into sequences."""
+        # TODO: this currently works as a mean to load previous results to the scene,
+        # but we should improve the workflow for importing and then registering with the same
+        # instance of TreeNode (like appropriately setting the run status and clickable buttons)
         import glob
         import logging
         import os
 
         with slicer.util.tryWithErrorDisplay("Failed to import transforms", 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!")
+            importDir = self.ui.ioDir.currentPath
+            Validation.validateInputs(importDir=importDir)
+            Validation.validatePaths(importDir=importDir)
+
+            if self._parameterNode is None or self.rootBone is None:
+                # TODO: Remove this once this is working with the parameterNodeWrapper
+                #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+                currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+                if currentRootIDStatus == 0:
+                    raise ValueError("Invalid hierarchy object selected!")
+                self._parameterNode.hierarchyRootID = currentRootIDStatus
 
-            CT = self.ui.inputSelectorCT.currentNode()
-            rootID = self.ui.SubjectHierarchyComboBox.currentItem()
-            rootNode = TreeNode(hierarchyID=rootID, ctSequence=CT, isRoot=True)
+                if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                    raise ValueError("The source volume must not be the same as the input sequence selected!")
 
-            importDir = self.ui.ioDir.currentPath
-            if importDir == "":
-                raise ValueError("Import directory not set!")
+                self.rootBone = TreeNode(
+                    hierarchyID=self._parameterNode.hierarchyRootID,
+                    ctSequence=self._parameterNode.volumeSequence,
+                    sourceVolume=self._parameterNode.sourceVolume,
+                    isRoot=True,
+                )
 
-            node_list = [rootNode]
+            node_list = [self.rootBone]
             for node in node_list:
-                node.dataNode.SetAndObserveTransformNodeID(node.getTransform(0).GetID())
-                node_list.extend(node.childNodes)
-
                 foundFiles = glob.glob(os.path.join(importDir, f"{node.name}*.tra"))
                 if len(foundFiles) == 0:
-                    logging.warning(f"No files found matching the '{node.name}*.tra' pattern")
+                    raise ValueError(f"No files found matching the '{node.name}*.tra' pattern")
                     return
 
                 if len(foundFiles) > 1:
@@ -278,52 +510,42 @@ def onImportButton(self):
                     )
 
                 node.importTransfromsFromTRAFile(foundFiles[0])
+                node_list.extend(node.childNodes)
+
+            self.rootBone.setModelsVisibility(True)
+
         slicer.util.messageBox("Success!")
 
     def onExportButton(self):
         """UI button for writing the sequences as TRA files."""
         with slicer.util.tryWithErrorDisplay("Failed to export transforms.", 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!")
-
-            CT = self.ui.inputSelectorCT.currentNode()
-            rootID = self.ui.SubjectHierarchyComboBox.currentItem()
-            rootNode = TreeNode(hierarchyID=rootID, ctSequence=CT, isRoot=True)
+            if self._parameterNode is None or self.rootBone is None:
+                raise ValueError("Cannot export as no session is ongoing!")
 
             exportDir = self.ui.ioDir.currentPath
-            if exportDir == "":
-                raise ValueError("Export directory not set!")
+            Validation.validateInputs(exportDir=exportDir)
+            Validation.validatePaths(exportDir=exportDir)
 
-            node_list = [rootNode]
+            node_list = [self.rootBone]
             for node in node_list:
                 node.exportTransformsAsTRAFile(exportDir)
                 node_list.extend(node.childNodes)
+
         slicer.util.messageBox("Success!")
 
     def updateFrameSlider(self, CTSelectorNode: slicer.vtkMRMLNode):
-        """Updates the slider and spin boxes when a new sequence is selected."""
-        if self.logic.autoscoperLogic.IsSequenceVolume(CTSelectorNode):
+        """Update the slider and spin boxes when a new sequence is selected."""
+        if AutoscoperMLogic.IsSequenceVolume(CTSelectorNode):
             numNodes = CTSelectorNode.GetNumberOfDataNodes()
             maxFrame = numNodes - 1
         elif CTSelectorNode is None:
             maxFrame = 0
         self.ui.frameSlider.maximum = maxFrame
+        self.ui.startFrame.minimum = 0
         self.ui.startFrame.maximum = maxFrame
         self.ui.endFrame.maximum = maxFrame
         self.ui.endFrame.value = maxFrame
 
-    def onInitHierarchyButton(self):
-        """UI Button for initializing the hierarchy transforms."""
-        with slicer.util.tryWithErrorDisplay("Failed initialize transforms.", 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!")
-
-            CT = self.ui.inputSelectorCT.currentNode()
-            rootID = self.ui.SubjectHierarchyComboBox.currentItem()
-            TreeNode(hierarchyID=rootID, ctSequence=CT, isRoot=True)
-
 
 #
 # Hierarchical3DRegistrationLogic
@@ -345,9 +567,6 @@ def __init__(self) -> None:
         Called when the logic class is instantiated. Can be used for initializing member variables.
         """
         ScriptedLoadableModuleLogic.__init__(self)
-        self.autoscoperLogic = AutoscoperM.AutoscoperMLogic()
-        self.cancelRequested = False
-        self.isRunning = False
         self._itk = None
 
     @property
@@ -396,7 +615,7 @@ def getParameterNode(self):
         return Hierarchical3DRegistrationParameterNode(super().getParameterNode())
 
     @staticmethod
-    def parameterObject2SlicerTransform(paramObj) -> slicer.vtkMRMLTransformNode:
+    def parameterObject2SlicerTransform(paramObj) -> slicer.vtkMRMLLinearTransformNode:
         from math import cos, sin
 
         import numpy as np
@@ -429,22 +648,36 @@ def parameterObject2SlicerTransform(paramObj) -> slicer.vtkMRMLTransformNode:
 
     def registerRigidBody(
         self,
-        CT: vtkMRMLScalarVolumeNode,
-        partialVolume: vtkMRMLScalarVolumeNode,
-        transformNode: vtkMRMLTransformNode,
-    ):
-        """Registers a partial volume to a CT scan, uses ITKElastix."""
+        sourceVolume: vtkMRMLScalarVolumeNode,
+        targetVolume: vtkMRMLScalarVolumeNode,
+        transformNode: vtkMRMLLinearTransformNode,
+    ) -> vtkMRMLLinearTransformNode:
+        """
+        Registers a source volume to a target volume using ITKElastix.
+        The output of this function is written into transformNode such that,
+        when applied on the source image, will align it with the target image.
+        The relative transform found by ITKElastix is also returned.
+
+        :param sourceVolume: the source input to be registered, aka the "moving image"
+        :param targetVolume: the target input to be registered, aka the "fixed image"
+        :param transformNode: the initial guess transform, node will be overwritten to
+                              the composition of its value and the ITKElastix result
+                              (the total transform from sourceVolume to targetVolume)
+
+        :return: the transform node representing the result relative transform from
+                 sourceVolume *with* the transformNode applied, to targetVolume
+        """
         from tempfile import NamedTemporaryFile
 
         # Apply the initial guess if there is one
-        partialVolume.SetAndObserveTransformNodeID(None)
-        partialVolume.SetAndObserveTransformNodeID(transformNode.GetID())
-        partialVolume.HardenTransform()
+        sourceVolume.SetAndObserveTransformNodeID(None)
+        sourceVolume.SetAndObserveTransformNodeID(transformNode.GetID())
+        sourceVolume.HardenTransform()
 
         # Register with Elastix
         with NamedTemporaryFile(suffix=".mha") as movingTempFile, NamedTemporaryFile(suffix=".mha") as fixedTempFile:
-            slicer.util.saveNode(CT, movingTempFile.name)
-            slicer.util.saveNode(partialVolume, fixedTempFile.name)
+            slicer.util.saveNode(sourceVolume, movingTempFile.name)
+            slicer.util.saveNode(targetVolume, fixedTempFile.name)
 
             movingITKImage = self.itk.imread(movingTempFile.name, self.itk.F)
             fixedITKImage = self.itk.imread(fixedTempFile.name, self.itk.F)
@@ -462,71 +695,83 @@ def registerRigidBody(
             except Exception:
                 # Remove the hardened initial guess and then throw the exception
                 transformNode.Inverse()
-                partialVolume.SetAndObserveTransformNodeID(transformNode.GetID())
-                partialVolume.HardenTransform()
+                sourceVolume.SetAndObserveTransformNodeID(transformNode.GetID())
+                sourceVolume.HardenTransform()
                 transformNode.Inverse()
                 raise
 
         resultTransform = self.parameterObject2SlicerTransform(elastixObj.GetTransformParameterObject())
+        # The elastix result represents the transformation from the fixed to the moving
+        # image, we so invert it to get the transform from the moving to the fixed
+        resultTransform.Inverse()
 
         # Remove the hardened initial guess
         transformNode.Inverse()
-        partialVolume.SetAndObserveTransformNodeID(transformNode.GetID())
-        partialVolume.HardenTransform()
+        sourceVolume.SetAndObserveTransformNodeID(transformNode.GetID())
+        sourceVolume.HardenTransform()
         transformNode.Inverse()
 
         # Combine the initial and result transforms
         transformNode.SetAndObserveTransformNodeID(resultTransform.GetID())
         transformNode.HardenTransform()
 
-        # Clean up
-        slicer.mrmlScene.RemoveNode(resultTransform)
+        return resultTransform
 
-    def registerSequence(
+    def registerBoneInFrame(
         self,
-        ctSequence: vtkMRMLSequenceNode,
-        rootID: int,
-        startFrame: int,
-        endFrame: int,
+        boneNode: TreeNode,
+        targetFrameIdx: int,
+        manualTfmMatrix: vtk.vtkMatrix4x4,
         trackOnlyRoot: bool = False,
     ) -> None:
-        """Performs hierarchical registration on a ct sequence."""
+        """
+        Performs hierarchical registration for a specific bone in a sequence frame.
+
+        :param boneNode: the object representing the bone to be registered
+        :param targetFrameIdx: the target frame index the bone will be registered to
+        :param manualTfmMatrix: the matrix representing the portion of the transform
+                                  adjusted by the user, to be used with elastix results
+        :param trackOnlyRoot: whether to propagate the result to child node in the hierarchy
+        """
         import logging
         import time
 
-        rootNode = TreeNode(hierarchyID=rootID, ctSequence=ctSequence, isRoot=True)
-
-        try:
-            self.isRunning = True
-            for idx in range(startFrame, endFrame + 1):
-                nodeList = [rootNode]
-                for node in nodeList:
-                    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
-                    if not trackOnlyRoot:
-                        node.applyTransformToChildren(idx)
-                        nodeList.extend(node.childNodes)
-
-                    node.dataNode.SetAndObserveTransformNodeID(node.getTransform(idx).GetID())
-
-                if idx != endFrame:  # Unless it's the last frame
-                    rootNode.copyTransformToNextFrame(idx)
-        finally:
-            self.isRunning = False
-            self.cancelRequested = False
+        # Get the moving and fixed images for registration, as well as the initial guess transform.
+        # This transform is ready to be input to elastix, and it's comprised of the initial bone
+        # position and the manual adjustment made by the user.
+        source_cropped_volume = boneNode.croppedSourceVolume
+        target_cropped_volume = boneNode.getCroppedFrame(targetFrameIdx)
+        bone_tfm = boneNode.getTransform(targetFrameIdx)
+
+        # Calculate the relative transform from the source to the target volume.
+        # NOTE: The result of the complete transform of the bone from the source volume is written
+        # into the bone_tfm node, which is already saved in the TreeNode's transformSequence.
+        logging.info(f"Registering: {boneNode.name} to frame {targetFrameIdx}")
+        start = time.time()
+        elastix_tfm = self.registerRigidBody(
+            source_cropped_volume,
+            target_cropped_volume,
+            bone_tfm,
+        )
+        end = time.time()
+        logging.info(f"{boneNode.name} took {end-start:.3f}s for frame {targetFrameIdx}.")
+
+        # propagate the result transform to all child nodes of the current bone in the hierarchy
+        if not trackOnlyRoot:
+            # We want to propagate the transform from this bone's initial position in this frame to
+            # the position found by the elastix optimization. This is euqal to the composition of
+            # the manual adjustment from the user (or just the identity if none was performed), and
+            # the elastix result transform.
+            elastix_tfm_matrix = vtk.vtkMatrix4x4()
+            elastix_tfm.GetMatrixTransformToParent(elastix_tfm_matrix)
+            source_to_target_matrix = vtk.vtkMatrix4x4()
+            vtk.vtkMatrix4x4.Multiply4x4(elastix_tfm_matrix, manualTfmMatrix, source_to_target_matrix)
+            source_to_target_tfm = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLinearTransformNode")
+            source_to_target_tfm.SetMatrixTransformToParent(source_to_target_matrix)
+
+            # apply recursively to all child nodes of the current bone, then clean up
+            boneNode.applyTransformToChildren(targetFrameIdx, source_to_target_tfm)
+            slicer.mrmlScene.RemoveNode(source_to_target_tfm)
+
+        # Clean up intermediate result node that was just added to the scene
+        slicer.mrmlScene.RemoveNode(elastix_tfm)
diff --git a/Hierarchical3DRegistration/Hierarchical3DRegistrationLib/TreeNode.py b/Hierarchical3DRegistration/Hierarchical3DRegistrationLib/TreeNode.py
index 6fab7eb..a657b0c 100644
--- a/Hierarchical3DRegistration/Hierarchical3DRegistrationLib/TreeNode.py
+++ b/Hierarchical3DRegistration/Hierarchical3DRegistrationLib/TreeNode.py
@@ -11,110 +11,230 @@
 
 class TreeNode:
     """
-    Data structure to store a basic tree hierarchy.
+    Data structure to store a basic tree hierarchy along with data needed for registration.
+    Each TreeNode represents a bone in the hierarchy and keeps track of its respective
+    moving and fixed images for registration as well as the registration result transforms.
     """
 
     def __init__(
         self,
         hierarchyID: int,
         ctSequence: slicer.vtkMRMLSequenceNode,
+        sourceVolume: slicer.vtkMRMLScalarVolumeNode,
         parent: TreeNode | None = None,
         isRoot: bool = False,
     ):
+        """
+        Create a new TreeNode with the given parent.
+
+        :param hierarchyID: this node's subject hierarchy ID
+        :param ctSequence: the entire volume sequence (target) to be registered
+        :param sourceVolume: the scalar volume to register from (from which this node's model was generated)
+        :param parent: reference to the parent node
+        :param isRoot: whether this node it the root of the hierarchy
+        """
         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()
-
         self.name = self.shNode.GetItemName(self.hierarchyID)
-        self.dataNode = self.shNode.GetItemDataNode(self.hierarchyID)
-        self.transformSequence = self._initializeTransforms()
+        self.model = self.shNode.GetItemDataNode(self.hierarchyID)
+
+        if self.model.GetClassName() != "vtkMRMLModelNode":
+            raise ValueError(f"Hierarchy item '{self.name}' is not of type vtkMRMLModelNode!")
+
+        # disable model rendering, it will be turned visible once this bone is registered
+        self.model.CreateDefaultDisplayNodes()
+        self.model.SetDisplayVisibility(False)
 
+        # initialize registration inputs and outputs for this node
+        self.roi = self._generateRoiFromModel()
+        self.sourceVolume = sourceVolume
+        self.croppedSourceVolume = AutoscoperMLogic.cropVolumeFromROI(sourceVolume, self.roi)
+        self.croppedSourceVolume.SetName(f"{sourceVolume.GetName()}_{self.name}_cropped_source")
+        self.transformSequence = self._initializeTransforms(ctSequence)
+        self.croppedCtSequence = self._initializeCroppedCT(ctSequence)
+
+        # initialize temporary variable to save the recent transform before manual adjustment
+        self.currTransformBeforeAdjustment = slicer.mrmlScene.CreateNodeByClass("vtkMRMLLinearTransformNode")
+        self.currTransformBeforeAdjustment.UnRegister(None)  # release extra reference to avoid memory leak message
+
+        # recursively create any child nodes from the subject hierarchy
         children_ids = []
         self.shNode.GetItemChildren(self.hierarchyID, children_ids)
         self.childNodes = [
-            TreeNode(hierarchyID=child_id, ctSequence=self.ctSequence, parent=self) for child_id in children_ids
+            TreeNode(hierarchyID=child_id, parent=self, ctSequence=ctSequence, sourceVolume=sourceVolume)
+            for child_id in children_ids
         ]
 
-    def _initializeTransforms(self) -> slicer.vtkMRMLSequenceNode:
+    def _generateRoiFromModel(self) -> slicer.vtkMRMLMarkupsROINode:
+        """Creates a region of interest node from this TreeNode's model."""
+
+        # Create ROI node
+        bb_center, bb_size = AutoscoperMLogic.getModelBoundingBox(self.model)
+        modelROI = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLMarkupsROINode")
+        modelROI.SetCenter(bb_center)
+        modelROI.SetSize(bb_size)
+        modelROI.SetDisplayVisibility(0)
+        modelROI.SetName(f"{self.name}_roi")
+
+        return modelROI
+
+    def _initializeTransforms(self, ctSequence) -> slicer.vtkMRMLSequenceNode:
         """Creates a new transform sequence in the same browser as the CT sequence."""
-        import logging
-
-        try:
-            logging.info(f"Searching for {self.name} transforms")
-            newSequenceNode = slicer.util.getNode(f"{self.name}_transform_sequence")
-        except slicer.util.MRMLNodeNotFoundException:
-            try:  # Loading the sequence transforms from seq and tfm files can be wacky
-                logging.info(f"Searching for {self.name} transforms")
-                newSequenceNode = slicer.util.getNode(
-                    f"{self.name}_transform_sequence-{self.name}_transform_sequence-Seq"
-                )
-            except slicer.util.MRMLNodeNotFoundException:
-                logging.info(f"Transforms not found, Initializing {self.name}")
-                newSequenceNode = self.autoscoperLogic.createSequenceNodeInBrowser(
-                    f"{self.name}_transform_sequence", self.ctSequence
-                )
-                nodes = []
-                for i in range(self.ctSequence.GetNumberOfDataNodes()):
-                    curTfm = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLinearTransformNode", f"{self.name}-{i}")
-                    newSequenceNode.SetDataNodeAtValue(curTfm, f"{i}")
-                    nodes.append(curTfm)
-                [slicer.mrmlScene.RemoveNode(node) for node in nodes]
-
-                # Bit of a strange issue but the browser doesn't seem to update unless it moves to a new index,
-                # so we force it to update here
-                self.autoscoperLogic.getItemInSequence(newSequenceNode, 1)
-                self.autoscoperLogic.getItemInSequence(newSequenceNode, 0)
-
-                slicer.app.processEvents()
+
+        newSequenceNode = AutoscoperMLogic.createSequenceNodeInBrowser(f"{self.name}_transform_sequence", ctSequence)
+        identityTfm = slicer.mrmlScene.CreateNodeByClass("vtkMRMLLinearTransformNode")
+        identityTfm.UnRegister(None)  # release extra reference of object to avoid memory leak message
+
+        # batch the processing event for the addition of the new transform nodes, for speedup
+        slicer.mrmlScene.StartState(slicer.vtkMRMLScene.BatchProcessState)
+
+        for i in range(ctSequence.GetNumberOfDataNodes()):
+            idxValue = ctSequence.GetNthIndexValue(i)
+            newSequenceNode.SetDataNodeAtValue(identityTfm, idxValue)
+
+        slicer.mrmlScene.EndState(slicer.vtkMRMLScene.BatchProcessState)
+        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.transformSequence.GetNumberOfDataNodes():
-            logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
-            return
-        current_transform = self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
-        current_transform.SetAndObserveTransformNodeID(transform.GetID())
-        current_transform.HardenTransform()
+    def _initializeCroppedCT(self, ctSequence) -> slicer.vtkMRMLSequenceNode:
+        """Creates a new (but empty) volume sequence in the same browser as the CT sequence."""
+        newSequenceNode = AutoscoperMLogic.createSequenceNodeInBrowser(
+            f"{self.name}_cropped_volume_sequence", ctSequence
+        )
+        emptyVolume = slicer.mrmlScene.CreateNodeByClass("vtkMRMLScalarVolumeNode")
+        emptyVolume.UnRegister(None)  # release extra reference of object to avoid memory leak message
+
+        # batch the processing event for the addition of the new transform nodes, for speedup
+        slicer.mrmlScene.StartState(slicer.vtkMRMLScene.BatchProcessState)
+
+        for i in range(ctSequence.GetNumberOfDataNodes()):
+            idxValue = ctSequence.GetNthIndexValue(i)
+            newSequenceNode.SetDataNodeAtValue(emptyVolume, idxValue)
+
+        slicer.mrmlScene.EndState(slicer.vtkMRMLScene.BatchProcessState)
+        slicer.app.processEvents()
+        return newSequenceNode
+
+    def startInteraction(self, frameIdx) -> None:
+        """Enable model visibility and transform interaction for this bone in the current frame"""
+        current_tfm = self.getTransform(frameIdx)
+        self.currTransformBeforeAdjustment.CopyContent(current_tfm, True)
+
+        self.roi.SetAndObserveTransformNodeID(current_tfm.GetID())
+        self.model.SetAndObserveTransformNodeID(current_tfm.GetID())
+        self.model.SetDisplayVisibility(True)
+        model_display = self.model.GetDisplayNode()
+        model_display.SetVisibility2D(True)
+        model_display.SetVisibility3D(True)
+
+        model_center, _ = AutoscoperMLogic.getModelBoundingBox(self.model)
+
+        tfm = self.getTransform(frameIdx)
+        tfm.SetCenterOfTransformation(model_center)
+        tfm.CreateDefaultDisplayNodes()
+        tfm_display = tfm.GetDisplayNode()
+        tfm_display.SetEditorVisibility(True)
+        tfm_display.SetEditorVisibility3D(True)
+
+        slicer.app.processEvents()
+
+    def stopInteraction(self, frameIdx) -> vtk.vtkMatrix4x4:
+        """Disable transform interaction for this bone in the current frame"""
+        self.model.SetDisplayVisibility(True)
+        model_display = self.model.GetDisplayNode()
+        model_display.SetVisibility2D(True)
+        model_display.SetVisibility3D(True)
+
+        tfm = self.getTransform(frameIdx)
+        tfm.CreateDefaultDisplayNodes()
+        tfm_display = tfm.GetDisplayNode()
+        tfm_display.SetEditorVisibility(False)
+        tfm_display.SetEditorVisibility3D(False)
+
+        # calculate the component of the transform modified by the user interaction
+        current_tfm = self.getTransform(frameIdx)
+        current_tfm_matrix = vtk.vtkMatrix4x4()
+        current_tfm.GetMatrixTransformToParent(current_tfm_matrix)
+
+        inverse_tfm_before_adjustment_matrix = vtk.vtkMatrix4x4()
+        self.currTransformBeforeAdjustment.GetMatrixTransformToParent(inverse_tfm_before_adjustment_matrix)
+        inverse_tfm_before_adjustment_matrix.Invert()
+
+        tfm_manual = vtk.vtkMatrix4x4()
+        vtk.vtkMatrix4x4.Multiply4x4(current_tfm_matrix, inverse_tfm_before_adjustment_matrix, tfm_manual)
+
+        # reset the helper variable with the identity for next time
+        identity_matrix = vtk.vtkMatrix4x4()
+        self.currTransformBeforeAdjustment.SetMatrixTransformToParent(identity_matrix)
+
+        slicer.app.processEvents()
+
+        return tfm_manual
+
+    def setupFrame(self, frameIdx, ctFrame) -> None:
+        """
+        Crop the target CT frame based on the initial guess transform and this node's ROI.
+        """
+        current_tfm = self.getTransform(frameIdx)
+        self.roi.SetAndObserveTransformNodeID(current_tfm.GetID())
+
+        # first check if the roi bounds exceed the CT frame target volume
+        new_roi_dims = AutoscoperMLogic.checkROIAndVolumeOverlap(self.roi, ctFrame)
+        if None not in new_roi_dims:
+            logging.info(f"Downsizing ROI of bone '{self.name}' to not exceed target volume '{ctFrame.GetName()}'")
+            # update roi to dimension of the intersection and align it
+            new_roi_center, new_roi_size = new_roi_dims
+            self.roi.SetCenter(new_roi_center)
+            self.roi.SetSize(new_roi_size)
+            current_tfm.Inverse()
+            self.roi.SetAndObserveTransformNodeID(current_tfm.GetID())
+            self.roi.HardenTransform()
+            current_tfm.Inverse()
+            # replace the cropped source volume with that from the new roi
+            slicer.mrmlScene.RemoveNode(self.croppedSourceVolume)
+            self.croppedSourceVolume = AutoscoperMLogic.cropVolumeFromROI(self.sourceVolume, self.roi)
+            self.croppedSourceVolume.SetName(f"{self.sourceVolume.GetName()}_{self.name}_cropped_source")
+            self.roi.SetAndObserveTransformNodeID(current_tfm.GetID())
+
+        # generate cropped volume from the given frame
+        self.model.SetAndObserveTransformNodeID(current_tfm.GetID())
+        self.croppedSourceVolume.SetAndObserveTransformNodeID(current_tfm.GetID())
+        croppedFrameNode = self.getCroppedFrame(frameIdx)
+        AutoscoperMLogic.cropVolumeFromROI(ctFrame, self.roi, croppedFrameNode)
 
     def getTransform(self, idx: int) -> slicer.vtkMRMLTransformNode:
         """Returns the transform at the provided index."""
         if idx >= self.transformSequence.GetNumberOfDataNodes():
             logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
             return None
-        return self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
+        return AutoscoperMLogic.getItemInSequence(self.transformSequence, idx)[0]
 
-    def setTransformFromNode(self, transform: slicer.vtkMRMLLinearTransformNode, idx: int) -> None:
-        """Sets the transform for the provided index."""
-        if idx >= self.transformSequence.GetNumberOfDataNodes():
+    def getCroppedFrame(self, idx: int) -> slicer.vtkMRMLScalarVolumeNode:
+        if idx >= self.croppedCtSequence.GetNumberOfDataNodes():
             logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
-            return
-        mat = vtk.vtkMatrix4x4()
-        transform.GetMatrixTransformToParent(mat)
-        current_transform = self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
-        current_transform.SetMatrixTransformToParent(mat)
+            return None
+        return AutoscoperMLogic.getItemInSequence(self.croppedCtSequence, idx)[0]
 
-    def setTransformFromMatrix(self, transform: vtk.vtkMatrix4x4, idx: int) -> None:
-        if idx >= self.transformSequence.GetNumberOfDataNodes():
-            logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
+    def _applyTransform(self, idx: int, transform: slicer.vtkMRMLTransformNode) -> None:
+        """Applies and hardends a transform node to the transform sequence at the provided index."""
+        current_transform = self.getTransform(idx)
+        if current_transform is None:
             return
-        current_transform = self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
-        current_transform.SetMatrixTransformToParent(transform)
+        current_transform.SetAndObserveTransformNodeID(transform.GetID())
+        current_transform.HardenTransform()
 
-    def applyTransformToChildren(self, idx: int) -> None:
+    def applyTransformToChildren(self, idx: int, transform: slicer.vtkMRMLLinearTransformNode) -> None:
         """Applies the transform at the provided index to all children of this node."""
-        if idx >= self.transformSequence.GetNumberOfDataNodes():
-            logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
-            return
-        applyTransform = self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
-        [childNode.setTransformFromNode(applyTransform, idx) for childNode in self.childNodes]
+        for childNode in self.childNodes:
+            childNode._applyTransform(idx, transform)
+            # recurse down all child nodes and apply it to them as well
+            childNode.applyTransformToChildren(idx, transform)
 
     def copyTransformToNextFrame(self, currentIdx: int) -> None:
         """Copies the transform at the provided index to the next frame."""
@@ -123,10 +243,26 @@ def copyTransformToNextFrame(self, currentIdx: int) -> None:
         currentTransform = self.getTransform(currentIdx)
         transformMatrix = vtk.vtkMatrix4x4()
         currentTransform.GetMatrixTransformToParent(transformMatrix)
-        nextTransform = self.getTransform(currentIdx + 1)
+
+        nextIdx = currentIdx + 1
+        nextTransform = self.getTransform(nextIdx)
         if nextTransform is not None:
             nextTransform.SetMatrixTransformToParent(transformMatrix)
 
+    def setModelsVisibility(self, visibility: bool) -> None:
+        """Sets the visibility of the model node of this node and all its child nodes"""
+        self.model.SetDisplayVisibility(visibility)
+        for childNode in self.childNodes:
+            childNode.setModelsVisibility(visibility)
+
+    def setTransformFromMatrix(self, transform: vtk.vtkMatrix4x4, idx: int) -> None:
+        """Sets the registration transform for the given index from the input matrix"""
+        current_transform = self.getTransform(idx)
+        if current_transform is None:
+            raise ValueError(f"Could not set transform at index {idx} of '{self.transformSequence.GetName()}'.")
+        current_transform.SetMatrixTransformToParent(transform)
+        self.model.SetAndObserveTransformNodeID(current_transform.GetID())
+
     def exportTransformsAsTRAFile(self, exportDir: str):
         """Exports the sequence as a TRA file for reading into Autoscoper."""
         # Convert the sequence to a list of vtkMatrices
@@ -143,6 +279,7 @@ def exportTransformsAsTRAFile(self, exportDir: str):
         IO.writeTRA(filename, transforms)
 
     def importTransfromsFromTRAFile(self, filename: str):
+        """Loads a TRA file as the registration transform sequence"""
         import numpy as np
 
         tra = np.loadtxt(filename, delimiter=",")
diff --git a/Hierarchical3DRegistration/Resources/UI/Hierarchical3DRegistration.ui b/Hierarchical3DRegistration/Resources/UI/Hierarchical3DRegistration.ui
index d241fd9..1eda1f3 100644
--- a/Hierarchical3DRegistration/Resources/UI/Hierarchical3DRegistration.ui
+++ b/Hierarchical3DRegistration/Resources/UI/Hierarchical3DRegistration.ui
@@ -11,21 +11,53 @@
    </rect>
   </property>
   <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QProgressBar" name="progressBar">
+     <property name="value">
+      <number>0</number>
+     </property>
+     <property name="invertedAppearance">
+      <bool>false</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="registrationStatusLabel">
+     <property name="font">
+      <font>
+       <weight>75</weight>
+       <bold>true</bold>
+      </font>
+     </property>
+     <property name="text">
+      <string/>
+     </property>
+     <property name="SlicerParameterName" stdset="0">
+      <string>statusMsg</string>
+     </property>
+     <property name="styleSheet">
+      <string notr="true">color: palette(link);</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignCenter</set>
+     </property>
+    </widget>
+   </item>
    <item>
     <widget class="ctkCollapsibleButton" name="inputsCollapsibleButton">
      <property name="text">
       <string>Registration</string>
      </property>
      <layout class="QGridLayout" name="gridLayout">
-      <item row="4" column="0">
-       <widget class="QLabel" name="label_2">
+      <item row="0" column="0">
+       <widget class="QLabel" name="inputCTSequenceLabel">
         <property name="text">
-         <string>Frames</string>
+         <string>Input CT Sequence:</string>
         </property>
        </widget>
       </item>
-      <item row="3" column="1" colspan="5">
-       <widget class="qMRMLNodeComboBox" name="inputSelectorCT">
+      <item row="0" column="1" colspan="5">
+       <widget class="qMRMLNodeComboBox" name="inputCTSequenceSelector">
         <property name="enabled">
          <bool>false</bool>
         </property>
@@ -35,6 +67,7 @@
         <property name="nodeTypes">
          <stringlist notr="true">
           <string>vtkMRMLScalarVolumeNode</string>
+          <string>vtkMRMLSequenceNode</string>
          </stringlist>
         </property>
         <property name="showChildNodeTypes">
@@ -47,24 +80,55 @@
          <bool>false</bool>
         </property>
         <property name="SlicerParameterName" stdset="0">
-         <string>inputVolumeSequence</string>
+         <string>volumeSequence</string>
         </property>
        </widget>
       </item>
-      <item row="4" column="1">
-       <widget class="QSpinBox" name="startFrame">
-        <property name="minimum">
-         <number>0</number>
+      <item row="1" column="0">
+       <widget class="QLabel" name="inputSourceCTLabel">
+        <property name="text">
+         <string>Source Volume:</string>
         </property>
-        <property name="maximum">
-         <number>0</number>
+       </widget>
+      </item>
+      <item row="1" column="1" colspan="5">
+       <widget class="qMRMLNodeComboBox" name="inputSourceCTSelector">
+        <property name="enabled">
+         <bool>true</bool>
         </property>
-        <property name="value">
-         <number>0</number>
+        <property name="toolTip">
+         <string>Pick the volume from which the models were generated, to be registered against the input sequence.</string>
+        </property>
+        <property name="nodeTypes">
+         <stringlist notr="true">
+          <string>vtkMRMLScalarVolumeNode</string>
+         </stringlist>
+        </property>
+        <property name="showChildNodeTypes">
+         <bool>false</bool>
+        </property>
+        <property name="addEnabled">
+         <bool>false</bool>
+        </property>
+        <property name="removeEnabled">
+         <bool>false</bool>
+        </property>
+        <property name="interactionNodeSingletonTag">
+         <string notr="true"/>
+        </property>
+        <property name="SlicerParameterName" stdset="0">
+         <string>sourceVolume</string>
         </property>
        </widget>
       </item>
-      <item row="0" column="1" colspan="5">
+      <item row="2" column="0">
+       <widget class="QLabel" name="label">
+        <property name="text">
+         <string>Hierarchy:</string>
+        </property>
+       </widget>
+      </item>
+      <item row="2" column="1" colspan="5">
        <widget class="qMRMLSubjectHierarchyComboBox" name="SubjectHierarchyComboBox">
         <property name="includeItemAttributeNamesFilter">
          <stringlist notr="true"/>
@@ -79,45 +143,34 @@
          <stringlist notr="true"/>
         </property>
         <!-- <property name="SlicerParameterName" stdset="0"> -->
-        <!--  <string>inputHierarchyRootID</string> -->
+        <!--  <string>hierarchyRootID</string> -->
         <!-- </property> -->
        </widget>
       </item>
       <item row="3" column="0">
-       <widget class="QLabel" name="label_6">
+       <widget class="QLabel" name="label_2">
         <property name="text">
-         <string>Input CT Sequence:</string>
+         <string>Frames:</string>
         </property>
        </widget>
       </item>
-      <item row="5" column="1" colspan="5">
-       <widget class="QPushButton" name="applyButton">
-        <property name="enabled">
-         <bool>false</bool>
-        </property>
-        <property name="toolTip">
-         <string>Run the algorithm.</string>
+      <item row="3" column="1">
+       <widget class="QSpinBox" name="startFrame">
+        <property name="minimum">
+         <number>0</number>
         </property>
-        <property name="text">
-         <string>Apply</string>
+        <property name="maximum">
+         <number>0</number>
         </property>
-       </widget>
-      </item>
-      <item row="5" column="0">
-       <widget class="QCheckBox" name="onlyTrackRootNodeCheckBox">
-        <property name="text">
-         <string>Track Root Node Only</string>
+        <property name="value">
+         <number>0</number>
         </property>
-       </widget>
-      </item>
-      <item row="0" column="0">
-       <widget class="QLabel" name="label">
-        <property name="text">
-         <string>Hierarchy:</string>
+        <property name="SlicerParameterName" stdset="0">
+         <string>startFrameIdx</string>
         </property>
        </widget>
       </item>
-      <item row="4" column="4">
+      <item row="3" column="2" colspan="3">
        <widget class="ctkRangeSlider" name="frameSlider">
         <property name="maximum">
          <number>0</number>
@@ -136,7 +189,7 @@
         </property>
        </widget>
       </item>
-      <item row="4" column="5">
+      <item row="3" column="5">
        <widget class="QSpinBox" name="endFrame">
         <property name="maximum">
          <number>0</number>
@@ -144,18 +197,74 @@
         <property name="value">
          <number>0</number>
         </property>
+        <property name="SlicerParameterName" stdset="0">
+         <string>endFrameIdx</string>
+        </property>
        </widget>
       </item>
-      <item row="1" column="1" colspan="5">
-       <widget class="QPushButton" name="initHierarchyButton">
-        <property name="toolTip">
-         <string/>
+      <item row="4" column="0" colspan="2">
+       <widget class="QCheckBox" name="onlyTrackRootNodeCheckBox">
+        <property name="text">
+         <string>Track Root Node Only</string>
         </property>
+        <property name="SlicerParameterName" stdset="0">
+         <string>trackOnlyRoot</string>
+        </property>
+       </widget>
+      </item>
+      <item row="4" column="2">
+       <widget class="QCheckBox" name="skipManualTfmAdjustmentCheckBox">
         <property name="text">
-         <string>Initialize Hierarchy Transforms</string>
+         <string>Skip Initial Guess Adjustment</string>
+        </property>
+        <property name="SlicerParameterName" stdset="0">
+         <string>skipManualTfmAdjustments</string>
         </property>
        </widget>
       </item>
+      <item row="5" column="0" colspan="6">
+       <layout class="QGridLayout" name="trackingButtonsLayout">
+        <item row="0" column="0">
+         <widget class="QPushButton" name="abortButton">
+          <property name="enabled">
+           <bool>false</bool>
+          </property>
+          <property name="toolTip">
+           <string>Abort the current registration process.</string>
+          </property>
+          <property name="text">
+           <string>Abort</string>
+          </property>
+         </widget>
+        </item>
+        <item row="0" column="1">
+         <widget class="QPushButton" name="initializeButton">
+          <property name="enabled">
+           <bool>false</bool>
+          </property>
+          <property name="toolTip">
+           <string>Begin the registration process for the selected input fields.</string>
+          </property>
+          <property name="text">
+           <string>Initialize Registration</string>
+          </property>
+         </widget>
+        </item>
+        <item row="0" column="2">
+         <widget class="QPushButton" name="registerButton">
+          <property name="enabled">
+           <bool>false</bool>
+          </property>
+          <property name="toolTip">
+           <string>Run the algorithm.</string>
+          </property>
+          <property name="text">
+           <string>Set Initial Guess And Register</string>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </item>
      </layout>
     </widget>
    </item>
@@ -251,7 +360,7 @@
   <connection>
    <sender>Tracking3D</sender>
    <signal>mrmlSceneChanged(vtkMRMLScene*)</signal>
-   <receiver>inputSelectorCT</receiver>
+   <receiver>inputCTSequenceSelector</receiver>
    <slot>setMRMLScene(vtkMRMLScene*)</slot>
    <hints>
     <hint type="sourcelabel">
@@ -344,5 +453,21 @@
     </hint>
    </hints>
   </connection>
+  <connection>
+   <sender>Tracking3D</sender>
+   <signal>mrmlSceneChanged(vtkMRMLScene*)</signal>
+   <receiver>inputSourceCTSelector</receiver>
+   <slot>setMRMLScene(vtkMRMLScene*)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>392</x>
+     <y>280</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>453</x>
+     <y>116</y>
+    </hint>
+   </hints>
+  </connection>
  </connections>
 </ui>


Reviews:
user: login: amymmorton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:21:30Z
user: login: amymmorton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:29:36Z
user: login: sbelsk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:32:48Z
user: login: amymmorton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:39:04Z
user: login: amymmorton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:21:06Z
user: login: sbelsk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:25:00Z
user: login: amymmorton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T20:28:29Z
user: login: sbelsk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:24:11Z
user: login: amymmorton
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T20:40:25Z

Review comments:
diff_hunk: @@ -61,12 +70,33 @@ class Hierarchical3DRegistrationParameterNode:
     """
     The parameters needed by module.
 
-    inputVolumeSequence - The volume sequence.
-
+    hierarchyRootID: The ID associated with the root of the model hierarchy
+    volumeSequence: The volume sequence to be registered
+    startFrame: The frame index from which registration is to start
+    endFrame: The frame index up to which registration is to be performed
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: amymmorton
body: ```suggestion
    volumeSequence: The volume sequence (target) to be registered to
    startFrame: The frame index in volumeSequence from which registration is to start
    endFrame: The frame index in volumeSequence up to which registration is to be performed
    sourceVolume: The scalar volume to register from (from which input hierarchy were generated)
```
created_at: 2025-03-11T19:21:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: amymmorton
body: source volume is scalar... why frame index?
created_at: 2025-03-11T19:29:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: sbelsk
body: This variable holds the frame index, yes. The code as currently written needs better organization and some cleaning up for clarity.
created_at: 2025-03-11T19:32:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,103 +18,196 @@ def __init__(
         self,
         hierarchyID: int,
         ctSequence: slicer.vtkMRMLSequenceNode,
+        sourceVolume: slicer.vtkMRMLScalarVolumeNode,
         parent: TreeNode | None = None,
         isRoot: bool = False,
     ):
         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()
-
         self.name = self.shNode.GetItemName(self.hierarchyID)
-        self.dataNode = self.shNode.GetItemDataNode(self.hierarchyID)
-        self.transformSequence = self._initializeTransforms()
+        self.model = self.shNode.GetItemDataNode(self.hierarchyID)
+
+        # hide from user, this will be turned visible only once it's being registered
+        self.model.CreateDefaultDisplayNodes()
+        self.model.SetDisplayVisibility(False)
+
+        if self.model.GetClassName() != "vtkMRMLModelNode":
+            raise ValueError(f"Hierarchy item '{self.name}' is not of type vtkMRMLModelNode!")
+
+        self.roi = self._generateRoiFromModel()
+        self.croppedSourceVolume = self.cropFrameFromRoi(sourceVolume)
+        self.transformSequence = self._initializeTransforms(ctSequence)
+        self.croppedCtSequence = dict() #self._initializeCroppedCT(ctSequence)
 
         children_ids = []
         self.shNode.GetItemChildren(self.hierarchyID, children_ids)
         self.childNodes = [
-            TreeNode(hierarchyID=child_id, ctSequence=self.ctSequence, parent=self) for child_id in children_ids
+            TreeNode(hierarchyID=child_id, parent=self, ctSequence=ctSequence, sourceVolume=sourceVolume) for child_id in children_ids
         ]
 
-    def _initializeTransforms(self) -> slicer.vtkMRMLSequenceNode:
+    def _generateRoiFromModel(self) -> slicer.vtkMRMLMarkupsROINode:
+        """Creates a region of interest node from this TreeNode's model."""
+
+        # Create ROI node
+        bb_center, bb_size = AutoscoperMLogic.getModelBoundingBox(self.model)
+        modelROI = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLMarkupsROINode")
+        modelROI.SetCenter(bb_center)
+        modelROI.SetSize(bb_size)
+        modelROI.SetDisplayVisibility(0)
+        modelROI.SetName(f"{self.name}_roi")
+
+        return modelROI
+
+    def _initializeTransforms(self, ctSequence) -> slicer.vtkMRMLSequenceNode:
         """Creates a new transform sequence in the same browser as the CT sequence."""
-        import logging
-
-        try:
-            logging.info(f"Searching for {self.name} transforms")
-            newSequenceNode = slicer.util.getNode(f"{self.name}_transform_sequence")
-        except slicer.util.MRMLNodeNotFoundException:
-            try:  # Loading the sequence transforms from seq and tfm files can be wacky
-                logging.info(f"Searching for {self.name} transforms")
-                newSequenceNode = slicer.util.getNode(
-                    f"{self.name}_transform_sequence-{self.name}_transform_sequence-Seq"
-                )
-            except slicer.util.MRMLNodeNotFoundException:
-                logging.info(f"Transforms not found, Initializing {self.name}")
-                newSequenceNode = self.autoscoperLogic.createSequenceNodeInBrowser(
-                    f"{self.name}_transform_sequence", self.ctSequence
-                )
-                nodes = []
-                for i in range(self.ctSequence.GetNumberOfDataNodes()):
-                    curTfm = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLinearTransformNode", f"{self.name}-{i}")
-                    newSequenceNode.SetDataNodeAtValue(curTfm, f"{i}")
-                    nodes.append(curTfm)
-                [slicer.mrmlScene.RemoveNode(node) for node in nodes]
-
-                # Bit of a strange issue but the browser doesn't seem to update unless it moves to a new index,
-                # so we force it to update here
-                self.autoscoperLogic.getItemInSequence(newSequenceNode, 1)
-                self.autoscoperLogic.getItemInSequence(newSequenceNode, 0)
-
-                slicer.app.processEvents()
+        # TODO: make sure output transforms start at the appropriate if startIdx != 0.
+
+        newSequenceNode = AutoscoperMLogic.createSequenceNodeInBrowser(f"{self.name}_transform_sequence", ctSequence)
+        identityTfm = slicer.mrmlScene.CreateNodeByClass("vtkMRMLLinearTransformNode")
+        identityTfm.UnRegister(None)  # release extra reference of ojbect to avoid memory leak message
+
+        # batch the processing event for the addition of the transforms, for speedup
+        slicer.mrmlScene.StartState(slicer.vtkMRMLScene.BatchProcessState)
+
+        for i in range(ctSequence.GetNumberOfDataNodes()):
+            idxValue = ctSequence.GetNthIndexValue(i)
+            newSequenceNode.SetDataNodeAtValue(identityTfm, idxValue)
+
+        slicer.mrmlScene.EndState(slicer.vtkMRMLScene.BatchProcessState)
+        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.transformSequence.GetNumberOfDataNodes():
-            logging.warning(f"Provided index {idx} is greater than number of data nodes in the sequence.")
-            return
-        current_transform = self.autoscoperLogic.getItemInSequence(self.transformSequence, idx)[0]
-        current_transform.SetAndObserveTransformNodeID(transform.GetID())
-        current_transform.HardenTransform()
+    def _initializeCroppedCT(self, ctSequence) -> slicer.vtkMRMLSequenceNode:
+        """Creates a new (but empty) volume sequence in the same browser as the CT sequence."""
+
+        return AutoscoperMLogic.createSequenceNodeInBrowser(f"{self.name}_cropped_CT_sequence", ctSequence)
+
+    def startInteraction(self, frameIdx) -> None:
+        """Enable model visibility and transform interaction for this bone in the current frame"""
+        current_tfm = self.getTransform(frameIdx)
+        self.model.SetAndObserveTransformNodeID(current_tfm.GetID())
+        self.model.SetDisplayVisibility(True)
+        model_display = self.model.GetDisplayNode()
path: Hierarchical3DRegistration/Hierarchical3DRegistrationLib/TreeNode.py
user: login: amymmorton
body: suggest applying volume render visibility to volumeSequence(frameIndx) 
created_at: 2025-03-11T19:39:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
+            target_frame = self._parameterNode.currentFrame
 
-                    startFrame = self.ui.startFrame.value
-                    endFrame = self.ui.endFrame.value
+            slicer.util.forceRenderAllViews()
 
-                    trackOnlyRoot = self.ui.onlyTrackRootNodeCheckBox.isChecked()
+            if source_frame < self._parameterNode.startFrame:
+                source_frame = "source"
 
-                    self.logic.registerSequence(CT, rootID, startFrame, endFrame, trackOnlyRoot)
-                finally:
-                    self.inProgress = False
-            slicer.util.messageBox("Success!")
-        self.updateApplyButtonState()
+            self._parameterNode.statusMsg = f"Registering bone '{self.currentBone.name}' in frame {target_frame}"
 
-    def onImportButton(self):
+            target_volume = AutoscoperMLogic.getItemInSequence(self._parameterNode.volumeSequence, target_frame)[0]
+            self.currentBone.setupFrame(target_frame, target_volume)
+
+            self.logic.registerBoneInFrame(
+                self.currentBone,
+                source_frame,
+                target_frame,
+                self._parameterNode.trackOnlyRoot
+            )
+
+            # If there is a next frame to register, propagate the transform generated from this frame
+            if target_frame != self._parameterNode.endFrame:
+                self.currentBone.copyTransformToNextFrame(target_frame)
+
+            # append all child bones to queue of bones to register next in this frame
+            if not self._parameterNode.trackOnlyRoot:
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: amymmorton
body: Transform propagation to next child in TreeNode within the current frame doesn't seem to be working:

![image (1)](https://github.com/user-attachments/assets/65f6dcce-9a9a-43c1-8166-2e402f93c4a5)

Expected the mc1 to be in same relative alignment from tpm as in source scalar vol 
created_at: 2025-03-11T20:21:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
+            target_frame = self._parameterNode.currentFrame
 
-                    startFrame = self.ui.startFrame.value
-                    endFrame = self.ui.endFrame.value
+            slicer.util.forceRenderAllViews()
 
-                    trackOnlyRoot = self.ui.onlyTrackRootNodeCheckBox.isChecked()
+            if source_frame < self._parameterNode.startFrame:
+                source_frame = "source"
 
-                    self.logic.registerSequence(CT, rootID, startFrame, endFrame, trackOnlyRoot)
-                finally:
-                    self.inProgress = False
-            slicer.util.messageBox("Success!")
-        self.updateApplyButtonState()
+            self._parameterNode.statusMsg = f"Registering bone '{self.currentBone.name}' in frame {target_frame}"
 
-    def onImportButton(self):
+            target_volume = AutoscoperMLogic.getItemInSequence(self._parameterNode.volumeSequence, target_frame)[0]
+            self.currentBone.setupFrame(target_frame, target_volume)
+
+            self.logic.registerBoneInFrame(
+                self.currentBone,
+                source_frame,
+                target_frame,
+                self._parameterNode.trackOnlyRoot
+            )
+
+            # If there is a next frame to register, propagate the transform generated from this frame
+            if target_frame != self._parameterNode.endFrame:
+                self.currentBone.copyTransformToNextFrame(target_frame)
+
+            # append all child bones to queue of bones to register next in this frame
+            if not self._parameterNode.trackOnlyRoot:
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: sbelsk
body: Will look into this. Is mc1 under rad or tpm in the hierarchy here?
created_at: 2025-03-11T20:25:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
+            target_frame = self._parameterNode.currentFrame
 
-                    startFrame = self.ui.startFrame.value
-                    endFrame = self.ui.endFrame.value
+            slicer.util.forceRenderAllViews()
 
-                    trackOnlyRoot = self.ui.onlyTrackRootNodeCheckBox.isChecked()
+            if source_frame < self._parameterNode.startFrame:
+                source_frame = "source"
 
-                    self.logic.registerSequence(CT, rootID, startFrame, endFrame, trackOnlyRoot)
-                finally:
-                    self.inProgress = False
-            slicer.util.messageBox("Success!")
-        self.updateApplyButtonState()
+            self._parameterNode.statusMsg = f"Registering bone '{self.currentBone.name}' in frame {target_frame}"
 
-    def onImportButton(self):
+            target_volume = AutoscoperMLogic.getItemInSequence(self._parameterNode.volumeSequence, target_frame)[0]
+            self.currentBone.setupFrame(target_frame, target_volume)
+
+            self.logic.registerBoneInFrame(
+                self.currentBone,
+                source_frame,
+                target_frame,
+                self._parameterNode.trackOnlyRoot
+            )
+
+            # If there is a next frame to register, propagate the transform generated from this frame
+            if target_frame != self._parameterNode.endFrame:
+                self.currentBone.copyTransformToNextFrame(target_frame)
+
+            # append all child bones to queue of bones to register next in this frame
+            if not self._parameterNode.trackOnlyRoot:
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: amymmorton
body: good question - I embedded a child or rad only - not a 3rd layer (i.e. not a child of tpm )
created_at: 2025-03-11T20:28:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,78 +207,196 @@ def initializeParameterNode(self) -> None:
         """
         self.setParameterNode(self.logic.getParameterNode())
 
-        if not self._parameterNode.inputVolumeSequence:
+        self._parameterNode.currentFrame = -1
+        self._parameterNode.currentBoneID = -1
+        self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.NOT_RUNNING
+
+        if not self._parameterNode.volumeSequence:
             firstSequenceNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSequenceNode")
             if firstSequenceNode:
-                self._parameterNode.inputVolumeSequence = firstSequenceNode
+                self._parameterNode.volumeSequence = firstSequenceNode
 
     def setParameterNode(self, inputParameterNode: Optional[Hierarchical3DRegistrationParameterNode]) -> 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.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
         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
+            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateRegistrationButtonsState)
+            self.updateRegistrationButtonsState()
+
+    def updateRegistrationButtonsState(self, _caller=None, _event=None):
+        """Sets the text and whether the buttons are enabled."""
+        # update the abort and initialize buttons
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+            self.ui.abortButton.enabled = False
+            self.ui.initializeButton.enabled = True
+        else:
+            self.ui.abortButton.enabled = True
+            self.ui.initializeButton.enabled = False
+
+        # update the register button
+        if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.IN_PROGRESS:
+            self.ui.registerButton.enabled = True
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.ui.registerButton.text = "Register"
             else:
-                self.ui.applyButton.text = "Cancel"
-                self.ui.applyButton.enabled = True
+                self.ui.registerButton.text = "Set Initial Guess And Register"
         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
+            self.ui.registerButton.enabled = False
+            self.ui.registerButton.text = "Set Initial Guess And Register"
+        # tooltip?: "Adjust the initial guess transform for the registration of this bone in the current frame."
+
         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()
+    def onInitializeButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            self._parameterNode.statusMsg = "Initializing registration process"
+            if self._parameterNode.runSatus != Hierarchical3DRegistrationRunStatus.NOT_RUNNING:
+                raise ValueError("Cannot initialize registration process, as one is already ongoing!")
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.INITIALIZING
+            self.updateRegistrationButtonsState()
+
+            # TODO: Remove this once this is working with the parameterNodeWrapper
+            #  see Slicer issue: https://github.com/Slicer/Slicer/issues/7905
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem()
+            if currentRootIDStatus == 0:
+                raise ValueError("Invalid hierarchy object selected!")
+            self._parameterNode.hierarchyRootID = currentRootIDStatus
+
+            if self._parameterNode.sourceVolume.GetID() == self._parameterNode.volumeSequence.GetID():
+                raise ValueError(f"The source volume must not be the same as the input sequence selected!")
+
+            self.rootBone = TreeNode(
+                hierarchyID=self._parameterNode.hierarchyRootID,
+                ctSequence=self._parameterNode.volumeSequence,
+                sourceVolume=self._parameterNode.sourceVolume,
+                isRoot=True,
+            )
+            self._parameterNode.currentFrame = self._parameterNode.startFrame
+            self.bonesToTrack = [self.rootBone]
+
+            self._parameterNode.runSatus = Hierarchical3DRegistrationRunStatus.IN_PROGRESS
+            self.updateRegistrationButtonsState()
+
+            if self._parameterNode.skipManualTfmAdjustments:
+                self.onRegisterButton()
+            else:
+                nextBone = self.bonesToTrack[0]
+                nextBone.startInteraction(self._parameterNode.currentFrame)
+                self._parameterNode.statusMsg = "Adjust the initial guess transform for the bone " \
+                                                    f"'{nextBone.name}' in frame {self._parameterNode.currentFrame}"
+                slicer.app.processEvents()
+
+    def onRegisterButton(self):
+        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
+            currentRootIDStatus = self.ui.SubjectHierarchyComboBox.currentItem() != 0
+            # TODO: Remove this once this is working with the parameterNodeWrapper.
+            #  It's currently commented out due to bug with parameter node, see
+            #  Slice issue: https://github.com/Slicer/Slicer/issues/7905
+            if not currentRootIDStatus:
+                raise ValueError("Invalid hierarchy object selected!")
+            if self._parameterNode.runSatus == Hierarchical3DRegistrationRunStatus.CANCELING:
+                raise ValueError("Canceling registration...")
+
+            self.currentBone = self.bonesToTrack.pop(0)
+            self._parameterNode.currentBoneID = -1  # TOOD: want this meaningfully saved to scene and re-imported later
+
+            self.currentBone.stopInteraction(self._parameterNode.currentFrame)
 
-                    CT = self.ui.inputSelectorCT.currentNode()
-                    rootID = self.ui.SubjectHierarchyComboBox.currentItem()
+            source_frame = self._parameterNode.currentFrame - 1
+            target_frame = self._parameterNode.currentFrame
 
-                    startFrame = self.ui.startFrame.value
-                    endFrame = self.ui.endFrame.value
+            slicer.util.forceRenderAllViews()
 
-                    trackOnlyRoot = self.ui.onlyTrackRootNodeCheckBox.isChecked()
+            if source_frame < self._parameterNode.startFrame:
+                source_frame = "source"
 
-                    self.logic.registerSequence(CT, rootID, startFrame, endFrame, trackOnlyRoot)
-                finally:
-                    self.inProgress = False
-            slicer.util.messageBox("Success!")
-        self.updateApplyButtonState()
+            self._parameterNode.statusMsg = f"Registering bone '{self.currentBone.name}' in frame {target_frame}"
 
-    def onImportButton(self):
+            target_volume = AutoscoperMLogic.getItemInSequence(self._parameterNode.volumeSequence, target_frame)[0]
+            self.currentBone.setupFrame(target_frame, target_volume)
+
+            self.logic.registerBoneInFrame(
+                self.currentBone,
+                source_frame,
+                target_frame,
+                self._parameterNode.trackOnlyRoot
+            )
+
+            # If there is a next frame to register, propagate the transform generated from this frame
+            if target_frame != self._parameterNode.endFrame:
+                self.currentBone.copyTransformToNextFrame(target_frame)
+
+            # append all child bones to queue of bones to register next in this frame
+            if not self._parameterNode.trackOnlyRoot:
path: Hierarchical3DRegistration/Hierarchical3DRegistration.py
user: login: sbelsk
body: Resolved in recent push.
created_at: 2025-03-14T15:24:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: amymmorton
created_at: 2025-03-11T19:36:02Z
body: In dark mode- prompt in blue cannot be read:
![image](https://github.com/user-attachments/assets/af9b939f-b2e8-4eeb-9828-81f26937e793)

author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-11T19:38:44Z
body: > In dark mode- prompt in blue cannot be read: ![image](https://private-user-images.githubusercontent.com/65619862/421544285-af9b939f-b2e8-4eeb-9828-81f26937e793.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDE3MjIwNjUsIm5iZiI6MTc0MTcyMTc2NSwicGF0aCI6Ii82NTYxOTg2Mi80MjE1NDQyODUtYWY5YjkzOWYtYjJlOC00ZWViLTk4MjgtODFmMjY5MzdlNzkzLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMTElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzExVDE5MzYwNVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWJkOGYyMTk0ZTEzMDRjYmY3ZDBhMzU3MmEwZDRlOTBiODEwMTY4YTMyOWRhNjk1MzM3MzE2OWRjOGZiOTFjYTMmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.tHkEcNjVg2VZqRbvHmXvY0ujJc61CIUKSfXmGnTMkBg)

@amymmorton  Good point, this blue label was the first draft of reporting a status to the user. What do you think would be the most effective way to convey the current step of the workflow in the UI?
author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-11T19:42:18Z
body: once rendered, transform node on model (treenode root index) works great!! Love the model outline in 2d views too - nice @sbelsk 

![image](https://github.com/user-attachments/assets/7645e74c-82eb-43a6-8862-8f1cfb284952)

author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-11T20:24:19Z
body: hit an elastix error when trying to register mc1 (final in treenode hierarchy) in frame 0
![image](https://github.com/user-attachments/assets/3722e80f-f149-4e9b-8bcc-386cd0f0519d)


 File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 328, in onRegisterButton
    self.logic.registerBoneInFrame(
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 651, in registerBoneInFrame
    sourceToTargetTfm = self.registerRigidBody(
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 608, in registerRigidBody
    elastixObj.UpdateLargestPossibleRegion()
  File "C:\Users\brown\AppData\Local\slicer.org\Slicer 5.8.1\lib\Python\Lib\site-packages\itk\ITKCommonBasePython.py", line 3115, in UpdateLargestPossibleRegion
    return _ITKCommonBasePython.itkProcessObject_UpdateLargestPossibleRegion(self)
RuntimeError: D:\a\im\build\cp39-cp39-win_amd64\_deps\elx-src\Core\Main\itkElastixRegistrationMethod.hxx:389:
ITK ERROR: ElastixRegistrationMethod(00000225636DEFA0): Internal elastix error: See elastix log (use LogToConsoleOn() or LogToFileOn()).

author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-11T20:27:45Z
body: > hit an elastix error when trying to register mc1 (final in treenode hierarchy) in frame 0 

@amymmorton With the current implementation, if the source volume is the same as frame 0, the expected registration result from elastix is an identity transform. When this is the case, I have noticed too that elastix fails.


author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-11T20:30:30Z
body: > > hit an elastix error when trying to register mc1 (final in treenode hierarchy) in frame 0
> 
> @amymmorton With the current implementation, if the source volume is the same as frame 0, the expected registration result from elastix is an identity transform. When this is the case, I have noticed too that elastix fails.

In this test case, frame0 of the volumeSequence was a distict and non-repeat of the src scalar volume- transform expected not to be identity
author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-11T20:38:36Z
body: > > > hit an elastix error when trying to register mc1 (final in treenode hierarchy) in frame 0
> > 
> > 
> > @amymmorton With the current implementation, if the source volume is the same as frame 0, the expected registration result from elastix is an identity transform. When this is the case, I have noticed too that elastix fails.
> 
> In this test case, frame0 of the volumeSequence was a distict and non-repeat of the src scalar volume- transform expected not to be identity

Hmm, then from my experience testing so far with wrist datasets, I noticed that the mc1 cropped source volume can have a relatively large ROI with most of the voxels not corresponding to bone or tissue (say, if the bone in the source is not parallel to any of the axes, and since the bone is long and narrow, the ROI will be big), which makes its registration more unstable (aka more sensitive to the adjustments to initial guess transform).
author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-11T20:40:35Z
body: > > In dark mode- prompt in blue cannot be read: ![image](https://private-user-images.githubusercontent.com/65619862/421544285-af9b939f-b2e8-4eeb-9828-81f26937e793.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDE3MjIwNjUsIm5iZiI6MTc0MTcyMTc2NSwicGF0aCI6Ii82NTYxOTg2Mi80MjE1NDQyODUtYWY5YjkzOWYtYjJlOC00ZWViLTk4MjgtODFmMjY5MzdlNzkzLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMTElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzExVDE5MzYwNVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWJkOGYyMTk0ZTEzMDRjYmY3ZDBhMzU3MmEwZDRlOTBiODEwMTY4YTMyOWRhNjk1MzM3MzE2OWRjOGZiOTFjYTMmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.tHkEcNjVg2VZqRbvHmXvY0ujJc61CIUKSfXmGnTMkBg)
> 
> @amymmorton Good point, this blue label was the first draft of reporting a status to the user. What do you think would be the most effective way to convey the current step of the workflow in the UI?

The timing and location are well placed .. can we just find a text color suitable to both themes?
author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-13T22:26:40Z
body: With the changes in https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/167/commits/1a66e5336e496f9c06bcfc277f94919a2ca09755, the status message should now look better both in light and dark mode. I also added a progress bar which I think helps to see how far along you are. 
| Light Mode | ![image](https://github.com/user-attachments/assets/d6ccc455-0962-4132-b5d8-1f7eb465bace) |
| - | - |
| Dark Mode | ![image](https://github.com/user-attachments/assets/5b5d0564-ce69-41a5-95aa-b1c82cb18cea) | 

author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-14T18:13:15Z
body: On initialize 

![image](https://github.com/user-attachments/assets/02dd4165-72d2-47ca-b862-d7f15ee13b04)

author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-18T13:05:19Z
body: Mismatch in progress, progress bar:
![image](https://github.com/user-attachments/assets/439cb422-0bbd-40bd-b483-008b1f6abd85)

Following the tpm registration, the mc1 interaction is live but displays as 32%?

Also - the transform propagation for the mc1 is incorrect
author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-18T13:09:47Z
body: on next frame, radius positioned, on register:
Traceback (most recent call last):
  File "C:\Users\brown\AppData\Local\slicer.org\Slicer 5.8.1\bin\Python\slicer\util.py", line 3303, in tryWithErrorDisplay
    yield
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 208, in tryWithErrorDisplayAndCleanup
    yield
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 353, in onRegisterButton
    self.doNextRegistrationStep()
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 374, in doNextRegistrationStep
    self.logic.registerBoneInFrame(self.currentBone, targetFrameIdx, self._parameterNode.trackOnlyRoot)
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 731, in registerBoneInFrame
    self.registerRigidBody(
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 680, in registerRigidBody
    elastixObj.UpdateLargestPossibleRegion()
  File "C:\Users\brown\AppData\Local\slicer.org\Slicer 5.8.1\lib\Python\Lib\site-packages\itk\ITKCommonBasePython.py", line 3115, in UpdateLargestPossibleRegion
    return _ITKCommonBasePython.itkProcessObject_UpdateLargestPossibleRegion(self)
RuntimeError: D:\a\im\build\cp39-cp39-win_amd64\_deps\elx-src\Core\Main\itkElastixRegistrationMethod.hxx:389:
ITK ERROR: ElastixRegistrationMethod(000001FA53581140): Internal elastix error: See elastix log (use LogToConsoleOn() or LogToFileOn()).


![image](https://github.com/user-attachments/assets/0a040b5a-f8a1-4c71-8472-37f6b63bdb1e)

author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-18T15:18:22Z
body: > Mismatch in progress: Following the tpm registration, the mc1 interaction is live but displays as 32%?

It looks like in the testing example there are 3 bones to register in 2 total frames. The progress counts that as 6 "registration steps" to be performed. After registering the rad and tpm bones in frame 0, that's 2/6 of the way done, which should be 33.33%, but instead it displays 32%. The progress bar saves its progress as an integer, so it rounds the result every time we increment it, which can result in a slight lag in the percentage. I added some logic to make sure that at the end of every frame it will catch back up to what it should be. Since the hierarchy is traversed recursively in each frame, it's a little more annoying to keep tab of the exact percentage instead of relying on the rounded number that progress bar itself saves, but we can definitely still do it. Would you prefer it to be more accurate?

> Also - the transform propagation for the mc1 is incorrect

Looking into this. I'm also looking into an oversight I made with the adjustment of the ROI when we detect it exceeds the target volume bounds (didn't take into account the transform applied to the ROI, which doesn't change its inherent bounds that we use in the calculation). 

author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-18T16:00:40Z
body: > > Mismatch in progress: Following the tpm registration, the mc1 interaction is live but displays as 32%?
> 
> It looks like in the testing example there are 3 bones to register in 2 total frames. The progress counts that as 6 "registration .... Since the hierarchy is traversed recursively in each frame, it's a little more annoying to keep tab of the exact percentage instead of relying on the rounded number that progress bar itself saves, but we can definitely still do it. Would you prefer it to be more accurate?


Didn't think about overall progress - nope- no need to modify - thanks for clarifying!


author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-18T16:19:29Z
body: 
> > Also - the transform propagation for the mc1 is incorrect
> 
> Looking into this. I'm also looking into an oversight I made with the adjustment of the ROI when we detect it exceeds the target volume bounds (didn't take into account the transform applied to the ROI, which doesn't change its inherent bounds that we use in the calculation).

This is only the case- if the mc1 is a child of the tpm (second tier in the heirarchy) 
The applied transform is nominal if the mc1 is a direct child of the root (rad)
![image](https://github.com/user-attachments/assets/5563eb05-0c9d-48f0-9adf-6e19088a14b1)

author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-18T22:55:08Z
body: @amymmorton I think I found (part of?) the issue where sometimes the elastix registration just fails. It comes down to the volume cropping with a rotated ROI. As it turns out, `vtkMRMLCropVolumeParametersNode` and `vtkSlicerCropVolumeLogic` like to crop based on axes-aligned regions, so it crops based on the min and max coordinates of the transformed ROI, not based on the the whole transformed geometry. This means that whenever we crop with an ROI that is rotated due to transformations from prior frames and/or propagated from parent bones in the hierarchy, the result cropped volume is actually larger than we want.

Example: trying to register the mc1 bone in subject BN00121 from source volume `1 neutral` to frame `5 extension`. This screenshot shows the ROI of the source volume and model manually aligned, and the resulting cropped target volume along with its ROI.

![image](https://github.com/user-attachments/assets/1ef38fbf-6c55-461d-a519-5b27b0c2a888)

The ratio of the volumes of the bounded regions is quite high, so that makes sense why elastix fails with the error:
```
itk::ExceptionObject (0000005A13AF82F8)
Location: "ElastixTemplate - Run()"
File: D:\a\im\build\cp39-cp39-win_amd64\_deps\elx-src\Common\CostFunctions\itkAdvancedImageToImageMetric.hxx
Line: 761
Description: ITK ERROR: AdvancedMattesMutualInformationMetric(000001A2D04F2D70): Too many samples map outside moving image buffer: 1105 / 5280
```
According to the docs, `vtkMRMLCropVolumeParametersNode` has a [method to specify a transform node](https://apidocs.slicer.org/v5.8/classvtkMRMLCropVolumeParametersNode.html#aa8368a936ca25b2229ce907dc8f61c7f), but in my testing it didn't seem to help. Will continue to investigate.
author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-19T13:57:11Z
body: Darn, but kinda makes sense.. cropping the target based on roi bounds does
lead to a size inconsistency

perhaps we add in the dilated mask ? such that even with the roi
inconsistency, the mask in that region will be cs independent

On Tue, Mar 18, 2025 at 6:55 PM Shelly Belsky ***@***.***>
wrote:

> @amymmorton <https://github.com/amymmorton> I think I found (part of?)
> the issue where sometimes the elastix registration just fails. It comes
> down to the volume cropping with a rotated ROI. As it turns out,
> vtkMRMLCropVolumeParametersNode and vtkSlicerCropVolumeLogic like to crop
> based on axes-aligned regions, so it crops based on the min and max
> coordinates of the transformed ROI, not based on the the whole transformed
> geometry. This means that whenever we crop with an ROI that is rotated due
> to transformations from prior frames and/or propagated from parent bones in
> the hierarchy, the result cropped volume is actually larger than we want.
>
> Example: trying to register the mc1 bone in subject BN00121 from source
> volume 1 neutral to frame 5 extension. This screenshot shows the ROI of
> the source volume and model manually aligned, and the resulting cropped
> target volume along with its ROI.
>
> image.png (view on web)
> <https://github.com/user-attachments/assets/1ef38fbf-6c55-461d-a519-5b27b0c2a888>
>
> The ratio of the volumes of the bounded regions is quite high, so that
> makes sense why elastix fails with the error:
>
> itk::ExceptionObject (0000005A13AF82F8)
> Location: "ElastixTemplate - Run()"
> File: D:\a\im\build\cp39-cp39-win_amd64\_deps\elx-src\Common\CostFunctions\itkAdvancedImageToImageMetric.hxx
> Line: 761
> Description: ITK ERROR: AdvancedMattesMutualInformationMetric(000001A2D04F2D70): Too many samples map outside moving image buffer: 1105 / 5280
>
> According to the docs, vtkMRMLCropVolumeParametersNode has a method to
> specify a transform node
> <https://apidocs.slicer.org/v5.8/classvtkMRMLCropVolumeParametersNode.html#aa8368a936ca25b2229ce907dc8f61c7f>,
> but it doesn't seem to help here. Will continue to investigate.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/167#issuecomment-2734910447>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/APUUPFVGVPIUY6F3UTQ3WFL2VCP6FAVCNFSM6AAAAABYHHFUWOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDOMZUHEYTANBUG4>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
> [image: sbelsk]*sbelsk* left a comment
> (BrownBiomechanics/SlicerAutoscoperM#167)
> <https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/167#issuecomment-2734910447>
>
> @amymmorton <https://github.com/amymmorton> I think I found (part of?)
> the issue where sometimes the elastix registration just fails. It comes
> down to the volume cropping with a rotated ROI. As it turns out,
> vtkMRMLCropVolumeParametersNode and vtkSlicerCropVolumeLogic like to crop
> based on axes-aligned regions, so it crops based on the min and max
> coordinates of the transformed ROI, not based on the the whole transformed
> geometry. This means that whenever we crop with an ROI that is rotated due
> to transformations from prior frames and/or propagated from parent bones in
> the hierarchy, the result cropped volume is actually larger than we want.
>
> Example: trying to register the mc1 bone in subject BN00121 from source
> volume 1 neutral to frame 5 extension. This screenshot shows the ROI of
> the source volume and model manually aligned, and the resulting cropped
> target volume along with its ROI.
>
> image.png (view on web)
> <https://github.com/user-attachments/assets/1ef38fbf-6c55-461d-a519-5b27b0c2a888>
>
> The ratio of the volumes of the bounded regions is quite high, so that
> makes sense why elastix fails with the error:
>
> itk::ExceptionObject (0000005A13AF82F8)
> Location: "ElastixTemplate - Run()"
> File: D:\a\im\build\cp39-cp39-win_amd64\_deps\elx-src\Common\CostFunctions\itkAdvancedImageToImageMetric.hxx
> Line: 761
> Description: ITK ERROR: AdvancedMattesMutualInformationMetric(000001A2D04F2D70): Too many samples map outside moving image buffer: 1105 / 5280
>
> According to the docs, vtkMRMLCropVolumeParametersNode has a method to
> specify a transform node
> <https://apidocs.slicer.org/v5.8/classvtkMRMLCropVolumeParametersNode.html#aa8368a936ca25b2229ce907dc8f61c7f>,
> but it doesn't seem to help here. Will continue to investigate.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/BrownBiomechanics/SlicerAutoscoperM/pull/167#issuecomment-2734910447>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/APUUPFVGVPIUY6F3UTQ3WFL2VCP6FAVCNFSM6AAAAABYHHFUWOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDOMZUHEYTANBUG4>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>


-- 
-----------

Amy Morton MSc
*Senior Research Engineer - Software*


*Brown University Healt**h*

Orthopaedic Research, 1 Hoppin Street, Coro West, 4th
Providence, RI 02903-4923


linkedin <https://www.linkedin.com/in/amy-morton-35374340/>

author_association: CONTRIBUTOR
user: login: sbelsk
created_at: 2025-03-19T20:06:31Z
body: > Darn, but kinda makes sense.. cropping the target based on roi bounds does lead to a size inconsistency perhaps we add in the dilated mask ? such that even with the roi inconsistency, the mask in that region will be cs independent

@amymmorton I couldn't really find the documentation for this, but I figured out that that by setting 
```python
cvpn.SetVoxelBased(False)
```
instead of `True` in the parameters for `vtkMRMLCropVolumeParametersNode` (plus fixing one small issue with our ROI creation), it performs our intended cropping, although it takes a little longer to calculate. I will push another commit soon to fix this as well as the check of the ROI bounds against the target volume, which faced a similar issue.
author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-24T14:15:42Z
body: @sbelsk  I reordered to todo for export transforms:
- [ ] revisit export of registration results (and import ) 
As when I attempt the test export :


Traceback (most recent call last):
  File "C:\Users\brown\AppData\Local\slicer.org\Slicer 5.8.1\bin\Python\slicer\util.py", line 3303, in tryWithErrorDisplay
    yield
  File "C:/Users/brown/OneDrive/Documents/GitHub/SlicerAutoscoperM/Hierarchical3DRegistration/Hierarchical3DRegistration.py", line 514, in onExportButton
    rootNode = TreeNode(hierarchyID=rootID, ctSequence=CT, isRoot=True)
TypeError: __init__() missing 1 required positional argument: 'sourceVolume'


![image](https://github.com/user-attachments/assets/5fa8d229-b0b2-4a8b-a09e-12020366015e)

That said results look fantastic, registration is fast and robust to imperfect initial alignment input

author_association: CONTRIBUTOR
user: login: amymmorton
created_at: 2025-03-24T20:38:28Z
body: Beautiful.

![image](https://github.com/user-attachments/assets/c9e488ea-af9c-4da3-9d8a-884ce815bf65)

author_association: CONTRIBUTOR