Synchronization of Axial, Coronal, and Sagittal slices by clicking on a pixel from one of the views

Hello Dear Developers and Users

To draw ROI, it is needed that three views axial, coronal, and sagittal be synchronize together. This means, for example, by clicking on one certain pixel in sagittal slice, the slices shown in axial and coronal display windows also pass through that pixel in orthogonal way.

To activate this synchronization, the commands below must be copy and paste in Python Interactor.

import slicer

from slicer.util import VTKObservationMixin

import vtk

import numpy as np

class SliceClickUpdater(VTKObservationMixin):

def _init_(self):

VTKObservationMixin._init_(self)

# Define slice nodes with their expected orientations

self.sliceNodes = {

“Red”: {“node”: slicer.mrmlScene.GetNodeByID(“vtkMRMLSliceNodeRed”), “orientation”: “Axial”},

“Yellow”: {“node”: slicer.mrmlScene.GetNodeByID(“vtkMRMLSliceNodeYellow”), “orientation”: “Coronal”},

“Green”: {“node”: slicer.mrmlScene.GetNodeByID(“vtkMRMLSliceNodeGreen”), “orientation”: “Sagittal”}

}

# Validate slice nodes

for color, info in self.sliceNodes.items():

if info[“node”] is None:

print(f"Error: Slice node for {color} not found in the scene.")

continue

# Ensure correct orientation

current_orientation = info[“node”].GetOrientation()

expected_orientation = info[“orientation”]

if current_orientation != expected_orientation:

print(f"Setting {color} slice node orientation to {expected_orientation} (was {current_orientation})")

info[“node”].SetOrientation(expected_orientation)

# Ensure slice is visible

info[“node”].SetSliceVisible(1)

info[“node”].SetWidgetVisible(1)

# Validate and link volume to slice nodes via composite nodes

self.volumeNode = slicer.mrmlScene.GetFirstNodeByClass(“vtkMRMLScalarVolumeNode”)

if self.volumeNode is None:

print(“Error: No volume node found in the scene. Please load a volume.”)

return

if self.volumeNode.GetImageData() is None:

print(f"Error: Volume node {self.volumeNode.GetName()} has no image data.")

return

# Get volume bounds in RAS coordinates

self.volumeBounds = self.getVolumeBounds()

# Find composite nodes for each slice view

compositeNodes = slicer.mrmlScene.GetNodesByClass(“vtkMRMLSliceCompositeNode”)

self.compositeNodeMap = {}

for color, info in self.sliceNodes.items():

if info[“node”] is None:

continue

sliceNodeID = info[“node”].GetID()

compositeNode = None

for i in range(compositeNodes.GetNumberOfItems()):

node = compositeNodes.GetItemAsObject(i)

if node.GetLayoutName() == color:

compositeNode = node

break

if compositeNode is None:

print(f"Error: Composite node for {color} slice view not found.")

continue

# Link volume to the slice view

print(f"Linking {color} slice node to volume {self.volumeNode.GetName()} via composite node")

compositeNode.SetBackgroundVolumeID(self.volumeNode.GetID())

compositeNode.SetForegroundVolumeID(None) # Ensure no foreground volume interferes

compositeNode.SetLinkedControl(0) # Disable linked control to prevent automatic updates

self.compositeNodeMap[color] = compositeNode

# Reset field of view for all slice views

slicer.util.resetSliceViews()

# Get the crosshair node

self.crosshairNode = slicer.mrmlScene.GetFirstNodeByClass(“vtkMRMLCrosshairNode”)

if self.crosshairNode is None:

print(“Error: Crosshair node not found in the scene.”)

return

# Add observers for interaction events on each slice view

for color, info in self.sliceNodes.items():

if info[“node”] is None or color not in self.compositeNodeMap:

continue

# Access the render window via the layout manager

layoutManager = slicer.app.layoutManager()

sliceWidget = layoutManager.sliceWidget(color)

if sliceWidget is None:

print(f"Error: Slice widget for {color} not found.")

continue

renderWindow = sliceWidget.sliceView().renderWindow()

interactor = renderWindow.GetInteractor()

if interactor:

self.addObserver(interactor, vtk.vtkCommand.LeftButtonPressEvent, lambda caller, event, c=color: self.onSliceViewClicked(caller, event, c))

print(f"Added click observer for {color} slice view")

print(“SliceClickUpdater initialized successfully.”)

def getVolumeBounds(self):

“”“Get the RAS bounds of the volume.”“”

imageData = self.volumeNode.GetImageData()

if not imageData:

return None

# Get volume dimensions and spacing

dimensions = imageData.GetDimensions()

spacing = imageData.GetSpacing()

# Get IJK-to-RAS transformation matrix

ijkToRas = vtk.vtkMatrix4x4()

self.volumeNode.GetIJKToRASMatrix(ijkToRas)

# Compute bounds in RAS coordinates

rasBounds = [0, 0, 0, 0, 0, 0]

