Overlaying Structures on CT Images in Axial, Coronal, and Sagittal Views

Hello everyone,

I am working on displaying CT images in the axial, coronal, and sagittal planes, along with dose maps and RTSTRUCT contours. While the CT and dose images are displayed correctly, I am having trouble overlaying the structures (contours) from the RTSTRUCT file on the CT images—the contours do not align properly with the CT, as shown in the attached image.

What steps should I take to ensure the contours are properly overlaid on the CT images in all three planes? Can you help me with this issue?

thanks a lot

my code:

import pydicom
import numpy as np
import matplotlib.pyplot as plt
import ipywidgets as widgets
from ipywidgets import interact, HBox, VBox, Output
import os
import SimpleITK as sitk
from rt_utils import RTStructBuilder
from scipy.ndimage import zoom
from skimage.measure import find_contours
import warnings
warnings.filterwarnings('ignore')

class MedicalImageViewer:
    def __init__(self, ct_path, dose_paths=None, rtstruct_path=None):
        """
        Initialize the medical image viewer.
        """
        self.ct_path = ct_path
        self.dose_paths = dose_paths or {}
        self.rtstruct_path = rtstruct_path
        
        # Data storage
        self.ct_volume = None
        self.ct_metadata = None
        self.dose_volumes = {}
        self.contours = {}
        self.roi_names = []
        
        # Display parameters
        self.window_level = (40, 400)
        self.dose_colormap = plt.cm.jet
        self.dose_alpha = 0.4
        
        # Orientation
        self.patient_position = 'HFS'
        
        self.load_all_data()
        
    def load_ct(self):
        """Load CT volume."""
        print("Loading CT images...")
        reader = sitk.ImageSeriesReader()
        dicom_names = reader.GetGDCMSeriesFileNames(self.ct_path)
        if not dicom_names: raise ValueError("No DICOM files found in CT path")
        
        reader.SetFileNames(dicom_names)
        ct_image = reader.Execute()
        
        array = sitk.GetArrayFromImage(ct_image)
        self.ct_volume = np.transpose(array, (1, 2, 0))
        
        first_slice = pydicom.dcmread(dicom_names[0])
        self.patient_position = first_slice.PatientPosition if hasattr(first_slice, 'PatientPosition') else 'HFS'
        self.ct_metadata = {
            'pixel_spacing': first_slice.PixelSpacing if hasattr(first_slice, 'PixelSpacing') else [1.0, 1.0],
            'slice_thickness': float(first_slice.SliceThickness) if hasattr(first_slice, 'SliceThickness') else 1.0
        }
        print(f"CT loaded: {self.ct_volume.shape}")
        
    def detect_sagittal_flip(self):
        """Detect if sagittal flip is needed."""
        return 'FF' in self.patient_position.upper()
        
    def load_doses(self):
        """Load dose volumes."""
        for dose_name, dose_path in self.dose_paths.items():
            print(f"Loading {dose_name} dose...")
            ds = pydicom.dcmread(dose_path)
            dose_array = ds.pixel_array.astype(np.float32)
            dose_volume = dose_array * float(ds.DoseGridScaling) if hasattr(ds, 'DoseGridScaling') else dose_array
            dose_volume = np.transpose(dose_volume, (1, 2, 0))
            if dose_volume.shape != self.ct_volume.shape:
                zoom_factors = [self.ct_volume.shape[i] / dose_volume.shape[i] for i in range(3)]
                dose_volume = zoom(dose_volume, zoom_factors, order=1)
            self.dose_volumes[dose_name] = dose_volume
            print(f"{dose_name} dose loaded: {dose_volume.shape}")
        
    def load_rtstruct(self):
        """Load RTSTRUCT masks."""
        if not self.rtstruct_path or not os.path.exists(self.rtstruct_path): return
        print("Loading RTSTRUCT...")
        rtstruct = RTStructBuilder.create_from(dicom_series_path=self.ct_path, rt_struct_path=self.rtstruct_path)
        self.roi_names = rtstruct.get_roi_names()
        colors = plt.cm.rainbow(np.linspace(0, 1, len(self.roi_names)))
        for i, roi_name in enumerate(self.roi_names):
            mask = rtstruct.get_roi_mask_by_name(roi_name)
            mask = np.transpose(mask, (1, 0, 2))
            self.contours[roi_name] = {'mask': mask, 'color': colors[i]}
        print(f"Loaded {len(self.contours)} ROIs")
        
    def load_all_data(self):
        """Load all data."""
        self.load_ct()
        self.load_doses()
        self.load_rtstruct()
        
    def apply_window_level(self, image, window, level):
        """Apply window/level to CT."""
        img_min = level - window / 2
        img_max = level + window / 2
        windowed = np.clip(image, img_min, img_max)
        return (windowed - img_min) / (img_max - img_min)
    
    def create_interactive_viewer(self):
        """Create the fully interactive viewe."""
        ct_shape = self.ct_volume.shape
        pixel_spacing = self.ct_metadata['pixel_spacing']
        slice_thickness = self.ct_metadata['slice_thickness']
        
        axial_aspect = pixel_spacing[1] / pixel_spacing[0]
        sagittal_aspect = slice_thickness / pixel_spacing[1]
        coronal_aspect = slice_thickness / pixel_spacing[0]
        
        sagittal_flip = self.detect_sagittal_flip()
        
        output = Output()
        
        # --- Sliders
