From b11d334c0a93f913879cdd75888dc7cb9a3426f4 Mon Sep 17 00:00:00 2001 From: danielhorton4001 <115335774+danielhorton4001@users.noreply.github.com> Date: Thu, 19 Oct 2023 20:38:32 +1100 Subject: [PATCH] Custom Windowing Slider (#295) * merged Phil's xr-rtstruct.py and readxr.py to my OpenPatientWindow.py and ImageLoading.py changes. RTSTRUCTs can be generated, and XR files with RTSTRUCTs can be opened, however they are not added automatically yet. Additionally, the XR display needs adjustement. * XR files now render inverted. Temp RTSTRUCTS are now applied to XR files. Removed some testing code from and renamed xr-rtstruct.py -> xrRtstruct.py. IM000001 sample file can be opened, but IM000002-IM000004 cause issues. * Allows images with uneven dimensions to be opened. Most CT files have equal dimensions, but CR files do not, requiring a change. Made some requested changes. * Add files via upload * Revert "Add files via upload" This reverts commit 6b39071875298e55ba970e34cbbdf73867e72295. * Add files via upload * Contributions to the GUI by Phil. * Requested changes to draft PR. Slider now updates image correctly, albeit slow. * Adds constants to WindowSlider class. * Slider middle drag added. Hopefully fixed tests. * Fix the tests. * Tests again. --------- Co-authored-by: Daniel Horton --- src/Controller/ActionHandler.py | 8 +- src/Model/Windowing.py | 29 ++- src/View/mainpage/MainPage.py | 24 +- src/View/mainpage/WindowingSlider.py | 336 +++++++++++++++++++++++++++ test/test_view_dicom_view.py | 6 +- 5 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 src/View/mainpage/WindowingSlider.py diff --git a/src/Controller/ActionHandler.py b/src/Controller/ActionHandler.py index 50aba5c4..4cb22c3e 100644 --- a/src/Controller/ActionHandler.py +++ b/src/Controller/ActionHandler.py @@ -376,8 +376,10 @@ def one_view_handler(self): self.is_four_view = False self.__main_page.dicom_view.setCurrentWidget( - self.__main_page.dicom_single_view) + self.__main_page.dicom_single_view_widget) self.__main_page.dicom_single_view.update_view() + self.__main_page.dicom_single_view_layout.addWidget( + self.__main_page.windowing_slider, 0, 0) if hasattr(self.__main_page, 'image_fusion_view'): self.has_image_registration_four = False @@ -392,8 +394,10 @@ def four_views_handler(self): self.is_four_view = True self.__main_page.dicom_view.setCurrentWidget( - self.__main_page.dicom_four_views) + self.__main_page.dicom_four_views_slider) self.__main_page.dicom_axial_view.update_view() + self.__main_page.dicom_four_views_slider_layout.addWidget( + self.__main_page.windowing_slider, 0, 0) if hasattr(self.__main_page, 'image_fusion_view'): self.has_image_registration_four = True diff --git a/src/Model/Windowing.py b/src/Model/Windowing.py index 6f8a4c1d..42a04ce8 100644 --- a/src/Model/Windowing.py +++ b/src/Model/Windowing.py @@ -5,6 +5,9 @@ from src.Model.ImageFusion import get_fused_window +windowing_slider = None + + def windowing_model(text, init): """ Function triggered when a window is selected from the menu. @@ -12,8 +15,6 @@ def windowing_model(text, init): :param init: list of bool to determine which views are chosen """ patient_dict_container = PatientDictContainer() - moving_dict_container = MovingDictContainer() - pt_ct_dict_container = PTCTDictContainer() # Get the values for window and level from the dict windowing_limits = patient_dict_container.get("dict_windowing")[text] @@ -22,6 +23,21 @@ def windowing_model(text, init): window = windowing_limits[0] level = windowing_limits[1] + windowing_model_direct(level, window, init) + + +def windowing_model_direct(level, window, init): + """ + Function triggered when a window is selected from the menu, + or when the windowing slider bars are adjusted + :param level: The desired level + :param window: The desired window + :param init: list of bool to determine which views are chosen + """ + patient_dict_container = PatientDictContainer() + moving_dict_container = MovingDictContainer() + pt_ct_dict_container = PTCTDictContainer() + # Update the dictionary of pixmaps with the update window and # level values if init[0]: @@ -72,3 +88,12 @@ def windowing_model(text, init): patient_dict_container.set("color_coronal", fusion_coronal) patient_dict_container.set("color_sagittal", fusion_sagittal) moving_dict_container.set("tfm", tfm) + + # Update Slider + if windowing_slider is not None: + windowing_slider.set_bars_from_window(window, level) + + +def set_windowing_slider(slider): + global windowing_slider + windowing_slider = slider diff --git a/src/View/mainpage/MainPage.py b/src/View/mainpage/MainPage.py index 5a906618..68cd49df 100644 --- a/src/View/mainpage/MainPage.py +++ b/src/View/mainpage/MainPage.py @@ -16,6 +16,7 @@ from src.View.mainpage.DicomAxialView import DicomAxialView from src.View.mainpage.DicomCoronalView import DicomCoronalView from src.View.mainpage.DicomSagittalView import DicomSagittalView +from src.View.mainpage.WindowingSlider import WindowingSlider from src.View.mainpage.IsodoseTab import IsodoseTab from src.View.mainpage.MenuBar import MenuBar from src.View.mainpage.DicomView3D import DicomView3D @@ -102,6 +103,8 @@ def setup_actions(self): self.toolbar = Toolbar(self.action_handler) self.main_window_instance.addToolBar( QtCore.Qt.TopToolBarArea, self.toolbar) + self.windowing_slider.set_action_handler( + self.action_handler) self.main_window_instance.setWindowTitle("OnkoDICOM") def setup_central_widget(self): @@ -162,6 +165,7 @@ def setup_central_widget(self): roi_color=roi_color_dict, iso_color=iso_color_dict, cut_line_color=QtGui.QColor(0, 0, 255)) self.three_dimension_view = DicomView3D() + self.windowing_slider = WindowingSlider() # Rescale the size of the scenes inside the 3-slice views self.dicom_axial_view.zoom = INITIAL_FOUR_VIEW_ZOOM @@ -173,6 +177,7 @@ def setup_central_widget(self): self.dicom_four_views = QWidget() self.dicom_four_views_layout = QGridLayout() + for i in range(2): self.dicom_four_views_layout.setColumnStretch(i, 1) self.dicom_four_views_layout.setRowStretch(i, 1) @@ -180,11 +185,24 @@ def setup_central_widget(self): self.dicom_four_views_layout.addWidget(self.dicom_sagittal_view, 0, 1) self.dicom_four_views_layout.addWidget(self.dicom_coronal_view, 1, 0) self.dicom_four_views_layout.addWidget(self.three_dimension_view, 1, 1) + self.dicom_four_views.setLayout(self.dicom_four_views_layout) - self.dicom_view.addWidget(self.dicom_four_views) - self.dicom_view.addWidget(self.dicom_single_view) - self.dicom_view.setCurrentWidget(self.dicom_single_view) + self.dicom_four_views_slider = QWidget() + self.dicom_four_views_slider_layout = QGridLayout() + self.dicom_four_views_slider_layout.addWidget(self.dicom_four_views, 0, 1) + + self.dicom_four_views_slider.setLayout(self.dicom_four_views_slider_layout) + + self.dicom_single_view_widget = QWidget() + self.dicom_single_view_layout = QGridLayout() + self.dicom_single_view_layout.addWidget(self.windowing_slider, 0, 0) + self.dicom_single_view_layout.addWidget(self.dicom_single_view, 0, 1) + self.dicom_single_view_widget.setLayout(self.dicom_single_view_layout) + + self.dicom_view.addWidget(self.dicom_four_views_slider) + self.dicom_view.addWidget(self.dicom_single_view_widget) + self.dicom_view.setCurrentWidget(self.dicom_single_view_widget) # Add DICOM View to right panel as a tab self.right_panel.addTab(self.dicom_view, "DICOM View") diff --git a/src/View/mainpage/WindowingSlider.py b/src/View/mainpage/WindowingSlider.py new file mode 100644 index 00000000..0e536d6b --- /dev/null +++ b/src/View/mainpage/WindowingSlider.py @@ -0,0 +1,336 @@ +from src.Model.PatientDictContainer import PatientDictContainer +from PySide6.QtWidgets import QWidget, QLabel, QApplication, QGridLayout, QSizePolicy +from PySide6.QtCharts import QChart, QChartView, QLineSeries, QHorizontalBarSeries, QBarSet +from PySide6.QtGui import QPixmap, QPainter +from PySide6 import QtCore +from math import ceil + +from src.Model.Windowing import windowing_model_direct, set_windowing_slider + +import random + + +def gen_random_histogram(): + """ + Temporary function to populate histogram + """ + d = [0.5] + for n in range(0, 599): + d.append((d[-1] + random.random()) * random.random()) + return d + + +class WindowingSlider(QWidget): + """ + A custom Widget that contains a histogram of + pixel values in the current image, and two + sliders that allowing setting of the windowing + function. + """ + + # Max distance from slider clicks are accepted + MAX_CLICK_DIST = 5 + # Minimum rendering height for bottom bar + MIN_BOTTOM_INDEX = 4 + # Window/Level consts + MAX_PIXEL_VALUE = 4096 + LEVEL_OFFSET = 1000 + + SINGLETON = None + + def __init__(self, width=50): + """ + Initialise the slider + :param width: the fixed width of the widget + """ + + super().__init__() + self.action_handler = None + if WindowingSlider.SINGLETON is None: + WindowingSlider.SINGLETON = self + set_windowing_slider(self) + + # Manage size of whole widget + self.fixed_width = width + self.size_policy = QSizePolicy() + self.size_policy.setHorizontalPolicy(QSizePolicy.Policy.Fixed) + self.size_policy.setVerticalPolicy(QSizePolicy.Policy.Preferred) + self.setSizePolicy(self.size_policy) + self.setFixedWidth(self.fixed_width) + + # Histogram + self.histogram_view = HistogramChart(self) + self.histogram_view.windowing_slider = self + self.histogram = QChart() + self.histogram_view.setChart(self.histogram) + self.histogram.setPlotArea( + QtCore.QRectF(0, 0, self.fixed_width, self.height())) + + self.histogram_view.resize(self.fixed_width, self.height()) + self.histogram_view.setMouseTracking(True) + self.histogram_view.viewport().installEventFilter(self) + + self.density = QLineSeries() + self.slider_density = int(self.height() / 3) + + # Create sliders + self.sliders = QHorizontalBarSeries() + self.density = QLineSeries() + self.sliders.setLabelsVisible(False) + self.slider_bars = [] + for i in range(0, self.slider_density): + self.slider_bars.append(QBarSet("")) + self.slider_bars[-1].append(1) + self.slider_bars[-1].setColor("white") + self.sliders.append(self.slider_bars[-1]) + self.histogram.addSeries(self.sliders) + self.histogram.zoom(2) # at default zoom bars don't fill the chart + + # Layout + self.layout = QGridLayout() + self.layout.addWidget(self.histogram_view, 0, 0) + self.setLayout(self.layout) + + # index of slider positions + self.top = self.slider_density - 1 + self.bottom = 0 + + # Get the values for window and level from the dict + patient_dict_container = PatientDictContainer() + print(patient_dict_container.get("dict_windowing")) # testing + windowing_limits = patient_dict_container.get("dict_windowing")['Normal'] + + # Set window and level to the new values + window = windowing_limits[0] + level = windowing_limits[1] + self.set_bars_from_window(window, level) + + # Middle drag + self.mouse_held = False + self.selected_bar = "top" + self.drag_start = 0 + self.drag_upper_limit = 0 + self.drag_lower_limit = 0 + self.drag_upper_offset = 0 + self.drag_lower_offset = 0 + + # Test + self.set_density_histogram(gen_random_histogram()) + + def set_action_handler(self, action_handler): + """ + Sets the action handler. + The action handler can call update_views() + :param action_handler: the action handler + """ + self.action_handler = action_handler + + def resizeEvent(self, event): + self.histogram.setPlotArea( + QtCore.QRectF(0, 0, self.fixed_width, event.size().height())) + + def height_to_index(self, pos): + """ + Converts graph coordinates to a slider index + :param pos: a local coordinate on the graph + """ + index = self.slider_density-1-int(pos/(self.height()/self.slider_density)) + if index < 0: + return 0 + if index >= self.slider_density: + return self.slider_density-1 + return index + + def window_to_index(self, val): + """ + Converts a window value to a slider index + :param val: a value + """ + normalized_val = val / WindowingSlider.MAX_PIXEL_VALUE + index = ceil(self.slider_density * (1 - normalized_val)) - 1 + return index + + def index_to_window(self, index): + """ + Converts a slider index to a window value + :param index: a value + """ + + percent = index / self.slider_density + val = round((1 - percent) * WindowingSlider.MAX_PIXEL_VALUE) + return val + + def set_bars_from_window(self, window, level): + """ + Triggered when the user selects a windowing preset. + Adjusts the values into the 0-2000 range, then updates the bars. + :param window: the window value of the preset + :param level: the level value of the preset + """ + level = level + WindowingSlider.LEVEL_OFFSET + self.update_bar( + self.window_to_index(level - window * 0.5), top_bar=True) + self.update_bar( + self.window_to_index(level + window * 0.5), top_bar=False) + + def set_density_histogram(self, densities): + """ + Takes a list of any size and forms the histogram. + Index 0 is for the lowest density. + :param densities: a list of values from 0-1 + """ + + self.density.setColor("grey") + for i in range(0, len(densities)): + self.density.append(2-densities[i], i) + self.histogram.addSeries(self.density) + + def update_bar(self, index, top_bar=True): + """ + Moves the chosen bar to the provided index. + :param index: the index to move to + :param top_bar: whether to move the top or bottom bar + """ + + # Clamp index to range + index = max(index, 0) + index = min(index, self.slider_density-1) + + if top_bar: + self.slider_bars[self.top].setColor("white") + self.top = index + self.slider_bars[index].setColor("red") + else: + # Ensure the bottom bar is actually rendered + # Functionally the bar will still be correct + self.slider_bars[ + max(self.bottom, WindowingSlider.MIN_BOTTOM_INDEX) + ].setColor("white") + self.bottom = index + self.slider_bars[ + max(index, WindowingSlider.MIN_BOTTOM_INDEX) + ].setColor("red") + + def set_bars(self, top_index, bottom_index): + """ + Call to set the state of the two sliders. + :param top_index: index of the top bar + :param bottom_index: index of the bottom bar + """ + + self.update_bar(top_index, top_bar=True) + self.update_bar(bottom_index, top_bar=False) + + def mouse_press(self, event): + """ + Called on the HistogramChart's mousePressEvent + :param event: PySide mouse press event + """ + + # get the closest bar + index = self.height_to_index(event.position().y()) + + dist_to_top = abs(index - self.top) + dist_to_bottom = abs(index - self.bottom) + min_dist = min(dist_to_top, dist_to_bottom) + + if min_dist > WindowingSlider.MAX_CLICK_DIST: + # Check for middle drag + if index - self.top > 0 or index - self.bottom > 0: + self.selected_bar = "middle" + self.mouse_held = True + self.drag_start = index + self.drag_upper_limit = self.slider_density - self.top + self.drag_lower_limit = self.bottom + self.drag_upper_offset = self.top - index + self.drag_lower_offset = index - self.bottom + return + + self.mouse_held = True + if dist_to_top < dist_to_bottom: + self.selected_bar = "top" + else: + self.selected_bar = "bottom" + + def mouse_move(self, event): + """ + Called on the HistogramChart's mouseMoveEvent + :param event: PySide mouse move event + """ + + if not self.mouse_held: + return + + self.update_bar_position(event) + + def mouse_release(self, event): + """ + Called on the HistogramChart's mouseReleaseEvent + :param event: PySide mouse release event + """ + + if not self.mouse_held: + return + + self.mouse_held = False + self.update_bar_position(event) + + send = [ + True, + False, + False, + False] + + top_bar = self.index_to_window(self.top) + bottom_bar = self.index_to_window(self.bottom) + + level = (top_bar + bottom_bar) * 0.5 + window = 2 * (bottom_bar - level) + level = level - WindowingSlider.LEVEL_OFFSET + + windowing_model_direct(level, window, send) + if self.action_handler is not None: + self.action_handler.update_views() + + def update_bar_position(self, event): + # move selected bar to the closest valid position + index = int(self.height_to_index(event.position().y())) + if self.selected_bar == "top": + if index <= self.bottom: + index = self.bottom + 1 + self.update_bar(index, top_bar=True) + elif self.selected_bar == "bottom": + if index >= self.top: + index = self.top - 1 + self.update_bar(index, top_bar=False) + else: + # middle drag + top_index = index + self.drag_upper_offset + bot_index = index - self.drag_lower_offset + self.update_bar(top_index, top_bar=True) + self.update_bar(bot_index, top_bar=False) + + def update_bar_middle_drag(self, event): + """""" + pass + + +class HistogramChart(QChartView): + """ + A custom QChartView class to override mouse events + when clicking on the histogram. + Calls the respective functions in the parent + WindowingSlider class. + """ + + def __int__(self, parent): + self.windowing_slider = parent + + def mousePressEvent(self, event): + self.windowing_slider.mouse_press(event) + + def mouseMoveEvent(self, event): + self.windowing_slider.mouse_move(event) + + def mouseReleaseEvent(self, event): + self.windowing_slider.mouse_release(event) diff --git a/test/test_view_dicom_view.py b/test/test_view_dicom_view.py index 1b43dbd3..e0c5aa8d 100644 --- a/test/test_view_dicom_view.py +++ b/test/test_view_dicom_view.py @@ -75,7 +75,7 @@ def test_one_view_handling(qtbot, test_object, init_config): test_object.main_window.show() test_object.main_window.action_handler.action_one_view.trigger() assert isinstance(test_object.main_window.dicom_single_view, DicomView) is True - assert test_object.main_window.dicom_view.currentWidget() == test_object.main_window.dicom_single_view + assert test_object.main_window.dicom_view.currentWidget() == test_object.main_window.dicom_single_view_widget def test_one_view_zoom(qtbot, test_object, init_config): @@ -95,8 +95,8 @@ def test_four_view_handling(qtbot, test_object, init_config): assert isinstance(test_object.main_window.dicom_axial_view, DicomView) is True assert isinstance(test_object.main_window.dicom_sagittal_view, DicomView) is True assert isinstance(test_object.main_window.dicom_coronal_view, DicomView) is True - assert isinstance(test_object.main_window.dicom_four_views_layout, QGridLayout) is True - assert test_object.main_window.dicom_view.currentWidget() == test_object.main_window.dicom_four_views + assert isinstance(test_object.main_window.dicom_four_views_slider_layout, QGridLayout) is True + assert test_object.main_window.dicom_view.currentWidget() == test_object.main_window.dicom_four_views_slider assert test_object.main_window.dicom_axial_view.pos().x(), test_object.main_window.dicom_axial_view.pos().y() == ( 0, 0) assert test_object.main_window.dicom_sagittal_view.pos().x(), \