corners = [

[0, 0, 0], [dimensions[0] - 1, 0, 0], [0, dimensions[1] - 1, 0], [dimensions[0] - 1, dimensions[1] - 1, 0],

[0, 0, dimensions[2] - 1], [dimensions[0] - 1, 0, dimensions[2] - 1], [0, dimensions[1] - 1, dimensions[2] - 1], [dimensions[0] - 1, dimensions[1] - 1, dimensions[2] - 1]

]

for corner in corners:

rasPoint = [0, 0, 0, 1]

ijkToRas.MultiplyPoint([corner[0], corner[1], corner[2], 1], rasPoint)

for i in range(3):

rasBounds[2*i] = min(rasBounds[2*i], rasPoint[i])

rasBounds[2*i+1] = max(rasBounds[2*i+1], rasPoint[i])

print(f"Volume RAS bounds: R=[{rasBounds[0]}, {rasBounds[1]}], A=[{rasBounds[2]}, {rasBounds[3]}], S=[{rasBounds[4]}, {rasBounds[5]}]")

return rasBounds

def clampOffset(self, offset, orientation):

“”“Clamp the slice offset to the volume’s bounds.”“”

if not self.volumeBounds:

return offset

rMin, rMax, aMin, aMax, sMin, sMax = self.volumeBounds

if orientation == “Axial”:

return max(sMin, min(sMax, offset)) # S axis for Axial

elif orientation == “Coronal”:

return max(aMin, min(aMax, offset)) # A axis for Coronal

elif orientation == “Sagittal”:

# Use -offset to convert R to L (Left = -R in RAS)

lOffset = -offset

# Clamp to the negative of R bounds (L = -R)

clamped_offset = max(-rMax, min(-rMin, lOffset))

if lOffset < -rMax or lOffset > -rMin:

center = (-rMax - rMin) / 2

print(f"Invalid L offset {lOffset} (R={offset}) for Sagittal, using center {center}")

return center

return clamped_offset

return offset

def onSliceViewClicked(self, caller, event, color):

# Handle left-button click in the specified slice view

print(f"Left-button click detected in {color} slice view")

# Get the coordinates of the clicked point in the RAS system

ras = [0, 0, 0]

self.crosshairNode.GetCursorPositionRAS(ras)

r, a, s = ras

print(f"Click at RAS: R={r}, A={a}, S={s}")

# Update the other two slice views

for other_color, info in self.sliceNodes.items():

if other_color == color or info[“node”] is None:

continue # Skip the clicked view and any missing nodes

sliceNode = info[“node”]

sliceToRas = sliceNode.GetSliceToRAS()

orientation = sliceNode.GetOrientation()

# Set the slice based on RAS coordinates, clamped to volume bounds

if orientation == “Axial”:

clamped_offset = self.clampOffset(s, orientation)

sliceNode.SetSliceOffset(clamped_offset) # S axis for Axial view

print(f"Updated {other_color} (Axial) slice offset to {clamped_offset}")

elif orientation == “Coronal”:

clamped_offset = self.clampOffset(a, orientation)

sliceNode.SetSliceOffset(clamped_offset) # A axis for Coronal view

print(f"Updated {other_color} (Coronal) slice offset to {clamped_offset}")

elif orientation == “Sagittal”:

clamped_offset = self.clampOffset(r, orientation) # Use -R for Sagittal (L = -R)

sliceNode.SetSliceOffset(clamped_offset) # R axis for Sagittal view

print(f"Updated {other_color} (Sagittal) slice offset to {clamped_offset} (L={clamped_offset})")

else:

print(f"Unknown orientation {orientation} for {other_color} slice node")

sliceNode.UpdateMatrices()

sliceNode.SetSliceVisible(1)

sliceNode.SetWidgetVisible(1)

def removeObservers(self):

# Remove observers to deactivate the script

for color, info in self.sliceNodes.items():

if info[“node”] is None or color not in self.compositeNodeMap:

continue

layoutManager = slicer.app.layoutManager()

sliceWidget = layoutManager.sliceWidget(color)

if sliceWidget is None:

continue

renderWindow = sliceWidget.sliceView().renderWindow()

interactor = renderWindow.GetInteractor()

if interactor:

self.removeObserver(interactor, vtk.vtkCommand.LeftButtonPressEvent, lambda caller, event, c=color: self.onSliceViewClicked(caller, event, c))

print(“Observers removed.”)

# Activate the script

updater = SliceClickUpdater()

# To deactivate the script (if needed):

# updater.removeObservers()

After executing these commands, this synchronization of slices will occur.

Also, a clip has been prepared for showing this feature.

‌Best regards.

Shahrokh

If you hold down the Shift key while moving the mouse over a slice view, the other slice views will change to follow the point in such a way that each view contains the point under the mouse. https://slicer.readthedocs.io/en/latest/user_guide/user_interface.html

You can control whether the other slice views center on this point or simply scroll to contain that point without centering in the dropdown menu next to the crosshair icon in the toolbar (choose “Jump slices - centered” or “Jump slices -offset”).

1 Like