![ct and rt structure|690x217, 75%](upload://zJfDRspRrJhKPyexsKPl9qxqs06.png)
 ---
        axial_slider = widgets.IntSlider(min=0, max=ct_shape[2]-1, value=ct_shape[2]//2, description='Axial (Z):', continuous_update=False)
        coronal_slider = widgets.IntSlider(min=0, max=ct_shape[0]-1, value=ct_shape[0]//2, description='Coronal (Y):', continuous_update=False)
        sagittal_slider = widgets.IntSlider(min=0, max=ct_shape[1]-1, value=ct_shape[1]//2, description='Sagittal (X):', continuous_update=False)
        
        window_slider = widgets.IntSlider(min=1, max=2000, value=self.window_level[1], description='Window:')
        level_slider = widgets.IntSlider(min=-1000, max=1000, value=self.window_level[0], description='Level:')
        dose_selector = widgets.Dropdown(options=['None'] + list(self.dose_volumes.keys()), value='None', description='Dose:')
        dose_alpha_slider = widgets.FloatSlider(min=0, max=1, value=self.dose_alpha, step=0.1, description='Opacity:')
        roi_selector = widgets.SelectMultiple(options=self.roi_names, value=[], description='ROIs:', rows=5)
        
        
        def update_display(axial_idx, coronal_idx, sagittal_idx, window, level, dose_name, dose_alpha, selected_rois):
            with output:
                output.clear_output(wait=True)
                fig, axes = plt.subplots(1, 3, figsize=(18, 6))
                ax_axial, ax_coronal, ax_sagittal = axes # Correct plot order
                
                # --- Axial View ---
                axial_ct = np.flipud(self.ct_volume[:, :, axial_idx])
                axial_ct = self.apply_window_level(axial_ct, window, level)
                ax_axial.imshow(axial_ct, cmap='gray', aspect=axial_aspect, origin='lower')
                ax_axial.set_title(f'Axial (Z = {axial_idx})')
                
                # --- Coronal View ---
                coronal_ct = np.flipud(self.ct_volume[coronal_idx, :, :].T[::-1])
                coronal_ct = self.apply_window_level(coronal_ct, window, level)
                ax_coronal.imshow(coronal_ct, cmap='gray', aspect=coronal_aspect, origin='lower')
                ax_coronal.set_title(f'Coronal (Y = {coronal_idx})')
                
                # --- Sagittal View ---
                sagittal_ct = self.ct_volume[:, sagittal_idx, :].T
                if sagittal_flip: sagittal_ct = np.flipud(sagittal_ct)
                else: sagittal_ct = np.fliplr(sagittal_ct)
                sagittal_ct = self.apply_window_level(sagittal_ct, window, level)
                ax_sagittal.imshow(sagittal_ct, cmap='gray', aspect=sagittal_aspect, origin='lower')
                ax_sagittal.set_title(f'Sagittal (X = {sagittal_idx})')
                
                # --- Dose Overlay ---
                if dose_name != 'None' and dose_name in self.dose_volumes:
                    dose_vol = self.dose_volumes[dose_name]
                    max_dose = np.max(dose_vol)
                    
                    dose_axial = np.flipud(dose_vol[:, :, axial_idx])
                    im = ax_axial.imshow(np.ma.masked_equal(dose_axial, 0), cmap=self.dose_colormap, alpha=dose_alpha, vmin=0, vmax=max_dose, aspect=axial_aspect, origin='lower')
                    
                    dose_coronal = np.flipud(dose_vol[coronal_idx, :, :].T[::-1])
                    ax_coronal.imshow(np.ma.masked_equal(dose_coronal, 0), cmap=self.dose_colormap, alpha=dose_alpha, vmin=0, vmax=max_dose, aspect=coronal_aspect, origin='lower')
                    
                    dose_sagittal = dose_vol[:, sagittal_idx, :].T
                    if sagittal_flip: dose_sagittal = np.flipud(dose_sagittal)
                    else: dose_sagittal = np.fliplr(dose_sagittal)
                    ax_sagittal.imshow(np.ma.masked_equal(dose_sagittal, 0), cmap=self.dose_colormap, alpha=dose_alpha, vmin=0, vmax=max_dose, aspect=sagittal_aspect, origin='lower')
                    plt.colorbar(im, ax=axes, fraction=0.046, pad=0.04, label='Dose (GY)')
                
                # --- Structure Overlay ---
                for roi_name in selected_rois:
                    if roi_name in self.contours:
                        mask = self.contours[roi_name]['mask']
                        color = self.contours[roi_name]['color']
                        
                        # Axial Structure
                        axial_mask = np.flipud(mask[:, :, axial_idx])
                        contours_found = find_contours(axial_mask, 0.5)
                        for c in contours_found: ax_axial.plot(c[:, 1], c[:, 0], color=color, linewidth=1.5)
                        
                        # Coronal Structure
                        coronal_mask = np.flipud(mask[coronal_idx, :, :].T[::-1])
                        contours_found = find_contours(coronal_mask, 0.5)
                        for c in contours_found: ax_coronal.plot(c[:, 1], c[:, 0], color=color, linewidth=1.5)
                        
                        # Sagittal Structure
                        sagittal_mask = mask[:, sagittal_idx, :].T
                        if sagittal_flip: sagittal_mask = np.flipud(sagittal_mask)
                        else: sagittal_mask = np.fliplr(sagittal_mask)
                        contours_found = find_contours(sagittal_mask, 0.5)
                        for c in contours_found: ax_sagittal.plot(c[:, 1], c[:, 0], color=color, linewidth=1.5)

                for ax in axes: ax.axis('off')
                plt.tight_layout()
                plt.show()

        # --- Linking sliders to the correct function arguments ---
        interact(update_display, axial_idx=axial_slider, coronal_idx=coronal_slider, sagittal_idx=sagittal_slider,
                 window=window_slider, level=level_slider, dose_name=dose_selector, dose_alpha=dose_alpha_slider, selected_rois=roi_selector)
        display(output)


# directories
if __name__ == "__main__":
    ct_path = r'D:\TEST\CT'
    dose_paths = {
        'Physical': r'D:\TEST\RTDOSE\RTDOSE_Dose.dcm',
    
    }
    rtstruct_path = r'D:\TEST\RTSTRUCT\RTSTRUCT.dcm'
    
    viewer = MedicalImageViewer(ct_path=ct_path, dose_paths=dose_paths, rtstruct_path=rtstruct_path)
    viewer.create_interactive_viewer()

If you load these images in 3D Slicer, do they line up correctly?

Yes, these images are correctly displayed in 3D Slicer.

np.transpose(volume, (1, 2, 0)) - this looks so wrong! But you do it to all 3 of them, so that should not be the cause. As matplotlib does not respect direction matrix, I assume that RTStruct has it different than the other two? If you standardize the anatomical orientation of all 3 images using OrientImageFilter as per this blog post, does it solve the issue? You can choose whichever orientation you want, so you will not need `np.transpose`.