Question:
I’m developing a DICOM viewer application using PyQt5 that includes multiplanar reconstruction (MPR) similar to OsiriX/Horos. However, I’m encountering an issue where the images appear slightly stretched horizontally, when comparing with Horos images. Despite using pixel spacing metadata, the images still don’t match the expected display behavior seen in OsiriX/Horos.
Here is an example (left Horos, right mine): example
Code Overview:
I load DICOM images using pydicom and store them in a 3D NumPy array. The images are displayed using Matplotlib integrated within a PyQt5 interface. I also adjust the aspect ratios based on pixel spacing.
Here’s the relevant part of my code for doing MPR:
def show_mpr_images(self):
try:
if not self.series_images.any():
return
# Calculate aspect ratios for accurate MPR (including axial)
aspect_ratio_axial = self.pixel_spacing[1] / self.pixel_spacing[0]
aspect_ratio_sagittal = self.series_images.shape[0] / self.series_images.shape[2] * aspect_ratio_axial
aspect_ratio_coronal = self.series_images.shape[0] / self.series_images.shape[1] * aspect_ratio_axial
axial_slice = self.series_images[self.current_axial_index]
self.axial_ax.clear()
self.axial_ax.imshow(axial_slice, cmap='gray', aspect=aspect_ratio_axial)
sagittal_slice = self.series_images[:, self.current_sagittal_index, :]
self.sagittal_ax.clear()
self.sagittal_ax.imshow(sagittal_slice, cmap='gray', aspect=aspect_ratio_sagittal)
coronal_slice = self.series_images[:, :, self.current_coronal_index]
self.coronal_ax.clear()
self.coronal_ax.imshow(coronal_slice, cmap='gray', aspect=aspect_ratio_coronal)
# Redraw the canvases
self.axial_canvas.draw()
self.sagittal_canvas.draw()
self.coronal_canvas.draw()
except Exception as e:
logging.error(f"Error showing MPR images: {e}")
And here’s the full code:
import sys
import os
import logging
from PyQt5 import QtWidgets, uic, QtGui, QtCore
import pydicom
import numpy as np
from PyQt5.QtWidgets import QFileDialog, QVBoxLayout, QListWidgetItem, QScrollBar
from PyQt5.QtCore import Qt, QTimer
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
class DICOMViewer(QtWidgets.QMainWindow):
def __init__(self):
super(DICOMViewer, self).__init__()
uic.loadUi('main.ui', self)
self.dicom_series = {}
self.current_series = None
self.current_index = 0
self.current_axial_index = 0
self.current_sagittal_index = 0
self.current_coronal_index = 0
self.scroll_accumulator = 0
self.scroll_threshold = 240 # Adjust this for trackpad sensitivity
self.image_cache = {}
self.debounce_timer = QTimer()
self.debounce_timer.setSingleShot(True)
self.debounce_timer.timeout.connect(self.process_scroll)
self.loadButton.clicked.connect(self.load_dicom_folder)
self.seriesList.itemClicked.connect(self.series_selected)
self.prevButton.clicked.connect(self.show_previous_image)
self.nextButton.clicked.connect(self.show_next_image)
self.mprButton.clicked.connect(self.activate_mpr)
# Connect the scroll sensitivity slider
self.scrollSensitivitySlider.valueChanged.connect(self.update_scroll_threshold)
self.scrollSensitivitySlider.setValue(self.scroll_threshold)
# Setup the plot layouts for displaying images in three planes
self.axial_canvas = FigureCanvas(Figure())
self.axial_layout = QVBoxLayout(self.axialContainer)
self.axial_layout.addWidget(self.axial_canvas)
self.axial_ax = self.axial_canvas.figure.add_subplot(111)
self.axial_ax.axis('off') # Hide the axis
self.sagittal_canvas = FigureCanvas(Figure())
self.sagittal_layout = QVBoxLayout(self.sagittalContainer)
self.sagittal_layout.addWidget(self.sagittal_canvas)
self.sagittal_ax = self.sagittal_canvas.figure.add_subplot(111)
self.sagittal_ax.axis('off') # Hide the axis
self.coronal_canvas = FigureCanvas(Figure())
self.coronal_layout = QVBoxLayout(self.coronalContainer)
self.coronal_layout.addWidget(self.coronal_canvas)
self.coronal_ax = self.coronal_canvas.figure.add_subplot(111)
self.coronal_ax.axis('off') # Hide the axis
# Connect the vertical scroll bars
self.axialScrollBar.valueChanged.connect(self.axial_scroll_bar_moved)
self.sagittalScrollBar.valueChanged.connect(self.sagittal_scroll_bar_moved)
self.coronalScrollBar.valueChanged.connect(self.coronal_scroll_bar_moved)
# Set smaller icon size for the series list
self.seriesList.setIconSize(QtCore.QSize(48, 48)) # Adjust this size as needed
# Initialize the 3D numpy array to hold the series images
self.series_images = np.array([]) # Initialize as an empty NumPy array
self.pixel_spacing = None # Placeholder for pixel spacing
def load_dicom_folder(self):
try:
folder_path = QFileDialog.getExistingDirectory(self, "Select DICOM Folder")
if folder_path:
logging.debug(f"Loading DICOM folder: {folder_path}")
self.dicom_series = self.get_dicom_series(folder_path)
self.seriesList.clear()
self.image_cache.clear() # Clear the cache when loading a new folder
total_series = len(self.dicom_series)
logging.debug(f"Total series found: {total_series}")
for idx, (series_uid, file_paths) in enumerate(self.dicom_series.items(), start=1):
num_images = len(file_paths)
series_description = self.get_series_description(file_paths[0])
item_text = f"Series {idx}/{total_series} - {num_images} images"
if series_description:
item_text += f", {series_description}"
thumbnail = self.generate_thumbnail(file_paths[num_images // 2])
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, series_uid)
if thumbnail:
icon = QtGui.QIcon(QtGui.QPixmap.fromImage(thumbnail))
item.setIcon(icon)
self.seriesList.addItem(item)
if self.seriesList.count() > 0:
self.seriesList.setCurrentRow(0)
self.series_selected(self.seriesList.item(0))
except Exception as e:
logging.error(f"Error loading DICOM folder: {e}")
def get_dicom_series(self, folder_path):
series_dict = {}
try:
for root, _, files in os.walk(folder_path):
for file in files:
if file.endswith('.dcm'):
file_path = os.path.join(root, file)
try:
dicom_data = pydicom.dcmread(file_path)
series_uid = dicom_data.SeriesInstanceUID
instance_number = dicom_data.InstanceNumber
if series_uid not in series_dict:
series_dict[series_uid] = []
series_dict[series_uid].append((instance_number, file_path))
except Exception as e:
logging.error(f"Error reading DICOM file {file_path}: {e}")
# Sort each series by instance number
for series_uid in series_dict:
series_dict[series_uid].sort(key=lambda x: x[0])
series_dict[series_uid] = [x[1] for x in series_dict[series_uid]]
except Exception as e:
logging.error(f"Error getting DICOM series: {e}")
return series_dict
def get_series_description(self, file_path):
try:
dicom_data = pydicom.dcmread(file_path)
if hasattr(dicom_data, 'SeriesDescription'):
return dicom_data.SeriesDescription
except Exception as e:
logging.error(f"Error reading series description from {file_path}: {e}")
return None
def generate_thumbnail(self, file_path):
try:
dicom_data = pydicom.dcmread(file_path)
image_data = dicom_data.pixel_array
# Convert image to QImage
image = QtGui.QImage(image_data.data, image_data.shape[1], image_data.shape[0], image_data.strides[0], QtGui.QImage.Format_Grayscale8)
thumbnail = image.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation) # Adjust size here
return thumbnail
except Exception as e:
logging.error(f"Error generating thumbnail for {file_path}: {e}")
return None
def series_selected(self, item):
try:
series_uid = item.data(Qt.UserRole)
self.current_series = self.dicom_series[series_uid]
self.current_index = 0
if self.current_series:
self.update_window_title()
self.load_series_images(self.current_series)
# Update scrollbar ranges
self.axialScrollBar.setRange(0, len(self.current_series) - 1)
self.sagittalScrollBar.setRange(0, self.series_images.shape[1] - 1)
self.coronalScrollBar.setRange(0, self.series_images.shape[2] - 1)
self.show_mpr_images()
except Exception as e:
logging.error(f"Error selecting series: {e}")
def update_window_title(self):
try:
series_uid = self.seriesList.currentItem().data(Qt.UserRole)
num_images = len(self.current_series)
series_description = self.get_series_description(self.current_series[0])
title = f"Series {series_uid} - {num_images} images"
if series_description:
title += f", {series_description}"
self.setWindowTitle(title)
except Exception as e:
logging.error(f"Error updating window title: {e}")
def load_series_images(self, series):
try:
images = []
for file_path in series:
dicom_data = pydicom.dcmread(file_path)
images.append(dicom_data.pixel_array)
# Get pixel spacing (only needs to be done once per series)
if self.pixel_spacing is None:
self.pixel_spacing = dicom_data.PixelSpacing
self.series_images = np.array(images)
self.show_mpr_images()
except Exception as e:
logging.error(f"Error loading series images: {e}")
def show_mpr_images(self):
try:
if not self.series_images.any():
return
# Calculate aspect ratios for accurate MPR (including axial)
aspect_ratio_axial = self.pixel_spacing[1] / self.pixel_spacing[0]
aspect_ratio_sagittal = self.series_images.shape[0] / self.series_images.shape[2] * aspect_ratio_axial
aspect_ratio_coronal = self.series_images.shape[0] / self.series_images.shape[1] * aspect_ratio_axial
axial_slice = self.series_images[self.current_axial_index]
self.axial_ax.clear()
self.axial_ax.imshow(axial_slice, cmap='gray', aspect=aspect_ratio_axial)
sagittal_slice = self.series_images[:, self.current_sagittal_index, :]
self.sagittal_ax.clear()
self.sagittal_ax.imshow(sagittal_slice, cmap='gray', aspect=aspect_ratio_sagittal)
coronal_slice = self.series_images[:, :, self.current_coronal_index]
self.coronal_ax.clear()
self.coronal_ax.imshow(coronal_slice, cmap='gray', aspect=aspect_ratio_coronal)
# Redraw the canvases
self.axial_canvas.draw()
self.sagittal_canvas.draw()
self.coronal_canvas.draw()
except Exception as e:
logging.error(f"Error showing MPR images: {e}")
def show_image(self, file_path):
try:
if file_path in self.image_cache:
image_data = self.image_cache[file_path]
else:
dicom_data = pydicom.dcmread(file_path)
image_data = dicom_data.pixel_array
self.image_cache[file_path] = image_data
self.axial_ax.clear()
self.axial_ax.imshow(image_data, cmap='gray', aspect='auto')
self.axial_ax.axis('off') # Hide the axis
# Use tight_layout to ensure the image fits within the available space
self.axial_canvas.figure.tight_layout()
self.axial_canvas.draw()
except Exception as e:
logging.error(f"Error showing image {file_path}: {e}")
def show_previous_image(self):
try:
if self.current_series and self.current_index > 0:
self.current_index -= 1
self.show_image(self.current_series[self.current_index])
self.axialScrollBar.setValue(self.current_index)
self.show_mpr_images()
except Exception as e:
logging.error(f"Error showing previous image: {e}")
def show_next_image(self):
try:
if self.current_series and self.current_index < len(self.current_series) - 1:
self.current_index += 1
self.show_image(self.current_series[self.current_index])
self.axialScrollBar.setValue(self.current_index)
self.show_mpr_images()
except Exception as e:
logging.error(f"Error showing next image: {e}")
def update_scroll_threshold(self, value):
self.scroll_threshold = value
def wheelEvent(self, event):
if self.current_series:
delta = event.angleDelta().y()
self.scroll_accumulator += delta
logging.debug(f"Scroll Accumulator: {self.scroll_accumulator}")
if not self.debounce_timer.isActive():
self.debounce_timer.start(20) # Shorter delay for smoother trackpad scrolling
def process_scroll(self):
try:
steps = self.scroll_accumulator // self.scroll_threshold
logging.debug(f"Processing Scroll - Steps: {steps}")
if steps != 0:
self.scroll_accumulator = 0
if self.axial_canvas.underMouse():
new_index = self.current_axial_index - steps
new_index = max(0, min(new_index, len(self.current_series) - 1))
if new_index != self.current_axial_index:
self.current_axial_index = new_index
self.axialScrollBar.setValue(self.current_axial_index)
elif self.sagittal_canvas.underMouse():
new_index = self.current_sagittal_index - steps
new_index = max(0, min(new_index, self.series_images.shape[2] - 1))
if new_index != self.current_sagittal_index:
self.current_sagittal_index = new_index
self.sagittalScrollBar.setValue(self.current_sagittal_index)
elif self.coronal_canvas.underMouse():
new_index = self.current_coronal_index - steps
new_index = max(0, min(new_index, self.series_images.shape[1] - 1))
if new_index != self.current_coronal_index:
self.current_coronal_index = new_index
self.coronalScrollBar.setValue(self.current_coronal_index)
self.show_mpr_images()
except Exception as e:
logging.error(f"Error processing scroll: {e}")
def axial_scroll_bar_moved(self, value):
try:
self.current_axial_index = value
self.show_mpr_images()
except Exception as e:
logging.error(f"Error moving axial scroll bar: {e}")
def sagittal_scroll_bar_moved(self, value): # Updated for correct index
try:
self.current_sagittal_index = value
self.show_mpr_images()
except Exception as e:
logging.error(f"Error moving sagittal scroll bar: {e}")
def coronal_scroll_bar_moved(self, value): # Updated for correct index
try:
self.current_coronal_index = value
self.show_mpr_images()
except Exception as e:
logging.error(f"Error moving coronal scroll bar: {e}")
def activate_mpr(self):
self.show_mpr_images()
def mousePressEvent(self, event):
try:
if event.buttons() == Qt.LeftButton:
if self.axial_canvas.underMouse():
self.current_index = (self.current_index + 1) % self.series_images.shape[0]
elif self.sagittal_canvas.underMouse():
self.current_sagittal_index = (self.current_sagittal_index + 1) % self.series_images.shape[2]
elif self.coronal_canvas.underMouse():
self.current_coronal_index = (self.current_coronal_index + 1) % self.series_images.shape[1]
self.show_mpr_images()
except Exception as e:
logging.error(f"Error in mousePressEvent: {e}")
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
window = DICOMViewer()
window.show()
sys.exit(app.exec_())
Issue:
Despite setting the aspect ratios using pixel spacing, the images still appear horizontally stretched. I’m not sure what’s causing this discrepancy. Could there be an issue with how I’m calculating the aspect ratios or how Matplotlib renders the images within the PyQt5 interface?
What I’ve Tried:
Verified that pixel spacing values are correctly read from the DICOM files.
Ensured that aspect ratios are applied correctly in the imshow function.
Experimented with different aspect ratio values but couldn’t achieve the desired behavior.
Question:
What adjustments or additional calculations are needed to correct the horizontal stretching and ensure the images mimic the behavior of OsiriX/Horos? Are there any specific considerations when integrating Matplotlib with PyQt5 for accurate image rendering?
Any insights or suggestions would be greatly appreciated!
Additional Information:
Libraries Used: PyQt5, pydicom, NumPy, Matplotlib
Operating System: macOS (if relevant for UI rendering differences)
Thank you!