From 877c5da41989359d9d65050a250634fd26c110f3 Mon Sep 17 00:00:00 2001 From: dbouget Date: Fri, 20 Sep 2024 16:17:18 +0200 Subject: [PATCH 1/7] Progress on allowing to switch the display between patient space and MNI space [skip ci] --- gui/RaidionicsMainWindow.py | 12 +- .../CentralAreaWidget.py | 3 + .../CentralDisplayAreaWidget.py | 11 +- .../SinglePatientWidget.py | 8 + .../CustomQDialog/SoftwareSettingsDialog.py | 99 ++++++++- utils/data_structures/AnnotationStructure.py | 173 +++++++++++----- utils/data_structures/AtlasStructure.py | 80 +++++++- utils/data_structures/MRIVolumeStructure.py | 194 +++++++++++++----- .../PatientParametersStructure.py | 29 ++- .../UserPreferencesStructure.py | 16 ++ utils/logic/PipelineResultsCollector.py | 76 +++++++ utils/software_config.py | 10 +- 12 files changed, 588 insertions(+), 123 deletions(-) diff --git a/gui/RaidionicsMainWindow.py b/gui/RaidionicsMainWindow.py index c1787c7..b66914f 100644 --- a/gui/RaidionicsMainWindow.py +++ b/gui/RaidionicsMainWindow.py @@ -9,6 +9,9 @@ import numpy as np import logging import warnings + +from utils.data_structures.UserPreferencesStructure import UserPreferencesStructure + warnings.simplefilter(action='ignore', category=FutureWarning) from utils.software_config import SoftwareConfigResources @@ -39,7 +42,7 @@ def stop(self): class RaidionicsMainWindow(QMainWindow): - + reload_interface = Signal() new_patient_clicked = Signal(str) # Internal unique_id of the clicked patient def __init__(self, application, *args, **kwargs): @@ -322,6 +325,7 @@ def __set_inner_widget_connections(self): def __cross_widgets_connections(self): self.welcome_widget.left_panel_single_patient_pushbutton.clicked.connect(self.__on_single_patient_clicked) self.new_patient_clicked.connect(self.single_patient_widget.on_single_patient_clicked) + self.reload_interface.connect(self.single_patient_widget.on_reload_interface) self.welcome_widget.left_panel_multiple_patients_pushbutton.clicked.connect(self.__on_study_batch_clicked) self.welcome_widget.community_clicked.connect(self.__on_community_action_triggered) @@ -427,9 +431,15 @@ def __on_study_batch_clicked(self): self.adjustSize() def __on_settings_preferences_clicked(self): + patient_space = UserPreferencesStructure.getInstance().display_space diag = SoftwareSettingsDialog(self) diag.exec_() + # Reloading the interface is mainly meant to perform a visual refreshment based on the latest user display choices + # For now: changing the display space for viewing a patient images. + if UserPreferencesStructure.getInstance().display_space != patient_space: + self.reload_interface.emit() + def __on_patient_selected(self, patient_uid: str) -> None: """ A patient has been selected in another module than the single patient one in order to be displayed. diff --git a/gui/SinglePatientComponent/CentralAreaWidget.py b/gui/SinglePatientComponent/CentralAreaWidget.py index 4a3d8e0..ac12609 100644 --- a/gui/SinglePatientComponent/CentralAreaWidget.py +++ b/gui/SinglePatientComponent/CentralAreaWidget.py @@ -105,6 +105,9 @@ def __set_layout_dimensions(self): def get_widget_name(self): return self.widget_name + def on_reload_interface(self): + self.display_area_widget.on_patient_selected() + def on_patient_selected(self, patient_uid): self.patient_view_toggled.emit(patient_uid) diff --git a/gui/SinglePatientComponent/CentralDisplayArea/CentralDisplayAreaWidget.py b/gui/SinglePatientComponent/CentralDisplayArea/CentralDisplayAreaWidget.py index 3a8e405..7e4674d 100644 --- a/gui/SinglePatientComponent/CentralDisplayArea/CentralDisplayAreaWidget.py +++ b/gui/SinglePatientComponent/CentralDisplayArea/CentralDisplayAreaWidget.py @@ -162,9 +162,14 @@ def on_patient_selected(self): # Can only be 0 if the active patient is the default (and empty) temp patient created during initialization. if self.current_patient_parameters.get_patient_mri_volumes_number() != 0: - self.displayed_image = self.current_patient_parameters.get_mri_by_uid( - self.current_patient_parameters.get_all_mri_volumes_uids()[0]).get_display_volume() - self.displayed_image_uid = self.current_patient_parameters.get_mri_by_uid(self.current_patient_parameters.get_all_mri_volumes_uids()[0]).unique_id + # If updating the central panel after selecting a different display space, the same image as currently + # visible should be updated. + if self.displayed_image_uid in self.current_patient_parameters.get_all_mri_volumes_uids(): + self.displayed_image = self.current_patient_parameters.get_mri_by_uid(self.displayed_image_uid).get_display_volume() + else: # If an actual new patient selected, the first available image is displayed + self.displayed_image = self.current_patient_parameters.get_mri_by_uid( + self.current_patient_parameters.get_all_mri_volumes_uids()[0]).get_display_volume() + self.displayed_image_uid = self.current_patient_parameters.get_mri_by_uid(self.current_patient_parameters.get_all_mri_volumes_uids()[0]).unique_id self.point_clicker_position = [int(self.displayed_image.shape[0] / 2), int(self.displayed_image.shape[1] / 2), int(self.displayed_image.shape[2] / 2)] diff --git a/gui/SinglePatientComponent/SinglePatientWidget.py b/gui/SinglePatientComponent/SinglePatientWidget.py index 53c73b4..8e87287 100644 --- a/gui/SinglePatientComponent/SinglePatientWidget.py +++ b/gui/SinglePatientComponent/SinglePatientWidget.py @@ -275,6 +275,14 @@ def __on_patient_selected(self, patient_uid): self.top_logo_panel_label_import_dicom_pushbutton.setEnabled(True) self.top_logo_panel_statistics_pushbutton.setEnabled(True) + def on_reload_interface(self) -> None: + """ + In order to generate a new central panel, for example because the display space has changed. + """ + if not SoftwareConfigResources.getInstance().is_patient_list_empty(): + SoftwareConfigResources.getInstance().get_active_patient().load_in_memory() + self.center_panel.on_reload_interface() + def on_patient_selected(self, patient_name): self.results_panel.on_external_patient_selection(patient_name) diff --git a/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py b/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py index 8a28de3..f216533 100644 --- a/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py +++ b/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py @@ -369,22 +369,32 @@ def __set_processing_reporting_options_interface(self): def __set_appearance_options_interface(self): self.appearance_options_widget = QWidget() self.appearance_options_base_layout = QVBoxLayout() - self.appearance_options_label = QLabel("Appearance") + self.appearance_options_label = QLabel("Display") self.appearance_options_base_layout.addWidget(self.appearance_options_label) + self.display_space_layout = QHBoxLayout() + self.display_space_header_label = QLabel("Data display space ") + self.display_space_header_label.setToolTip("Select the space in which the volumes and annotations should be displayed.") + self.display_space_combobox = QComboBox() + self.display_space_combobox.addItems(["Patient", "MNI"]) + self.display_space_combobox.setCurrentText(UserPreferencesStructure.getInstance().display_space) + self.display_space_layout.addWidget(self.display_space_header_label) + self.display_space_layout.addWidget(self.display_space_combobox) + self.display_space_layout.addStretch(1) self.color_theme_layout = QHBoxLayout() - self.dark_mode_header_label = QLabel("Dark mode ") + self.dark_mode_header_label = QLabel("Dark mode appearance ") self.dark_mode_header_label.setToolTip("Click to use a dark-theme appearance mode ( a restart is necessary).") self.dark_mode_checkbox = QCheckBox() self.dark_mode_checkbox.setChecked(UserPreferencesStructure.getInstance().use_dark_mode) - self.color_theme_layout.addWidget(self.dark_mode_checkbox) self.color_theme_layout.addWidget(self.dark_mode_header_label) + self.color_theme_layout.addWidget(self.dark_mode_checkbox) self.color_theme_layout.addStretch(1) + self.appearance_options_base_layout.addLayout(self.display_space_layout) self.appearance_options_base_layout.addLayout(self.color_theme_layout) self.appearance_options_base_layout.addStretch(1) self.appearance_options_widget.setLayout(self.appearance_options_base_layout) self.options_stackedwidget.addWidget(self.appearance_options_widget) - self.appearance_options_pushbutton = QPushButton('Appearance') + self.appearance_options_pushbutton = QPushButton('Display') self.options_list_scrollarea_layout.insertWidget(self.options_list_scrollarea_layout.count() - 1, self.appearance_options_pushbutton) @@ -398,6 +408,7 @@ def __set_layout_dimensions(self): self.processing_options_label.setFixedHeight(40) self.appearance_options_pushbutton.setFixedHeight(30) self.appearance_options_label.setFixedHeight(40) + self.display_space_combobox.setFixedSize(QSize(70,20)) self.__set_layout_dimensions_processing_segmentation() self.__set_layout_dimensions_processing_reporting() self.setMinimumSize(800, 600) @@ -440,6 +451,7 @@ def __set_connections(self): self.processing_options_compute_braingridstructures_checkbox.stateChanged.connect(self.__on_compute_braingridstructures_status_changed) self.braingridstructures_voxels_checkbox.stateChanged.connect(self.__on_braingridstructure_voxels_status_changed) self.dark_mode_checkbox.stateChanged.connect(self.__on_dark_mode_status_changed) + self.display_space_combobox.currentTextChanged.connect(self.__on_display_space_changed) self.exit_accept_pushbutton.clicked.connect(self.__on_exit_accept_clicked) self.exit_cancel_pushbutton.clicked.connect(self.__on_exit_cancel_clicked) @@ -663,6 +675,72 @@ def __set_stylesheets(self): background-color: rgb(15, 15, 15); }""") + self.display_space_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + if os.name == 'nt': + self.display_space_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + } + """) + else: + self.display_space_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + border-left-width: 1px; + border-left-color: darkgray; + border-left-style: none; + border-top-right-radius: 3px; /* same radius as the QComboBox */ + border-bottom-right-radius: 3px; + } + QComboBox::down-arrow{ + image: url(""" + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../../Images/combobox-arrow-icon-10x7.png') + """) + } + """) + + self.dark_mode_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + def __on_home_dir_changed(self, directory: str) -> None: """ The user manually selected another location for storing patients/studies. @@ -841,6 +919,19 @@ def __on_dark_mode_status_changed(self, state): # @TODO. Would have to bounce back to the QApplication class, to trigger a global setStyleSheet on-the-fly? SoftwareConfigResources.getInstance().set_dark_mode_state(state) + def __on_display_space_changed(self, space: str) -> None: + """ + Changes the default space to be used to visualize a patient's images (e.g. original space or atlas space). + If a patient is currently being displayed, a reload in memory must be performed in order to use the images + in the proper space. + + Parameters + ---------- + space: str + String describing which image space must be used for visualization, from [Patient, MNI] at the moment. + """ + UserPreferencesStructure.getInstance().display_space = space + def __on_exit_accept_clicked(self): """ """ diff --git a/utils/data_structures/AnnotationStructure.py b/utils/data_structures/AnnotationStructure.py index 1835849..fee2893 100644 --- a/utils/data_structures/AnnotationStructure.py +++ b/utils/data_structures/AnnotationStructure.py @@ -1,5 +1,5 @@ import traceback - +import shutil from aenum import Enum, unique import logging from typing import Union, Any, Tuple @@ -11,7 +11,7 @@ from pathlib import PurePath from utils.utilities import get_type_from_string, input_file_type_conversion - +from utils.data_structures.UserPreferencesStructure import UserPreferencesStructure @unique class AnnotationClassType(Enum): @@ -74,6 +74,8 @@ class AnnotationVolume: _display_name = "" _display_volume = None # Displayable version of the annotation volume (e.g., resampled isotropically) _display_volume_filepath = None + _registered_volume_filepaths = {} # List of filepaths on disk with the registered volumes + _registered_volumes = {} # List of numpy arrays with the registered volumes _display_opacity = 50 # Percentage indicating the opacity for blending the annotation with the rest _display_color = [255, 255, 255, 255] # Visible color for the annotation, with format: [r, g, b, a] _default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] # Affine matrix for dumping resampled files @@ -117,6 +119,8 @@ def __reset(self): self._display_name = "" self._display_volume = None self._display_volume_filepath = None + self._registered_volume_filepaths = {} + self._registered_volumes = {} self._display_opacity = 50 self._display_color = [255, 255, 255, 255] self._default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] @@ -127,14 +131,18 @@ def unique_id(self) -> str: return self._unique_id def load_in_memory(self) -> None: - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + if UserPreferencesStructure.getInstance().display_space == 'Patient': + if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): + self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + self.__generate_display_volume() def release_from_memory(self) -> None: self._display_volume = None self._resampled_input_volume = None + self.registered_volumes = {} def delete(self): if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): @@ -150,6 +158,10 @@ def delete(self): and os.path.exists(self._usable_input_filepath): os.remove(self._usable_input_filepath) + if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) > 0: + for k in list(self.registered_volume_filepaths.keys()): + os.remove(self.registered_volume_filepaths[k]) + def set_unsaved_changes_state(self, state: bool) -> None: self._unsaved_changes = state @@ -193,6 +205,22 @@ def raw_input_filepath(self) -> str: def usable_input_filepath(self) -> str: return self._usable_input_filepath + @property + def registered_volume_filepaths(self) -> dict: + return self._registered_volume_filepaths + + @registered_volume_filepaths.setter + def registered_volume_filepaths(self, new_filepaths: dict) -> None: + self._registered_volume_filepaths = new_filepaths + + @property + def registered_volumes(self) -> dict: + return self._registered_volumes + + @registered_volumes.setter + def registered_volumes(self, new_volumes: dict) -> None: + self._registered_volumes = new_volumes + @property def display_name(self) -> str: return self._display_name @@ -392,6 +420,13 @@ def save(self) -> dict: base_patient_folder) volume_params['display_volume_filepath'] = os.path.relpath(self._display_volume_filepath, base_patient_folder) + + if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) != 0: + reg_volumes = {} + for k in list(self.registered_volume_filepaths.keys()): + reg_volumes[k] = os.path.relpath(self.registered_volume_filepaths[k], base_patient_folder) + volume_params['registered_volume_filepaths'] = reg_volumes + volume_params['annotation_class'] = str(self._annotation_class) volume_params['generation_type'] = str(self._generation_type) volume_params['parent_mri_uid'] = self._parent_mri_uid @@ -404,6 +439,24 @@ def save(self) -> dict: except Exception: logging.error("AnnotationStructure saving failed with:\n {}".format(traceback.format_exc())) + def import_registered_volume(self, filepath: str, registration_space: str) -> None: + """ + + """ + try: + registered_space_folder = os.path.join(self._output_patient_folder, + self._timestamp_folder_name, 'raw', registration_space) + os.makedirs(registered_space_folder, exist_ok=True) + dest_path = os.path.join(registered_space_folder, os.path.basename(filepath)) + shutil.copyfile(filepath, dest_path) + self.registered_volume_filepaths[registration_space] = dest_path + self.registered_volumes[registration_space] = nib.load(dest_path).get_fdata()[:] + logging.debug("""Unsaved changes - Registered annotation volume to space {} added in {}.""".format( + registration_space, dest_path)) + self._unsaved_changes = True + except Exception: + logging.error("Error while importing a registered annotation volume.\n {}".format(traceback.format_exc())) + def __init_from_scratch(self) -> None: os.makedirs(self.output_patient_folder, exist_ok=True) os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name), exist_ok=True) @@ -422,48 +475,72 @@ def __reload_from_disk(self, parameters: dict) -> None: potentially missing variables without crashing. @TODO. Might need a prompt if the loading of some elements failed to warn the user. """ - if os.path.exists(parameters['raw_input_filepath']): - self._raw_input_filepath = parameters['raw_input_filepath'] - else: - self._raw_input_filepath = os.path.join(self._output_patient_folder, parameters['raw_input_filepath']) + try: + if os.path.exists(parameters['raw_input_filepath']): + self._raw_input_filepath = parameters['raw_input_filepath'] + else: + self._raw_input_filepath = os.path.join(self._output_patient_folder, parameters['raw_input_filepath']) - # To check whether the usable filepath has been provided by the user (hence lies somewhere on the machine) - # or was generated by the software and lies within the patient folder. - if os.path.exists(parameters['usable_input_filepath']): - self._usable_input_filepath = parameters['usable_input_filepath'] - else: - self._usable_input_filepath = os.path.join(self._output_patient_folder, parameters['usable_input_filepath']) - - # The resampled volume can only be inside the output patient folder as it is internally computed and cannot be - # manually imported into the software. - self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, - parameters['resample_input_filepath']) - if os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] - else: - # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed - self.__generate_display_volume() + # To check whether the usable filepath has been provided by the user (hence lies somewhere on the machine) + # or was generated by the software and lies within the patient folder. + if os.path.exists(parameters['usable_input_filepath']): + self._usable_input_filepath = parameters['usable_input_filepath'] + else: + self._usable_input_filepath = os.path.join(self._output_patient_folder, parameters['usable_input_filepath']) + + # The resampled volume can only be inside the output patient folder as it is internally computed and cannot be + # manually imported into the software. + self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, + parameters['resample_input_filepath']) + if os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed + self.__generate_display_volume() - self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) - if os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) + if os.path.exists(self._display_volume_filepath): + self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + else: + self.__generate_display_volume() + + if 'registered_volume_filepaths' in parameters.keys(): + for k in list(parameters['registered_volume_filepaths'].keys()): + self.registered_volume_filepaths[k] = os.path.join(self._output_patient_folder, + parameters['registered_volume_filepaths'][k]) + self.registered_volumes[k] = nib.load(self.registered_volume_filepaths[k]).get_fdata()[:] + + self.set_annotation_class_type(anno_type=parameters['annotation_class'], manual=False) + self.set_generation_type(generation_type=parameters['generation_type'], manual=False) + self._parent_mri_uid = parameters['parent_mri_uid'] + self._timestamp_uid = parameters['investigation_timestamp_uid'] + self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] + if os.name == 'nt': + self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] + self._display_name = parameters['display_name'] + self._display_color = parameters['display_color'] + self._display_opacity = parameters['display_opacity'] + except Exception: + logging.error("""Reloading annotation structure from disk failed + for: {}.\n {}""".format(self.display_name, traceback.format_exc())) + + def __generate_display_volume(self) -> None: + """ + @TODO. What if there is no annotation in the registration space? + @TODO. Check if more than one label in the file? + """ + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ + UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): + display_space_anno = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] + self._display_volume = deepcopy(display_space_anno) else: - self.__generate_display_volume() - self.set_annotation_class_type(anno_type=parameters['annotation_class'], manual=False) - self.set_generation_type(generation_type=parameters['generation_type'], manual=False) - self._parent_mri_uid = parameters['parent_mri_uid'] - self._timestamp_uid = parameters['investigation_timestamp_uid'] - self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] - if os.name == 'nt': - self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] - self._display_name = parameters['display_name'] - self._display_color = parameters['display_color'] - self._display_opacity = parameters['display_opacity'] - - def __generate_display_volume(self): - # @TODO. Check if more than one label? - image_nib = nib.load(self._usable_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - - self._display_volume = deepcopy(self._resampled_input_volume) + image_nib = nib.load(self._usable_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + + self._display_volume = deepcopy(self._resampled_input_volume) + + if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ + UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): + logging.warning(""" The selected annotation ({}) does not have any expression in {} space.\n The default annotation in patient space is therefore used.""".format(self.get_annotation_class_str(), + UserPreferencesStructure.getInstance().display_space)) \ No newline at end of file diff --git a/utils/data_structures/AtlasStructure.py b/utils/data_structures/AtlasStructure.py index d97b6d2..4852e99 100644 --- a/utils/data_structures/AtlasStructure.py +++ b/utils/data_structures/AtlasStructure.py @@ -1,5 +1,6 @@ import os import logging +import shutil from typing import Union, List, Tuple import pandas as pd import numpy as np @@ -9,6 +10,7 @@ from copy import deepcopy from pathlib import PurePath +from utils.data_structures.UserPreferencesStructure import UserPreferencesStructure class AtlasVolume: """ @@ -26,6 +28,8 @@ class AtlasVolume: _display_name = "" # Visible and editable name for identifying the current Atlas _display_volume = None _display_volume_filepath = "" # Display MRI volume filepath, in its latest state after potential user modifiers + _atlas_space_filepaths = {} # List of atlas structures filepaths on disk expressed in an atlas space + _atlas_space_volumes = {} # List of numpy arrays with the atlases structures expressed in an atlas space _parent_mri_uid = "" # Internal unique identifier for the MRI volume to which this annotation is linked _one_hot_display_volume = None _visible_class_labels = [] @@ -72,6 +76,8 @@ def __reset(self): self._display_name = "" self._display_volume = None self._display_volume_filepath = "" + self._atlas_space_filepaths = {} + self._atlas_space_volumes = {} self._parent_mri_uid = "" self._one_hot_display_volume = None self._visible_class_labels = [] @@ -123,19 +129,29 @@ def __reload_from_disk(self, parameters: dict) -> None: if os.name == 'nt': self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] + if 'atlas_space_filepaths' in parameters.keys(): + for k in list(parameters['atlas_space_filepaths'].keys()): + self.atlas_space_filepaths[k] = os.path.join(self._output_patient_folder, + parameters['atlas_space_filepaths'][k]) + self.atlas_space_volumes[k] = nib.load(self.atlas_space_filepaths[k]).get_fdata()[:] + if 'display_colors' in parameters.keys(): self._class_display_color = {int(k): v for k, v in parameters['display_colors'].items()} if 'display_opacities' in parameters.keys(): self._class_display_opacity = {int(k): v for k, v in parameters['display_opacities'].items()} def load_in_memory(self) -> None: - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + if UserPreferencesStructure.getInstance().display_space == 'Patient': + if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): + self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + else: + self.__generate_display_volume() else: - pass + self.__generate_display_volume() def release_from_memory(self) -> None: self._display_volume = None + self.atlas_space_volumes = {} @property def unique_id(self) -> str: @@ -157,6 +173,22 @@ def display_name(self, name: str) -> None: self._unsaved_changes = True logging.debug("Unsaved changes - Atlas volume display name changed to {}.".format(name)) + @property + def atlas_space_filepaths(self) -> dict: + return self._atlas_space_filepaths + + @atlas_space_filepaths.setter + def atlas_space_filepaths(self, new_filepaths: dict) -> None: + self._atlas_space_filepaths = new_filepaths + + @property + def atlas_space_volumes(self) -> dict: + return self._atlas_space_volumes + + @atlas_space_volumes.setter + def atlas_space_volumes(self, new_volumes: dict) -> None: + self._atlas_space_volumes = new_volumes + def get_parent_mri_uid(self) -> str: return self._parent_mri_uid @@ -303,6 +335,20 @@ def delete(self): if self._class_description_filename and os.path.exists(self._class_description_filename): os.remove(self._class_description_filename) + def import_atlas_in_registration_space(self, filepath: str, registration_space: str) -> None: + """ + + """ + try: + self.atlas_space_filepaths[registration_space] = filepath + image_nib = nib.load(filepath) + self.atlas_space_volumes[registration_space] = image_nib.get_fdata()[:] + logging.debug("""Unsaved changes - Structures atlas in space {} added in {}.""".format( + registration_space, filepath)) + self._unsaved_changes = True + except Exception: + logging.error("Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) + def save(self) -> dict: """ @@ -345,6 +391,13 @@ def save(self) -> dict: # base_patient_folder = base_patient_folder.joinpath(x) volume_params['description_filepath'] = os.path.relpath(self._class_description_filename, self._output_patient_folder) + + if self.atlas_space_filepaths and len(self.atlas_space_filepaths.keys()) != 0: + atlas_volumes = {} + for k in list(self.atlas_space_filepaths.keys()): + atlas_volumes[k] = os.path.relpath(self.atlas_space_filepaths[k], self._output_patient_folder) + volume_params['atlas_space_filepaths'] = atlas_volumes + volume_params['parent_mri_uid'] = self._parent_mri_uid volume_params['investigation_timestamp_uid'] = self._timestamp_uid volume_params['display_colors'] = self._class_display_color @@ -358,13 +411,24 @@ def __generate_display_volume(self) -> None: """ Generate a display-compatible volume from the raw MRI volume the first time it is loaded in the software. """ - image_nib = nib.load(self._raw_input_filepath) + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ + UserPreferencesStructure.getInstance().display_space in self.atlas_space_volumes.keys(): + display_space_atlas = self.atlas_space_volumes[UserPreferencesStructure.getInstance().display_space] + self._display_volume = deepcopy(display_space_atlas) + else: + image_nib = nib.load(self._raw_input_filepath) + + # Resampling to standard output for viewing purposes. + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + + self._display_volume = deepcopy(self._resampled_input_volume) - # Resampling to standard output for viewing purposes. - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ + UserPreferencesStructure.getInstance().display_space not in self.atlas_space_volumes.keys(): + logging.warning(""" The selected structure atlas ({}) does not have any expression in {} space.\n The default structure atlas in patient space is therefore used.""".format(self.display_name, + UserPreferencesStructure.getInstance().display_space)) - self._display_volume = deepcopy(self._resampled_input_volume) self._visible_class_labels = list(np.unique(self._display_volume)) self._class_number = len(self._visible_class_labels) - 1 self._one_hot_display_volume = np.zeros(shape=(self._display_volume.shape + (self._class_number + 1,)), diff --git a/utils/data_structures/MRIVolumeStructure.py b/utils/data_structures/MRIVolumeStructure.py index 9030517..aa1b567 100644 --- a/utils/data_structures/MRIVolumeStructure.py +++ b/utils/data_structures/MRIVolumeStructure.py @@ -1,4 +1,5 @@ import datetime +import shutil import traceback import dateutil.tz from aenum import Enum, unique @@ -12,6 +13,7 @@ import json from pathlib import PurePath +from utils.data_structures.UserPreferencesStructure import UserPreferencesStructure from utils.utilities import get_type_from_string, input_file_type_conversion @@ -51,6 +53,8 @@ class MRIVolume: _display_name = "" # Name shown to the user to identify the current volume, and which can be modified. _display_volume = None _display_volume_filepath = "" # Display MRI volume filepath, in its latest state after potential user modifiers + _registered_volume_filepaths = {} # List of filepaths on disk with the registered volumes + _registered_volumes = {} # List of numpy arrays with the registered volumes _default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] # Affine matrix for dumping resampled files _unsaved_changes = False # Documenting any change, for suggesting saving when swapping between patients @@ -91,26 +95,32 @@ def __reset(self): self._display_name = "" self._display_volume = None self._display_volume_filepath = "" + self._registered_volume_filepaths = {} + self._registered_volumes = {} self._default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] self._unsaved_changes = False self._contrast_changed = False def load_in_memory(self) -> None: - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] - else: - # Should not occur unless the patient was not saved after being loaded. - # @behaviour. it is wanted? - self.__generate_display_volume() + if UserPreferencesStructure.getInstance().display_space == 'Patient': + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + # Should not occur unless the patient was not saved after being loaded. + # @behaviour. it is wanted? + self.__generate_display_volume() - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): + self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + else: + self.__generate_display_volume() else: self.__generate_display_volume() def release_from_memory(self) -> None: self._resampled_input_volume = None self._display_volume = None + self.registered_volumes = {} @property def unique_id(self) -> str: @@ -326,18 +336,41 @@ def set_dicom_metadata(self, metadata: dict) -> None: def get_dicom_metadata(self) -> dict: return self._dicom_metadata + @property + def registered_volume_filepaths(self) -> dict: + return self._registered_volume_filepaths + + @registered_volume_filepaths.setter + def registered_volume_filepaths(self, new_filepaths: dict) -> None: + self._registered_volume_filepaths = new_filepaths + + @property + def registered_volumes(self) -> dict: + return self._registered_volumes + + @registered_volumes.setter + def registered_volumes(self, new_volumes: dict) -> None: + self._registered_volumes = new_volumes + def delete(self): - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - os.remove(self._display_volume_filepath) - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - os.remove(self._resampled_input_volume_filepath) + try: + if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): + os.remove(self._display_volume_filepath) + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + os.remove(self._resampled_input_volume_filepath) + + if self._usable_input_filepath and self._output_patient_folder in self._usable_input_filepath \ + and os.path.exists(self._usable_input_filepath): + os.remove(self._usable_input_filepath) - if self._usable_input_filepath and self._output_patient_folder in self._usable_input_filepath \ - and os.path.exists(self._usable_input_filepath): - os.remove(self._usable_input_filepath) + if self._dicom_metadata_filepath and os.path.exists(self._dicom_metadata_filepath): + os.remove(self._dicom_metadata_filepath) - if self._dicom_metadata_filepath and os.path.exists(self._dicom_metadata_filepath): - os.remove(self._dicom_metadata_filepath) + if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) > 0: + for k in list(self.registered_volume_filepaths.keys()): + os.remove(self.registered_volume_filepaths[k]) + except Exception: + logging.error("Error while deleting a radiological volume from disk.\n {}".format(traceback.format_exc())) def save(self) -> dict: """ @@ -388,12 +421,38 @@ def save(self) -> dict: if self._dicom_metadata_filepath: volume_params['dicom_metadata_filepath'] = os.path.relpath(self._dicom_metadata_filepath, self._output_patient_folder) + + if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) != 0: + reg_volumes = {} + for k in list(self.registered_volume_filepaths.keys()): + reg_volumes[k] = os.path.relpath(self.registered_volume_filepaths[k], self._output_patient_folder) + volume_params['registered_volume_filepaths'] = reg_volumes + self._unsaved_changes = False self._contrast_changed = False return volume_params except Exception: logging.error("MRIVolumeStructure saving failed with:\n {}".format(traceback.format_exc())) + def import_registered_volume(self, filepath: str, registration_space: str) -> None: + """ + + """ + try: + registered_space_folder = os.path.join(self._output_patient_folder, + self._timestamp_folder_name, 'raw', registration_space) + os.makedirs(registered_space_folder, exist_ok=True) + dest_path = os.path.join(registered_space_folder, os.path.basename(filepath)) + shutil.copyfile(filepath, dest_path) + self.registered_volume_filepaths[registration_space] = dest_path + image_nib = nib.load(dest_path) + self.registered_volumes[registration_space] = image_nib.get_fdata()[:] + logging.debug("""Unsaved changes - Registered radiological volume to space {} added in {}.""".format( + registration_space, dest_path)) + self._unsaved_changes = True + except Exception: + logging.error("Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) + def __init_from_scratch(self) -> None: self._timestamp_folder_name = self._timestamp_uid os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name), exist_ok=True) @@ -408,32 +467,42 @@ def __init_from_scratch(self) -> None: self.__generate_display_volume() def __reload_from_disk(self, parameters: dict) -> None: - if os.path.exists(parameters['usable_input_filepath']): - self._usable_input_filepath = parameters['usable_input_filepath'] - else: - self._usable_input_filepath = os.path.join(self._output_patient_folder, parameters['usable_input_filepath']) + try: + if os.path.exists(parameters['usable_input_filepath']): + self._usable_input_filepath = parameters['usable_input_filepath'] + else: + self._usable_input_filepath = os.path.join(self._output_patient_folder, parameters['usable_input_filepath']) - self._contrast_window = [int(x) for x in parameters['contrast_window'].split(',')] + self._contrast_window = [int(x) for x in parameters['contrast_window'].split(',')] - # The resampled volume can only be inside the output patient folder as it is internally computed and cannot be - # manually imported into the software. - self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, - parameters['resample_input_filepath']) - if os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] - else: - # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed - self.__generate_display_volume() + # The resampled volume can only be inside the output patient folder as it is internally computed and cannot be + # manually imported into the software. + self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, + parameters['resample_input_filepath']) + if os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed + self.__generate_display_volume() - # @TODO. Must include a reloading of the DICOM metadata, if they exist. - self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - self._display_name = parameters['display_name'] - self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] - if os.name == 'nt': - self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] - self.set_sequence_type(type=parameters['sequence_type'], manual=False) - self.__generate_intensity_histogram() + if 'registered_volume_filepaths' in parameters.keys(): + for k in list(parameters['registered_volume_filepaths'].keys()): + self.registered_volume_filepaths[k] = os.path.join(self._output_patient_folder, + parameters['registered_volume_filepaths'][k]) + self.registered_volumes[k] = nib.load(self.registered_volume_filepaths[k]).get_fdata()[:] + + # @TODO. Must include a reloading of the DICOM metadata, if they exist. + self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) + self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + self._display_name = parameters['display_name'] + self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] + if os.name == 'nt': + self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] + self.set_sequence_type(type=parameters['sequence_type'], manual=False) + self.__generate_intensity_histogram() + except Exception: + logging.error("""Reloading radiological structure from disk failed + for: {}.\n {}""".format(self.display_name, traceback.format_exc())) def __parse_sequence_type(self): base_name = self._unique_id.lower() @@ -456,27 +525,50 @@ def __generate_intensity_histogram(self): def __generate_display_volume(self) -> None: """ Generate a display-compatible volume from the raw MRI volume the first time it is loaded in the software. + + If the viewing should be performed in a desired reference space, but no image has been generated for it, + the default image in patient space will be shown. """ - image_nib = nib.load(self._usable_input_filepath) + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ + UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): + display_space_volume = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] - # Resampling to standard output for viewing purposes. - resampled_input_ni = resample_to_output(image_nib, order=1) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:] + self.__generate_intensity_histogram() - self.__generate_intensity_histogram() + # The first time, the intensity boundaries must be retrieved + self._contrast_window[0] = int(np.min(display_space_volume)) + self._contrast_window[1] = int(np.max(display_space_volume)) + self.__apply_contrast_scaling_to_display_volume(display_space_volume) + else: + image_nib = nib.load(self._usable_input_filepath) - # The first time, the intensity boundaries must be retrieved - self._contrast_window[0] = int(np.min(self._resampled_input_volume)) - self._contrast_window[1] = int(np.max(self._resampled_input_volume)) + # Resampling to standard output for viewing purposes. + resampled_input_ni = resample_to_output(image_nib, order=1) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:] - self.__apply_contrast_scaling_to_display_volume() + self.__generate_intensity_histogram() - def __apply_contrast_scaling_to_display_volume(self) -> None: + # The first time, the intensity boundaries must be retrieved + self._contrast_window[0] = int(np.min(self._resampled_input_volume)) + self._contrast_window[1] = int(np.max(self._resampled_input_volume)) + self.__apply_contrast_scaling_to_display_volume(self._resampled_input_volume) + + if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ + UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): + logging.warning(""" The selected image ({} {}) does not have any expression in {} space.\n The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), + UserPreferencesStructure.getInstance().display_space)) + + def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray) -> None: """ Generate a display volume according to the contrast parameters set by the user. + + Parameters + ---------- + display_volume: np.ndarray + Base display volume to use to generate a contrast-scaled version of. """ # Scaling data to uint8 - image_res = deepcopy(self._resampled_input_volume) + image_res = deepcopy(display_volume) image_res[image_res < self._contrast_window[0]] = self._contrast_window[0] image_res[image_res > self._contrast_window[1]] = self._contrast_window[1] if (self._contrast_window[1] - self._contrast_window[0]) != 0: diff --git a/utils/data_structures/PatientParametersStructure.py b/utils/data_structures/PatientParametersStructure.py index d2f2a2f..1438fd7 100644 --- a/utils/data_structures/PatientParametersStructure.py +++ b/utils/data_structures/PatientParametersStructure.py @@ -847,19 +847,34 @@ def get_specific_annotations_for_mri(self, mri_volume_uid: str, annotation_class ---------- mri_volume_uid : str Unique id for the queried MRI volume object. - + annotation_class: AnnotationClassType + Type of the annotation class to retrieve. + generation_type: AnnotationGenerationType + Method the annotations to retrieve were generated Returns ------- - bool - True if an automatic segmentation exists for the given class, False otherwise. + List[str] + List of annotation object UIDs matching the query. """ res = [] for an in self._annotation_volumes: - if self._annotation_volumes[an].get_parent_mri_uid() == mri_volume_uid \ - and self._annotation_volumes[an].get_annotation_class_enum() == annotation_class \ - and self._annotation_volumes[an].get_generation_type_enum() == generation_type: - res.append(self._annotation_volumes[an].unique_id) + if annotation_class and generation_type: + if self._annotation_volumes[an].get_parent_mri_uid() == mri_volume_uid \ + and self._annotation_volumes[an].get_annotation_class_enum() == annotation_class \ + and self._annotation_volumes[an].get_generation_type_enum() == generation_type: + res.append(self._annotation_volumes[an].unique_id) + elif annotation_class: + if self._annotation_volumes[an].get_parent_mri_uid() == mri_volume_uid \ + and self._annotation_volumes[an].get_annotation_class_enum() == annotation_class: + res.append(self._annotation_volumes[an].unique_id) + elif generation_type: + if self._annotation_volumes[an].get_parent_mri_uid() == mri_volume_uid \ + and self._annotation_volumes[an].get_generation_type_enum() == generation_type: + res.append(self._annotation_volumes[an].unique_id) + else: + if self._annotation_volumes[an].get_parent_mri_uid() == mri_volume_uid: + res.append(self._annotation_volumes[an].unique_id) return res def is_annotation_raw_filepath_already_loaded(self, volume_filepath: str) -> bool: diff --git a/utils/data_structures/UserPreferencesStructure.py b/utils/data_structures/UserPreferencesStructure.py index 6b8e1f0..6651ebb 100644 --- a/utils/data_structures/UserPreferencesStructure.py +++ b/utils/data_structures/UserPreferencesStructure.py @@ -23,6 +23,7 @@ class UserPreferencesStructure: _export_results_as_rtstruct = False # True to export all masks as DICOM RTStruct in addition _use_stripped_inputs = False # True to use inputs already stripped (e.g., skull-stripped or lungs-stripped) _use_registered_inputs = False # True to use inputs already registered (e.g., altas-registered, multi-sequences co-registered) + _display_space = 'Patient' # Space to use for displaying the results _segmentation_tumor_model_type = "Tumor" # Type of output to expect from the tumor segmentation model (i.e., indicating if a BraTS model should be used) _perform_segmentation_refinement = False # True to enable any kind of segmentation refinement _segmentation_refinement_type = "dilation" # String indicating the type of refinement to perform, to select from ["dilation"] @@ -153,6 +154,16 @@ def use_registered_inputs(self, state: bool) -> None: self._use_registered_inputs = state self.save_preferences() + @property + def display_space(self) -> str: + return self._display_space + + @display_space.setter + def display_space(self, space: str) -> None: + logging.info("Display space set to {}.\n".format(space)) + self._display_space = space + self.save_preferences() + @property def segmentation_tumor_model_type(self) -> str: return self._segmentation_tumor_model_type @@ -246,6 +257,9 @@ def __parse_preferences(self) -> None: if 'Models' in preferences.keys(): if 'active_update' in preferences['Models'].keys(): self.active_model_update = preferences['Models']['active_update'] + if 'Display' in preferences.keys(): + if 'display_space' in preferences['Display'].keys(): + self.display_space = preferences['Display']['display_space'] if 'Processing' in preferences.keys(): if 'use_manual_sequences' in preferences['Processing'].keys(): self.use_manual_sequences = preferences['Processing']['use_manual_sequences'] @@ -293,6 +307,8 @@ def save_preferences(self) -> None: preferences['System']['user_home_location'] = self._user_home_location preferences['Models'] = {} preferences['Models']['active_update'] = self._active_model_update + preferences['Display'] = {} + preferences['Display']['display_space'] = self.display_space preferences['Processing'] = {} preferences['Processing']['use_manual_sequences'] = self._use_manual_sequences preferences['Processing']['use_manual_annotations'] = self._use_manual_annotations diff --git a/utils/logic/PipelineResultsCollector.py b/utils/logic/PipelineResultsCollector.py index b63bdd8..961b697 100644 --- a/utils/logic/PipelineResultsCollector.py +++ b/utils/logic/PipelineResultsCollector.py @@ -7,6 +7,7 @@ from utils.data_structures.UserPreferencesStructure import UserPreferencesStructure from utils.data_structures.MRIVolumeStructure import MRISequenceType +from utils.data_structures.AnnotationStructure import AnnotationClassType, AnnotationGenerationType from utils.utilities import get_type_from_string @@ -79,6 +80,51 @@ def collect_results(patient_parameters, pipeline): continue parent_mri_uid = parent_mri_uid[0] + # Collecting the patient volumes (radiological and annotation) in registered space + # @TODO. Only MNI for now, but should be made generic in the future if more atlas spaces used + atlas_registered_folder = os.path.join(patient_parameters.output_folder, 'reporting', + 'T' + str(pip_step["moving"]["timestamp"]), 'MNI_space') + registered_inputs = [] + registered_labels = [] + for _, _, rfiles in os.walk(atlas_registered_folder): + for rfile in rfiles: + if 'label' not in rfile: + registered_inputs.append(os.path.join(atlas_registered_folder, rfile)) + else: + registered_labels.append(os.path.join(atlas_registered_folder, rfile)) + break + + # @TODO. Have to match each registered file with the corresponding radiological volume. + # Technically, only the volumes used as inference inputs are registered, should all be registered? + for ri in registered_inputs: + patient_parameters.get_mri_by_uid(parent_mri_uid).import_registered_volume(filepath=ri, + registration_space=pip_step["fixed"]["sequence"]) + + for rl in registered_labels: + generation_type = AnnotationGenerationType.Automatic + if UserPreferencesStructure.getInstance().use_manual_annotations: + generation_type = AnnotationGenerationType.Manual + label_type = rl.split('_label_')[-1].split('_')[0] + anno_volume_uid = patient_parameters.get_specific_annotations_for_mri(mri_volume_uid=parent_mri_uid, + annotation_class=get_type_from_string(AnnotationClassType, label_type)) + if len(anno_volume_uid) > 1: + anno_volume_uid = patient_parameters.get_specific_annotations_for_mri( + mri_volume_uid=parent_mri_uid, + annotation_class=get_type_from_string(AnnotationClassType, label_type), + generation_type=generation_type) + if len(anno_volume_uid) == 1: + anno_volume_uid = anno_volume_uid[0] + else: + logging.error("""The registered labels files could not be linked to any existing + annotation file, with value: {}""".format(rl)) + elif len(anno_volume_uid) == 1: + anno_volume_uid = anno_volume_uid[0] + else: + logging.error("""The registered labels files could not be linked to any existing + annotation file, with value: {}""".format(rl)) + patient_parameters.get_annotation_by_uid(anno_volume_uid).import_registered_volume(filepath=rl, + registration_space=pip_step["fixed"]["sequence"]) + # Collecting the atlas cortical structures if UserPreferencesStructure.getInstance().compute_cortical_structures: cortical_folder = os.path.join(patient_parameters.output_folder, 'reporting', @@ -108,6 +154,16 @@ def collect_results(patient_parameters, pipeline): reference='Patient') results['Atlas'].append(data_uid) + # @TODO. Hard-coded MNI space for now as it is the only atlas space in use + ori_structure_filename = os.path.join(patient_parameters.output_folder, 'reporting', + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + dest_structure_filename = os.path.join(patient_parameters.output_folder, + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + shutil.copyfile(src=ori_structure_filename, dst=dest_structure_filename) + patient_parameters.get_atlas_by_uid(data_uid).import_atlas_in_registration_space( + filepath=dest_structure_filename, registration_space="MNI") # Collecting the atlas subcortical structures if UserPreferencesStructure.getInstance().compute_subcortical_structures: @@ -142,6 +198,16 @@ def collect_results(patient_parameters, pipeline): reference='Patient') results['Atlas'].append(data_uid) + # @TODO. Hard-coded MNI space for now as it is the only atlas space in use + ori_structure_filename = os.path.join(patient_parameters.output_folder, 'reporting', + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + dest_structure_filename = os.path.join(patient_parameters.output_folder, + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + shutil.copyfile(src=ori_structure_filename, dst=dest_structure_filename) + patient_parameters.get_atlas_by_uid(data_uid).import_atlas_in_registration_space( + filepath=dest_structure_filename, registration_space="MNI") # Collecting the atlas BrainGrid structures if UserPreferencesStructure.getInstance().compute_braingrid_structures: @@ -172,6 +238,16 @@ def collect_results(patient_parameters, pipeline): reference='Patient') results['Atlas'].append(data_uid) + # @TODO. Hard-coded MNI space for now as it is the only atlas space in use + ori_structure_filename = os.path.join(patient_parameters.output_folder, 'reporting', + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + dest_structure_filename = os.path.join(patient_parameters.output_folder, + 'atlas_descriptions', + 'MNI_' + m.split('_')[1] + '_structures.nii.gz') + shutil.copyfile(src=ori_structure_filename, dst=dest_structure_filename) + patient_parameters.get_atlas_by_uid(data_uid).import_atlas_in_registration_space( + filepath=dest_structure_filename, registration_space="MNI") elif pip_step["task"] == "Features computation": report_filename = os.path.join(patient_parameters.output_folder, 'reporting', diff --git a/utils/software_config.py b/utils/software_config.py index aac3c43..15d325b 100644 --- a/utils/software_config.py +++ b/utils/software_config.py @@ -24,7 +24,7 @@ class SoftwareConfigResources: __instance = None _software_home_location = None # Main dump location for the software elements (e.g., models, runtime log) _user_preferences_filename = None # json file containing the user preferences (for when reopening the software). - _session_log_filename = None # log filename containing the runtime logging for each software execution. + _session_log_filename = None # log filename containing the runtime logging for each software execution and backend. _software_version = "1.2.3" # Current software version (minor) for selecting which models to use in the backend. _software_medical_specialty = "neurology" # Overall medical target [neurology, thoracic] @@ -48,6 +48,7 @@ def __setup(self): self._software_home_location = os.path.join(expanduser('~'), '.raidionics') if not os.path.exists(self._software_home_location): os.makedirs(self._software_home_location) + os.makedirs(self._software_home_location) self._user_preferences_filename = os.path.join(expanduser('~'), '.raidionics', 'raidionics_preferences.json') self._session_log_filename = os.path.join(expanduser('~'), '.raidionics', 'session_log.log') self.models_path = os.path.join(expanduser('~'), '.raidionics', 'resources', 'models') @@ -59,6 +60,13 @@ def __setup(self): self.accepted_scene_file_format = ['raidionics'] self.accepted_study_file_format = ['sraidionics'] + logger = logging.getLogger() + handler = logging.FileHandler(filename=self._session_log_filename, mode='a', encoding='utf-8') + handler.setFormatter(logging.Formatter(fmt="%(asctime)s ; %(name)s ; %(levelname)s ; %(message)s", + datefmt='%d/%m/%Y %H.%M')) + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + self.__set_default_values() # self._user_preferences = UserPreferencesStructure(self._user_preferences_filename) self.__set_default_stylesheet_components() From d2a19e8475aa677af3e68f6094bcbe3f688de6aa Mon Sep 17 00:00:00 2001 From: dbouget Date: Sat, 21 Sep 2024 14:50:38 +0200 Subject: [PATCH 2/7] Stylesheets in the Settings dialog. Added an error detection thread for user feedback [skip ci] --- gui/RaidionicsMainWindow.py | 25 +- .../CustomQDialog/LogsViewerDialog.py | 1 + .../CustomQDialog/SoftwareSettingsDialog.py | 498 ++++++++++++++++-- utils/data_structures/AnnotationStructure.py | 8 +- utils/data_structures/AtlasStructure.py | 6 +- .../InvestigationTimestampStructure.py | 4 +- utils/data_structures/MRIVolumeStructure.py | 10 +- .../PatientParametersStructure.py | 10 +- utils/data_structures/ReportingStructure.py | 2 +- .../StudyParametersStructure.py | 12 +- utils/models_download.py | 14 +- utils/patient_dicom.py | 6 +- utils/software_config.py | 16 +- 13 files changed, 507 insertions(+), 105 deletions(-) diff --git a/gui/RaidionicsMainWindow.py b/gui/RaidionicsMainWindow.py index b66914f..802c26e 100644 --- a/gui/RaidionicsMainWindow.py +++ b/gui/RaidionicsMainWindow.py @@ -1,6 +1,6 @@ import sys, os from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QMenuBar, QMessageBox,\ - QHBoxLayout, QVBoxLayout, QStackedWidget, QSizePolicy + QHBoxLayout, QVBoxLayout, QStackedWidget, QSizePolicy, QDialog, QErrorMessage from PySide6.QtCore import QUrl, QSize, QThread, Signal, Qt from PySide6.QtGui import QIcon, QDesktopServices, QCloseEvent, QAction import traceback @@ -15,6 +15,7 @@ warnings.simplefilter(action='ignore', category=FutureWarning) from utils.software_config import SoftwareConfigResources +from gui.LogReaderThread import LogReaderThread from gui.WelcomeWidget import WelcomeWidget from gui.SinglePatientComponent.SinglePatientWidget import SinglePatientWidget from gui.StudyBatchComponent.StudyBatchWidget import StudyBatchWidget @@ -52,6 +53,8 @@ def __init__(self, application, *args, **kwargs): self.app.setWindowIcon(QIcon(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'Images/raidionics-icon.png'))) self.app.setStyle("Fusion") # @TODO: Should we remove Fusion style? Looks strange on macOS + self.logs_thread = LogReaderThread() + self.logs_thread.start() self.__set_interface() self.__set_layouts() self.__set_stylesheet() @@ -83,6 +86,7 @@ def __on_exit_software(self) -> None: """ Mirroring of the closeEvent, for when the user press the Quit action in the main menu. """ + self.logs_thread.stop() if not SoftwareConfigResources.getInstance().is_patient_list_empty()\ and SoftwareConfigResources.getInstance().get_active_patient().has_unsaved_changes(): dialog = SavePatientChangesDialog() @@ -348,6 +352,8 @@ def __cross_widgets_connections(self): self.batch_study_widget.patient_report_imported.connect(self.single_patient_widget.patient_report_imported) self.batch_study_widget.patient_radiological_sequences_imported.connect(self.single_patient_widget.patient_radiological_sequences_imported) + self.logs_thread.message.connect(self.on_process_log_message) + def __set_menubar_connections(self): self.home_action.triggered.connect(self.__on_home_clicked) self.single_use_action.triggered.connect(self.__on_single_patient_clicked) @@ -473,9 +479,9 @@ def __on_help_action_triggered(self) -> None: def __on_issues_action_triggered(self) -> None: QDesktopServices.openUrl(QUrl("https://github.com/dbouget/Raidionics/issues")) - def __on_view_logs_triggered(self): + def __on_view_logs_triggered(self) -> None: """ - @TODO. Should make a custom widget as text edit to see the content of the raidionics log file. + Opens up a pop-up dialog allowing to read through the log file. """ diag = LogsViewerDialog(self) diag.exec_() @@ -495,6 +501,19 @@ def __on_save_file_triggered(self): def __on_download_example_data(self): QDesktopServices.openUrl(QUrl("https://drive.google.com/file/d/1W3klW_F7Rfge9-utczz9qp7uWh-pVPS1/view?usp=sharing")) + def on_process_log_message(self, log_msg: str) -> None: + """ + Reading the log file on-the-fly to notify the user in case of software or processing issue to make them + aware of it (in case they don't have the reflex to check the log file). + """ + cases = ["[Software warning]", "[Software error]", "[Backend warning]", "[Backend error]"] + if True in [x in log_msg for x in cases]:#"warning" in log_msg.lower() or "error" in log_msg.lower(): + diag = QErrorMessage(self) + diag.setWindowTitle("Error or warning identified!") + diag.showMessage(log_msg + "\nPlease visit the log file (Settings > Logs)") + diag.setMinimumSize(QSize(400, 150)) + diag.exec() + def standardOutputWritten(self, text): """ Redirecting standard output prints to the (correct) displayed widget diff --git a/gui/UtilsWidgets/CustomQDialog/LogsViewerDialog.py b/gui/UtilsWidgets/CustomQDialog/LogsViewerDialog.py index 2e8aa6e..2f02318 100644 --- a/gui/UtilsWidgets/CustomQDialog/LogsViewerDialog.py +++ b/gui/UtilsWidgets/CustomQDialog/LogsViewerDialog.py @@ -9,6 +9,7 @@ class LogsViewerDialog(QDialog): + error_detected = Signal(str) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Runtime logs") diff --git a/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py b/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py index f216533..54d93d8 100644 --- a/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py +++ b/gui/UtilsWidgets/CustomQDialog/SoftwareSettingsDialog.py @@ -404,6 +404,8 @@ def __set_layout_dimensions(self): self.default_options_label.setFixedHeight(40) self.model_purge_pushbutton.setFixedSize(QSize(20, 20)) self.model_purge_pushbutton.setIconSize(QSize(20, 20)) + self.processing_options_segmentation_models_selector_combobox.setFixedSize(QSize(120, 20)) + self.processing_options_segmentation_refinement_selector_combobox.setFixedSize(QSize(90, 20)) self.processing_options_pushbutton.setFixedHeight(30) self.processing_options_label.setFixedHeight(40) self.appearance_options_pushbutton.setFixedHeight(30) @@ -455,7 +457,10 @@ def __set_connections(self): self.exit_accept_pushbutton.clicked.connect(self.__on_exit_accept_clicked) self.exit_cancel_pushbutton.clicked.connect(self.__on_exit_cancel_clicked) - def __set_stylesheets(self): + def __set_stylesheets(self) -> None: + """ + Main method for setting up the stylesheets and calling the respective stylesheets for each interface part + """ software_ss = SoftwareConfigResources.getInstance().stylesheet_components font_color = software_ss["Color7"] background_color = software_ss["Color5"] @@ -469,6 +474,70 @@ def __set_stylesheets(self): background-color: """ + background_color + """; }""") + self.__set_stylesheets_system_settings() + self.__set_stylesheets_inputs_outputs_settings() + self.__set_stylesheets_processing_segmentation_settings() + self.__set_stylesheets_processing_reporting_settings() + self.__set_stylesheets_display_settings() + + # if os.name == 'nt': + # self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" + # QComboBox{ + # color: """ + font_color + """; + # background-color: """ + background_color + """; + # font: bold; + # font-size: 12px; + # border-style:none; + # } + # QComboBox::hover{ + # border-style: solid; + # border-width: 1px; + # border-color: rgba(196, 196, 196, 1); + # } + # QComboBox::drop-down { + # subcontrol-origin: padding; + # subcontrol-position: top right; + # width: 30px; + # } + # """) + # else: + # self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" + # QComboBox{ + # color: """ + font_color + """; + # background-color: """ + background_color + """; + # font: bold; + # font-size: 12px; + # border-style:none; + # } + # QComboBox::hover{ + # border-style: solid; + # border-width: 1px; + # border-color: rgba(196, 196, 196, 1); + # } + # QComboBox::drop-down { + # subcontrol-origin: padding; + # subcontrol-position: top right; + # width: 30px; + # border-left-width: 1px; + # border-left-color: darkgray; + # border-left-style: none; + # border-top-right-radius: 3px; /* same radius as the QComboBox */ + # border-bottom-right-radius: 3px; + # } + # QComboBox::down-arrow{ + # image: url(""" + os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../../Images/combobox-arrow-icon-10x7.png') + """) + # } + # """) + + def __set_stylesheets_system_settings(self) -> None: + """ + Stylesheets specific for the Widgets inside the System section of Settings + """ + software_ss = SoftwareConfigResources.getInstance().stylesheet_components + font_color = software_ss["Color7"] + background_color = software_ss["Color5"] + pressed_background_color = software_ss["Color6"] + self.default_options_label.setStyleSheet(""" QLabel{ background-color: """ + background_color + """; @@ -479,7 +548,7 @@ def __set_stylesheets(self): border-style: none; }""") - self.model_purge_pushbutton.setStyleSheet(""" + self.default_options_pushbutton.setStyleSheet(""" QPushButton{ background-color: """ + pressed_background_color + """; color: """ + font_color + """; @@ -496,7 +565,44 @@ def __set_stylesheets(self): background-color: """ + pressed_background_color + """; }""") - self.default_options_pushbutton.setStyleSheet(""" + self.home_directory_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.home_directory_lineedit.setStyleSheet(""" + QLineEdit{ + color: """ + font_color + """; + font: 14px; + background-color: """ + background_color + """; + border-style: none; + } + QLineEdit::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + }""") + + self.model_update_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.model_purge_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.model_purge_pushbutton.setStyleSheet(""" QPushButton{ background-color: """ + pressed_background_color + """; color: """ + font_color + """; @@ -513,6 +619,15 @@ def __set_stylesheets(self): background-color: """ + pressed_background_color + """; }""") + def __set_stylesheets_inputs_outputs_settings(self) -> None: + """ + Stylesheets specific for the Widgets inside the Inputs/Outputs section of Settings + """ + software_ss = SoftwareConfigResources.getInstance().stylesheet_components + font_color = software_ss["Color7"] + background_color = software_ss["Color5"] + pressed_background_color = software_ss["Color6"] + self.processing_options_label.setStyleSheet(""" QLabel{ background-color: """ + background_color + """; @@ -540,16 +655,55 @@ def __set_stylesheets(self): background-color: """ + pressed_background_color + """; }""") - self.processing_segmentation_options_label.setStyleSheet(""" + self.processing_options_use_sequences_header_label.setStyleSheet(""" QLabel{ - background-color: """ + background_color + """; color: """ + font_color + """; - text-align: center; - font: 18px; - font-style: bold; - border-style: none; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_options_use_annotations_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_options_use_stripped_inputs_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_options_use_registered_inputs_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_options_export_results_rtstruct_header_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; }""") + def __set_stylesheets_processing_segmentation_settings(self) -> None: + """ + Stylesheets specific for the Widgets inside the Processing-Segmentation section of Settings + """ + software_ss = SoftwareConfigResources.getInstance().stylesheet_components + font_color = software_ss["Color7"] + background_color = software_ss["Color5"] + pressed_background_color = software_ss["Color6"] + self.processing_segmentation_options_pushbutton.setStyleSheet(""" QPushButton{ background-color: """ + background_color + """; @@ -567,55 +721,172 @@ def __set_stylesheets(self): background-color: """ + pressed_background_color + """; }""") - # if os.name == 'nt': - # self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" - # QComboBox{ - # color: """ + font_color + """; - # background-color: """ + background_color + """; - # font: bold; - # font-size: 12px; - # border-style:none; - # } - # QComboBox::hover{ - # border-style: solid; - # border-width: 1px; - # border-color: rgba(196, 196, 196, 1); - # } - # QComboBox::drop-down { - # subcontrol-origin: padding; - # subcontrol-position: top right; - # width: 30px; - # } - # """) - # else: - # self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" - # QComboBox{ - # color: """ + font_color + """; - # background-color: """ + background_color + """; - # font: bold; - # font-size: 12px; - # border-style:none; - # } - # QComboBox::hover{ - # border-style: solid; - # border-width: 1px; - # border-color: rgba(196, 196, 196, 1); - # } - # QComboBox::drop-down { - # subcontrol-origin: padding; - # subcontrol-position: top right; - # width: 30px; - # border-left-width: 1px; - # border-left-color: darkgray; - # border-left-style: none; - # border-top-right-radius: 3px; /* same radius as the QComboBox */ - # border-bottom-right-radius: 3px; - # } - # QComboBox::down-arrow{ - # image: url(""" + os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../../Images/combobox-arrow-icon-10x7.png') + """) - # } - # """) + self.processing_segmentation_options_label.setStyleSheet(""" + QLabel{ + background-color: """ + background_color + """; + color: """ + font_color + """; + text-align: center; + font: 18px; + font-style: bold; + border-style: none; + }""") + + self.processing_segmentation_models_groupbox.setStyleSheet(""" + QGroupBox{ + color: """ + software_ss["Color7"] + """; + font:normal; + font-size:15px; + } + """) + + self.processing_options_segmentation_models_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + if os.name == 'nt': + self.processing_options_segmentation_models_selector_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + } + """) + else: + self.processing_options_segmentation_models_selector_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + border-left-width: 1px; + border-left-color: darkgray; + border-left-style: none; + border-top-right-radius: 3px; /* same radius as the QComboBox */ + border-bottom-right-radius: 3px; + } + QComboBox::down-arrow{ + image: url(""" + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../../Images/combobox-arrow-icon-10x7.png') + """) + } + """) + + self.processing_segmentation_refinement_groupbox.setStyleSheet(""" + QGroupBox{ + color: """ + software_ss["Color7"] + """; + font:normal; + font-size:15px; + } + """) + + self.processing_options_segmentation_refinement_selector_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_options_segmentation_refinement_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + if os.name == 'nt': + self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + } + """) + else: + self.processing_options_segmentation_refinement_selector_combobox.setStyleSheet(""" + QComboBox{ + color: """ + font_color + """; + background-color: """ + background_color + """; + font: bold; + font-size: 12px; + border-style:none; + } + QComboBox::hover{ + border-style: solid; + border-width: 1px; + border-color: rgba(196, 196, 196, 1); + } + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + border-left-width: 1px; + border-left-color: darkgray; + border-left-style: none; + border-top-right-radius: 3px; /* same radius as the QComboBox */ + border-bottom-right-radius: 3px; + } + QComboBox::down-arrow{ + image: url(""" + os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../../Images/combobox-arrow-icon-10x7.png') + """) + } + """) + + self.processing_options_segmentation_refinement_dilation_threshold_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + def __set_stylesheets_processing_reporting_settings(self) -> None: + """ + Stylesheets specific for the Widgets inside the Processing-Reporting section of Settings + """ + software_ss = SoftwareConfigResources.getInstance().stylesheet_components + font_color = software_ss["Color7"] + background_color = software_ss["Color5"] + pressed_background_color = software_ss["Color6"] self.processing_reporting_options_label.setStyleSheet(""" QLabel{ background-color: """ + background_color + """; @@ -643,6 +914,119 @@ def __set_stylesheets(self): background-color: """ + pressed_background_color + """; }""") + self.processing_reporting_cortical_groupbox.setStyleSheet(""" + QGroupBox{ + color: """ + software_ss["Color7"] + """; + font:normal; + font-size:15px; + } + """) + + self.processing_options_compute_corticalstructures_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.corticalstructures_mni_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.corticalstructures_schaefer7_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.corticalstructures_schaefer17_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.corticalstructures_harvardoxford_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_reporting_subcortical_groupbox.setStyleSheet(""" + QGroupBox{ + color: """ + software_ss["Color7"] + """; + font:normal; + font-size:15px; + } + """) + + self.processing_options_compute_subcorticalstructures_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.subcorticalstructures_bcb_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.subcorticalstructures_braingrid_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.processing_reporting_braingrid_groupbox.setStyleSheet(""" + QGroupBox{ + color: """ + software_ss["Color7"] + """; + font:normal; + font-size:15px; + } + """) + + self.processing_options_compute_braingridstructures_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + self.braingridstructures_voxels_label.setStyleSheet(""" + QLabel{ + color: """ + font_color + """; + text-align:left; + font:semibold; + font-size:14px; + }""") + + def __set_stylesheets_display_settings(self) -> None: + """ + Stylesheets specific for the Widgets inside the Display section of Settings + """ + software_ss = SoftwareConfigResources.getInstance().stylesheet_components + font_color = software_ss["Color7"] + background_color = software_ss["Color5"] + pressed_background_color = software_ss["Color6"] + self.appearance_options_label.setStyleSheet(""" QLabel{ background-color: """ + background_color + """; diff --git a/utils/data_structures/AnnotationStructure.py b/utils/data_structures/AnnotationStructure.py index fee2893..cb149be 100644 --- a/utils/data_structures/AnnotationStructure.py +++ b/utils/data_structures/AnnotationStructure.py @@ -437,7 +437,7 @@ def save(self) -> dict: self._unsaved_changes = False return volume_params except Exception: - logging.error("AnnotationStructure saving failed with:\n {}".format(traceback.format_exc())) + logging.error("[Software error] AnnotationStructure saving failed with:\n {}".format(traceback.format_exc())) def import_registered_volume(self, filepath: str, registration_space: str) -> None: """ @@ -455,7 +455,7 @@ def import_registered_volume(self, filepath: str, registration_space: str) -> No registration_space, dest_path)) self._unsaved_changes = True except Exception: - logging.error("Error while importing a registered annotation volume.\n {}".format(traceback.format_exc())) + logging.error("[Software error] Error while importing a registered annotation volume.\n {}".format(traceback.format_exc())) def __init_from_scratch(self) -> None: os.makedirs(self.output_patient_folder, exist_ok=True) @@ -521,7 +521,7 @@ def __reload_from_disk(self, parameters: dict) -> None: self._display_color = parameters['display_color'] self._display_opacity = parameters['display_opacity'] except Exception: - logging.error("""Reloading annotation structure from disk failed + logging.error(""" [Software error] Reloading annotation structure from disk failed for: {}.\n {}""".format(self.display_name, traceback.format_exc())) def __generate_display_volume(self) -> None: @@ -542,5 +542,5 @@ def __generate_display_volume(self) -> None: if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): - logging.warning(""" The selected annotation ({}) does not have any expression in {} space.\n The default annotation in patient space is therefore used.""".format(self.get_annotation_class_str(), + logging.warning(""" [Software warning] The selected annotation ({}) does not have any expression in {} space.\n The default annotation in patient space is therefore used.""".format(self.get_annotation_class_str(), UserPreferencesStructure.getInstance().display_space)) \ No newline at end of file diff --git a/utils/data_structures/AtlasStructure.py b/utils/data_structures/AtlasStructure.py index 4852e99..d189c13 100644 --- a/utils/data_structures/AtlasStructure.py +++ b/utils/data_structures/AtlasStructure.py @@ -347,7 +347,7 @@ def import_atlas_in_registration_space(self, filepath: str, registration_space: registration_space, filepath)) self._unsaved_changes = True except Exception: - logging.error("Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) + logging.error(" [Software error] Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) def save(self) -> dict: """ @@ -405,7 +405,7 @@ def save(self) -> dict: self._unsaved_changes = False return volume_params except Exception: - logging.error("AtlasStructure saving failed with:\n {}".format(traceback.format_exc())) + logging.error(" [Software error] AtlasStructure saving failed with:\n {}".format(traceback.format_exc())) def __generate_display_volume(self) -> None: """ @@ -426,7 +426,7 @@ def __generate_display_volume(self) -> None: if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.atlas_space_volumes.keys(): - logging.warning(""" The selected structure atlas ({}) does not have any expression in {} space.\n The default structure atlas in patient space is therefore used.""".format(self.display_name, + logging.warning(""" [Software warning] The selected structure atlas ({}) does not have any expression in {} space.\n The default structure atlas in patient space is therefore used.""".format(self.display_name, UserPreferencesStructure.getInstance().display_space)) self._visible_class_labels = list(np.unique(self._display_volume)) diff --git a/utils/data_structures/InvestigationTimestampStructure.py b/utils/data_structures/InvestigationTimestampStructure.py index 41c9385..ad745eb 100644 --- a/utils/data_structures/InvestigationTimestampStructure.py +++ b/utils/data_structures/InvestigationTimestampStructure.py @@ -147,7 +147,7 @@ def save(self) -> dict: self._unsaved_changes = False return timestamp_params except Exception: - logging.error("InvestigationTimestampStructure saving failed with:\n {}".format(traceback.format_exc())) + logging.error("[Software error] InvestigationTimestampStructure saving failed with:\n {}".format(traceback.format_exc())) def delete(self) -> None: if os.path.exists(os.path.join(self._output_patient_folder, self._folder_name)): @@ -168,4 +168,4 @@ def __reload_from_disk(self, parameters: dict) -> None: if 'datetime' in list(parameters.keys()) and parameters['datetime']: self._datetime = datetime.datetime.strptime(parameters['datetime'], "%d/%m/%Y, %H:%M:%S") except Exception: - logging.error("InvestigationTimestampStructure reloading from disk failed with:\n {}".format(traceback.format_exc())) + logging.error("[Software error] InvestigationTimestampStructure reloading from disk failed with:\n {}".format(traceback.format_exc())) diff --git a/utils/data_structures/MRIVolumeStructure.py b/utils/data_structures/MRIVolumeStructure.py index aa1b567..97c68b4 100644 --- a/utils/data_structures/MRIVolumeStructure.py +++ b/utils/data_structures/MRIVolumeStructure.py @@ -370,7 +370,7 @@ def delete(self): for k in list(self.registered_volume_filepaths.keys()): os.remove(self.registered_volume_filepaths[k]) except Exception: - logging.error("Error while deleting a radiological volume from disk.\n {}".format(traceback.format_exc())) + logging.error(" [Software error] Error while deleting a radiological volume from disk.\n {}".format(traceback.format_exc())) def save(self) -> dict: """ @@ -432,7 +432,7 @@ def save(self) -> dict: self._contrast_changed = False return volume_params except Exception: - logging.error("MRIVolumeStructure saving failed with:\n {}".format(traceback.format_exc())) + logging.error(" [Software error] MRIVolumeStructure saving failed with:\n {}".format(traceback.format_exc())) def import_registered_volume(self, filepath: str, registration_space: str) -> None: """ @@ -451,7 +451,7 @@ def import_registered_volume(self, filepath: str, registration_space: str) -> No registration_space, dest_path)) self._unsaved_changes = True except Exception: - logging.error("Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) + logging.error("[Software error] Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) def __init_from_scratch(self) -> None: self._timestamp_folder_name = self._timestamp_uid @@ -501,7 +501,7 @@ def __reload_from_disk(self, parameters: dict) -> None: self.set_sequence_type(type=parameters['sequence_type'], manual=False) self.__generate_intensity_histogram() except Exception: - logging.error("""Reloading radiological structure from disk failed + logging.error("""[Software error] Reloading radiological structure from disk failed for: {}.\n {}""".format(self.display_name, traceback.format_exc())) def __parse_sequence_type(self): @@ -555,7 +555,7 @@ def __generate_display_volume(self) -> None: if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): - logging.warning(""" The selected image ({} {}) does not have any expression in {} space.\n The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), + logging.warning(""" [Software warning] The selected image ({} {}) does not have any expression in {} space.\n The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), UserPreferencesStructure.getInstance().display_space)) def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray) -> None: diff --git a/utils/data_structures/PatientParametersStructure.py b/utils/data_structures/PatientParametersStructure.py index 1438fd7..da0c3e9 100644 --- a/utils/data_structures/PatientParametersStructure.py +++ b/utils/data_structures/PatientParametersStructure.py @@ -402,7 +402,7 @@ def import_patient(self, filename: str) -> Any: traceback.format_exc()) except Exception: - error_message = "Import patient failed, from {}.\n".format(os.path.basename(filename)) + str(traceback.format_exc()) + error_message = "[Software error] Import patient failed, from {}.\n".format(os.path.basename(filename)) + str(traceback.format_exc()) logging.error(error_message) return error_message @@ -493,7 +493,7 @@ def import_data(self, filename: str, investigation_ts: str = None, investigation inv_ts_uid=investigation_ts, inv_ts_folder_name=investigation_ts_folder_name) else: - error_message = "No MRI volume has been imported yet. Mandatory for importing an annotation." + error_message = "[Software error] No MRI volume has been imported yet. Mandatory for importing an annotation." logging.error(error_message) except Exception as e: error_message = traceback.format_exc() @@ -558,7 +558,7 @@ def import_dicom_data(self, dicom_series: DICOMSeries, inv_ts: str = None) -> Un except Exception: if ori_filename and os.path.exists(ori_filename): os.remove(ori_filename) - logging.error("Import DICOM data failed with\n {}".format(traceback.format_exc())) + logging.error("[Software error] Import DICOM data failed with\n {}".format(traceback.format_exc())) error_msg = error_msg + traceback.format_exc() if error_msg else traceback.format_exc() return uid, error_msg @@ -691,7 +691,7 @@ def set_new_timestamp_display_name(self, ts_uid: str, display_name: str) -> None for im in list(self.get_all_reporting_uids_for_timestamp(timestamp_uid=ts_uid)): self._reportings[im].timestamp_folder_name = self._investigation_timestamps[ts_uid].folder_name except Exception as e: - logging.error("[PatientParametersStructure] Changing the timestamp display name to {} failed" + logging.error("[Software error] PatientParametersStructure - Changing the timestamp display name to {} failed" " with:\n {}".format(display_name, traceback.format_exc())) @property @@ -1093,7 +1093,7 @@ def insert_investigation_timestamp(self, order: int) -> Tuple[str, int]: logging.info("New investigation timestamp inserted with uid: {}".format(investigation_uid)) self._unsaved_changes = True except Exception as e: - logging.error("Inserting a new investigation timestamp failed with: {}".format(traceback.format_exc())) + logging.error("[Software error] Inserting a new investigation timestamp failed with: {}".format(traceback.format_exc())) error_code = 1 return investigation_uid, error_code diff --git a/utils/data_structures/ReportingStructure.py b/utils/data_structures/ReportingStructure.py index 0469d1d..469bf2f 100644 --- a/utils/data_structures/ReportingStructure.py +++ b/utils/data_structures/ReportingStructure.py @@ -223,4 +223,4 @@ def save(self) -> dict: self._unsaved_changes = False return report_params except Exception: - logging.error("ReportingStructure saving failed with:\n {}".format(traceback.format_exc())) + logging.error("[Software error] ReportingStructure saving failed with:\n {}".format(traceback.format_exc())) diff --git a/utils/data_structures/StudyParametersStructure.py b/utils/data_structures/StudyParametersStructure.py index 9e4d39d..7f92348 100644 --- a/utils/data_structures/StudyParametersStructure.py +++ b/utils/data_structures/StudyParametersStructure.py @@ -254,13 +254,16 @@ def import_study(self, filename: str) -> Union[None, str]: if 'Statistics' in self._study_parameters.keys(): if 'annotations_filename' in self._study_parameters['Statistics'].keys(): - self._segmentation_statistics_filename = os.path.join(self._output_study_folder, self._study_parameters['Statistics']['annotations_filename']) + self._segmentation_statistics_filename = os.path.join(self._output_study_folder, + self._study_parameters['Statistics']['annotations_filename']) self._segmentation_statistics_df = pd.read_csv(self._segmentation_statistics_filename) if 'reportings_filename' in self._study_parameters['Statistics'].keys(): - self._reporting_statistics_filename = os.path.join(self._output_study_folder, self._study_parameters['Statistics']['reportings_filename']) + self._reporting_statistics_filename = os.path.join(self._output_study_folder, + self._study_parameters['Statistics']['reportings_filename']) self._reporting_statistics_df = pd.read_csv(self._reporting_statistics_filename) except Exception: - error_message = "Import study failed, from {}.\n".format(os.path.basename(filename)) + str(traceback.format_exc()) + error_message = "[Software error] Import study failed, from {}.\n".format( + os.path.basename(filename)) + str(traceback.format_exc()) logging.error(error_message) return error_message @@ -280,7 +283,8 @@ def save(self) -> None: # Saving the study-specific parameters. self._last_editing_timestamp = datetime.datetime.now(tz=dateutil.tz.gettz(name='Europe/Oslo')) - self._study_parameters_filename = os.path.join(self._output_study_folder, self._display_name.strip().lower().replace(" ", "_") + '_study.sraidionics') + self._study_parameters_filename = os.path.join(self._output_study_folder, + self._display_name.strip().lower().replace(" ", "_") + '_study.sraidionics') self._study_parameters['Default']['unique_id'] = self._unique_id self._study_parameters['Default']['display_name'] = self._display_name self._study_parameters['Default']['creation_timestamp'] = self._creation_timestamp.strftime("%d/%m/%Y, %H:%M:%S") diff --git a/utils/models_download.py b/utils/models_download.py index 5aa66c6..5c42946 100644 --- a/utils/models_download.py +++ b/utils/models_download.py @@ -99,12 +99,9 @@ def download_model_ori(model_name): if d == d: download_model(d) else: - print("No model exists with the provided name: {}.\n".format(model_name)) - logging.error("No model exists with the provided name: {}.\n".format(model_name)) + logging.error("[Software error] No model exists with the provided name: {}.\n".format(model_name)) except Exception as e: - print('Issue trying to collect the latest {} model.\n'.format(model_name)) - print('{}'.format(traceback.format_exc())) - logging.error('Issue trying to collect the latest {} model with: \n {}'.format(model_name, + logging.error('[Software error] Issue trying to collect the latest {} model with: \n {}'.format(model_name, traceback.format_exc())) @@ -170,10 +167,7 @@ def download_model(model_name: str): if d == d: download_model(d) else: - print("No model exists with the provided name: {}.\n".format(model_name)) - logging.error("No model exists with the provided name: {}.\n".format(model_name)) + logging.error("[Software error] No model exists with the provided name: {}.\n".format(model_name)) except Exception as e: - print('Issue trying to collect the latest {} model.\n'.format(model_name)) - print('{}'.format(traceback.format_exc())) - logging.error('Issue trying to collect the latest {} model with: \n {}'.format(model_name, + logging.error('[Software error] Issue trying to collect the latest {} model with: \n {}'.format(model_name, traceback.format_exc())) diff --git a/utils/patient_dicom.py b/utils/patient_dicom.py index 4e0b654..809c39e 100644 --- a/utils/patient_dicom.py +++ b/utils/patient_dicom.py @@ -45,7 +45,7 @@ def parse_dicom_folder(self): self.gender = dicom_series.get_patient_gender() except Exception as e: - error_msg = """Provided folder does not contain any DICOM folder tree, nor can it be parsed as a + error_msg = """[Software error] Provided folder does not contain any DICOM folder tree, nor can it be parsed as a single MRI volume.\n Loading aborted with error:\n {}.""".format(traceback.format_exc()) logging.error(error_msg) return error_msg @@ -58,7 +58,7 @@ def parse_dicom_folder(self): break if len(main_dicom_dir) == 0: - error_msg = """Provided folder does not contain any proper DICOM folder hierarchy.\n + error_msg = """ [Software error] Provided folder does not contain any proper DICOM folder hierarchy.\n Loading aborted.""" logging.error(error_msg) return error_msg @@ -141,7 +141,7 @@ def parse_dicom_folder(self): logging.warning("DICOM Series reading issue with:\n {}".format(traceback.format_exc())) continue except Exception as e: - error_msg = """Provided DICOM could not be processed.\n + error_msg = """[Software error] Provided DICOM could not be processed.\n Encountered issue: {}.""".format(traceback.format_exc()) logging.error(error_msg) return error_msg diff --git a/utils/software_config.py b/utils/software_config.py index 15d325b..a37f295 100644 --- a/utils/software_config.py +++ b/utils/software_config.py @@ -139,7 +139,7 @@ def add_new_empty_patient(self, active: bool = True) -> Union[str, Any]: if active: self.set_active_patient(patient_uid) except Exception: - error_message = "Error while trying to create a new empty patient: \n" + error_message = "[Software error] Error while trying to create a new empty patient: \n" error_message = error_message + traceback.format_exc() logging.error(error_message) return patient_uid, error_message @@ -180,7 +180,7 @@ def load_patient(self, filename: str, active: bool = True) -> Union[str, Any]: # Doing the following rather than set_active_patient(), to avoid the overhead of doing memory release/load. self.active_patient_name = patient_id except Exception: - error_message = "Error while trying to load a patient: \n" + error_message = "[Software error] Error while trying to load a patient: \n" error_message = error_message + traceback.format_exc() logging.error(error_message) return patient_id, error_message @@ -217,8 +217,8 @@ def set_active_patient(self, patient_uid: str) -> Any: if patient_uid: self.patients_parameters[self.active_patient_name].load_in_memory() except Exception: - logging.error("Setting {} as active patient failed, with {}.\n".format(os.path.basename(patient_uid), - str(traceback.format_exc()))) + logging.error("[Software error] Setting {} as active patient failed, with {}.\n".format(os.path.basename(patient_uid), + str(traceback.format_exc()))) return error_message def is_patient_list_empty(self) -> bool: @@ -302,7 +302,7 @@ def add_new_empty_study(self, active: bool = True) -> Union[str, Any]: if active: self.set_active_study(study_uid) except Exception: - error_message = "Error while trying to create a new empty study: \n" + error_message = "[Software error] Error while trying to create a new empty study: \n" error_message = error_message + traceback.format_exc() logging.error(error_message) return study_uid, error_message @@ -354,7 +354,7 @@ def load_study(self, filename: str, active: bool = True) -> Union[str, Any]: if pat_err_mnsg: error_message = error_message + "\n" + pat_err_mnsg except Exception: - error_message = "Error while trying to load a study: \n" + error_message = "[Software error] Error while trying to load a study: \n" error_message = error_message + traceback.format_exc() logging.error(error_message) @@ -394,8 +394,8 @@ def set_active_study(self, study_uid: str) -> Any: if self.active_study_name: self.study_parameters[self.active_study_name].load_in_memory() except Exception: - error_message = "Setting {} as active study failed, with {}.\n".format(os.path.basename(study_uid), - str(traceback.format_exc())) + error_message = "[Software error] Setting {} as active study failed, with {}.\n".format(os.path.basename(study_uid), + str(traceback.format_exc())) logging.error(error_message) return error_message From 6f20a5574f8cca73bfd521467ac1d9caa1c4e972 Mon Sep 17 00:00:00 2001 From: dbouget Date: Mon, 23 Sep 2024 17:25:28 +0200 Subject: [PATCH 3/7] Improvement for results handling when displaying in atlas space[skip ci] --- gui/RaidionicsMainWindow.py | 2 +- .../SinglePatientWidget.py | 1 + utils/data_structures/AnnotationStructure.py | 23 +++++++++++------ utils/data_structures/AtlasStructure.py | 25 +++++++++++-------- utils/data_structures/MRIVolumeStructure.py | 6 +++-- utils/logic/PipelineResultsCollector.py | 4 +++ 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/gui/RaidionicsMainWindow.py b/gui/RaidionicsMainWindow.py index 802c26e..181f256 100644 --- a/gui/RaidionicsMainWindow.py +++ b/gui/RaidionicsMainWindow.py @@ -507,7 +507,7 @@ def on_process_log_message(self, log_msg: str) -> None: aware of it (in case they don't have the reflex to check the log file). """ cases = ["[Software warning]", "[Software error]", "[Backend warning]", "[Backend error]"] - if True in [x in log_msg for x in cases]:#"warning" in log_msg.lower() or "error" in log_msg.lower(): + if True in [x in log_msg for x in cases]: diag = QErrorMessage(self) diag.setWindowTitle("Error or warning identified!") diag.showMessage(log_msg + "\nPlease visit the log file (Settings > Logs)") diff --git a/gui/SinglePatientComponent/SinglePatientWidget.py b/gui/SinglePatientComponent/SinglePatientWidget.py index 8e87287..4387a0c 100644 --- a/gui/SinglePatientComponent/SinglePatientWidget.py +++ b/gui/SinglePatientComponent/SinglePatientWidget.py @@ -301,6 +301,7 @@ def on_process_finished(self) -> None: self.results_panel.on_process_finished() # Hides the process tracking to display back the layers interactor for viewing purposes. self.right_panel_stackedwidget.setCurrentIndex(0) + self.center_panel.on_reload_interface() def on_batch_process_started(self) -> None: """ diff --git a/utils/data_structures/AnnotationStructure.py b/utils/data_structures/AnnotationStructure.py index cb149be..3510588 100644 --- a/utils/data_structures/AnnotationStructure.py +++ b/utils/data_structures/AnnotationStructure.py @@ -467,7 +467,12 @@ def __init_from_scratch(self) -> None: output_folder=os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'raw')) - self.__generate_display_volume() + image_nib = nib.load(self._usable_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + + if UserPreferencesStructure.getInstance().display_space == 'Patient': + self.__generate_display_volume() def __reload_from_disk(self, parameters: dict) -> None: """ @@ -496,12 +501,18 @@ def __reload_from_disk(self, parameters: dict) -> None: self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] else: # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed + image_nib = nib.load(self._usable_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') self.__generate_display_volume() self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) if os.path.exists(self._display_volume_filepath): self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] else: + image_nib = nib.load(self._usable_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') self.__generate_display_volume() if 'registered_volume_filepaths' in parameters.keys(): @@ -533,14 +544,10 @@ def __generate_display_volume(self) -> None: UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): display_space_anno = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] self._display_volume = deepcopy(display_space_anno) - else: - image_nib = nib.load(self._usable_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - + elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.registered_volumes.keys()) == 0: self._display_volume = deepcopy(self._resampled_input_volume) if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ - UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): - logging.warning(""" [Software warning] The selected annotation ({}) does not have any expression in {} space.\n The default annotation in patient space is therefore used.""".format(self.get_annotation_class_str(), + UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): + logging.warning(""" [Software warning] The selected annotation ({}) does not have any expression in {} space. The default annotation in patient space is therefore used.""".format(self.get_annotation_class_str(), UserPreferencesStructure.getInstance().display_space)) \ No newline at end of file diff --git a/utils/data_structures/AtlasStructure.py b/utils/data_structures/AtlasStructure.py index d189c13..c3aa9f4 100644 --- a/utils/data_structures/AtlasStructure.py +++ b/utils/data_structures/AtlasStructure.py @@ -90,7 +90,14 @@ def __reset(self): self._unsaved_changes = False def __init_from_scratch(self): - self.__generate_display_volume() + image_nib = nib.load(self._raw_input_filepath) + # Resampling to standard output for viewing purposes. + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + + if UserPreferencesStructure.getInstance().display_space == 'Patient': + self.__generate_display_volume() + if not self._class_description_filename or not os.path.exists(self._class_description_filename): logging.info("Atlas provided without a description file with location {}.\n".format(self._raw_input_filepath)) self._class_description_filename = None @@ -121,6 +128,10 @@ def __reload_from_disk(self, parameters: dict) -> None: """ self._raw_input_filepath = os.path.join(self._output_patient_folder, parameters['raw_input_filepath']) self._class_description = pd.read_csv(self._class_description_filename) + # Resampling to standard output for viewing purposes. + image_nib = nib.load(self._raw_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') self.__generate_display_volume() self._display_name = parameters['display_name'] self._parent_mri_uid = parameters['parent_mri_uid'] @@ -415,18 +426,12 @@ def __generate_display_volume(self) -> None: UserPreferencesStructure.getInstance().display_space in self.atlas_space_volumes.keys(): display_space_atlas = self.atlas_space_volumes[UserPreferencesStructure.getInstance().display_space] self._display_volume = deepcopy(display_space_atlas) - else: - image_nib = nib.load(self._raw_input_filepath) - - # Resampling to standard output for viewing purposes. - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - + elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.atlas_space_volumes.keys()) == 0: self._display_volume = deepcopy(self._resampled_input_volume) if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ - UserPreferencesStructure.getInstance().display_space not in self.atlas_space_volumes.keys(): - logging.warning(""" [Software warning] The selected structure atlas ({}) does not have any expression in {} space.\n The default structure atlas in patient space is therefore used.""".format(self.display_name, + UserPreferencesStructure.getInstance().display_space not in self.atlas_space_volumes.keys(): + logging.warning(""" [Software warning] The selected structure atlas ({}) does not have any expression in {} space. The default structure atlas in patient space is therefore used.""".format(self.display_name, UserPreferencesStructure.getInstance().display_space)) self._visible_class_labels = list(np.unique(self._display_volume)) diff --git a/utils/data_structures/MRIVolumeStructure.py b/utils/data_structures/MRIVolumeStructure.py index 97c68b4..279e533 100644 --- a/utils/data_structures/MRIVolumeStructure.py +++ b/utils/data_structures/MRIVolumeStructure.py @@ -539,7 +539,9 @@ def __generate_display_volume(self) -> None: self._contrast_window[0] = int(np.min(display_space_volume)) self._contrast_window[1] = int(np.max(display_space_volume)) self.__apply_contrast_scaling_to_display_volume(display_space_volume) - else: + elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.registered_volumes.keys()) == 0: + # If the option is still set to display in one atlas space, but a new image is loaded (in patient space) + # then the new image must be displayed anyway image_nib = nib.load(self._usable_input_filepath) # Resampling to standard output for viewing purposes. @@ -555,7 +557,7 @@ def __generate_display_volume(self) -> None: if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): - logging.warning(""" [Software warning] The selected image ({} {}) does not have any expression in {} space.\n The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), + logging.warning(""" [Software warning] The selected image ({} {}) does not have any expression in {} space. The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), UserPreferencesStructure.getInstance().display_space)) def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray) -> None: diff --git a/utils/logic/PipelineResultsCollector.py b/utils/logic/PipelineResultsCollector.py index 961b697..f7a1805 100644 --- a/utils/logic/PipelineResultsCollector.py +++ b/utils/logic/PipelineResultsCollector.py @@ -1,6 +1,7 @@ import logging import os import shutil +import time import traceback import pandas as pd @@ -299,4 +300,7 @@ def collect_results(patient_parameters, pipeline): logging.error("Could not collect results for step {}.\n Received: {}".format(pipeline[step]["description"], traceback.format_exc())) continue + # When loading all results, potentially with expression in atlas space, a reloading of the patient will recompute + # all display volumes for a proper interface update when the processing done signal is emitted afterwards. + patient_parameters.load_in_memory() return results From 9851bb14c7b5f7309da9514e4afbb06c41b162f3 Mon Sep 17 00:00:00 2001 From: dbouget Date: Tue, 24 Sep 2024 16:33:45 +0200 Subject: [PATCH 4/7] Overall improvement for exception handling [skip ci] --- .../CentralDisplayArea/CustomQGraphicsView.py | 9 +- utils/data_structures/AnnotationStructure.py | 181 +++++++------- utils/data_structures/AtlasStructure.py | 223 ++++++++---------- utils/data_structures/MRIVolumeStructure.py | 204 ++++++++-------- .../PatientParametersStructure.py | 96 ++++---- utils/software_config.py | 29 ++- 6 files changed, 366 insertions(+), 376 deletions(-) diff --git a/gui/SinglePatientComponent/CentralDisplayArea/CustomQGraphicsView.py b/gui/SinglePatientComponent/CentralDisplayArea/CustomQGraphicsView.py index 5cc3b0a..c98cbac 100644 --- a/gui/SinglePatientComponent/CentralDisplayArea/CustomQGraphicsView.py +++ b/gui/SinglePatientComponent/CentralDisplayArea/CustomQGraphicsView.py @@ -89,9 +89,14 @@ def __set_stylesheets(self): self.setStyleSheet("QGraphicsView{background-color:rgb(0,0,0);}") def keyPressEvent(self, event) -> None: + """ + Shortcuts for displaying or hiding elements, only if there is an active patient. + S key: display or hide the tumor annotation. + """ if event.key() == Qt.Key_S: - logging.info("Changing annotations display state.") - self.annotation_display_state_changed.emit() + if not SoftwareConfigResources.getInstance().is_patient_list_empty(): + logging.info("Changing annotations display state.") + self.annotation_display_state_changed.emit() def mousePressEvent(self, event): """ diff --git a/utils/data_structures/AnnotationStructure.py b/utils/data_structures/AnnotationStructure.py index 3510588..e78f05a 100644 --- a/utils/data_structures/AnnotationStructure.py +++ b/utils/data_structures/AnnotationStructure.py @@ -73,7 +73,6 @@ class AnnotationVolume: _generation_type = AnnotationGenerationType.Manual # Generation method for the annotation _display_name = "" _display_volume = None # Displayable version of the annotation volume (e.g., resampled isotropically) - _display_volume_filepath = None _registered_volume_filepaths = {} # List of filepaths on disk with the registered volumes _registered_volumes = {} # List of numpy arrays with the registered volumes _display_opacity = 50 # Percentage indicating the opacity for blending the annotation with the rest @@ -118,7 +117,6 @@ def __reset(self): self._generation_type = AnnotationGenerationType.Manual self._display_name = "" self._display_volume = None - self._display_volume_filepath = None self._registered_volume_filepaths = {} self._registered_volumes = {} self._display_opacity = 50 @@ -131,36 +129,41 @@ def unique_id(self) -> str: return self._unique_id def load_in_memory(self) -> None: - if UserPreferencesStructure.getInstance().display_space == 'Patient': - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] - else: + try: + if UserPreferencesStructure.getInstance().display_space != 'Patient': + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + self.__generate_standardized_input_volume() self.__generate_display_volume() + except Exception as e: + raise ValueError("[AnnotationStructure] Loading in memory failed with: {}".format(e)) def release_from_memory(self) -> None: self._display_volume = None self._resampled_input_volume = None self.registered_volumes = {} - def delete(self): - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - os.remove(self._display_volume_filepath) - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - os.remove(self._resampled_input_volume_filepath) - - # In case the annotation was automatically generated, its raw version lies inside the patient folder, and can be safely erased - if self._raw_input_filepath and self._output_patient_folder in self._raw_input_filepath\ - and os.path.exists(self._raw_input_filepath): - os.remove(self._raw_input_filepath) - if self._usable_input_filepath and self._output_patient_folder in self._usable_input_filepath\ - and os.path.exists(self._usable_input_filepath): - os.remove(self._usable_input_filepath) - - if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) > 0: - for k in list(self.registered_volume_filepaths.keys()): - os.remove(self.registered_volume_filepaths[k]) + def delete(self) -> None: + try: + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + os.remove(self._resampled_input_volume_filepath) + + # In case the annotation was automatically generated, its raw version lies inside the patient folder, + # and can be safely erased + if self._raw_input_filepath and self._output_patient_folder in self._raw_input_filepath\ + and os.path.exists(self._raw_input_filepath): + os.remove(self._raw_input_filepath) + if self._usable_input_filepath and self._output_patient_folder in self._usable_input_filepath\ + and os.path.exists(self._usable_input_filepath): + os.remove(self._usable_input_filepath) + + if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) > 0: + for k in list(self.registered_volume_filepaths.keys()): + os.remove(self.registered_volume_filepaths[k]) + except Exception as e: + logging.error("[Software error] Annotation structure deletion failed with: {}.\n {}".format( + e, traceback.format_exc())) def set_unsaved_changes_state(self, state: bool) -> None: self._unsaved_changes = state @@ -240,9 +243,7 @@ def set_output_patient_folder(self, output_folder: str) -> None: if self._resampled_input_volume_filepath: self._resampled_input_volume_filepath = self._resampled_input_volume_filepath.replace( self._output_patient_folder, output_folder) - if self._display_volume_filepath: - self._display_volume_filepath = self._display_volume_filepath.replace(self._output_patient_folder, - output_folder) + self._output_patient_folder = output_folder @property @@ -307,23 +308,6 @@ def timestamp_folder_name(self, folder_name: str) -> None: self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, rel_path) - if self._display_volume_filepath and \ - self._output_patient_folder in self._display_volume_filepath: - if os.name == 'nt': - path_parts = list(PurePath(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder)).parts[1:]) - rel_path = PurePath() - rel_path = rel_path.joinpath(self._output_patient_folder) - rel_path = rel_path.joinpath(self._timestamp_folder_name) - for x in path_parts: - rel_path = rel_path.joinpath(x) - self._display_volume_filepath = os.fspath(rel_path) - else: - rel_path = '/'.join(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder).split('/')[1:]) - self._display_volume_filepath = os.path.join(self._output_patient_folder, - self._timestamp_folder_name, rel_path) - def get_display_opacity(self) -> int: return self._display_opacity @@ -377,7 +361,8 @@ def set_generation_type(self, generation_type: Union[str, Enum], manual: bool = self._generation_type = generation_type if manual: - logging.debug("Unsaved changes - Annotation volume generation type changed to {}.".format(str(self._generation_type))) + logging.debug("Unsaved changes - Annotation volume generation type changed to {}.".format( + str(self._generation_type))) self._unsaved_changes = True def save(self) -> dict: @@ -386,13 +371,6 @@ def save(self) -> dict: """ try: # Disk operations - if not self._display_volume is None: - self._display_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, - 'display', self._unique_id + '_display.nii.gz') - if not os.path.exists(self._display_volume_filepath): - nib.save(nib.Nifti1Image(self._display_volume, affine=self._default_affine), - self._display_volume_filepath) - if not self._resampled_input_volume is None: self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'display', @@ -418,8 +396,6 @@ def save(self) -> dict: volume_params['resample_input_filepath'] = os.path.relpath(self._resampled_input_volume_filepath, base_patient_folder) - volume_params['display_volume_filepath'] = os.path.relpath(self._display_volume_filepath, - base_patient_folder) if self.registered_volume_filepaths and len(self.registered_volume_filepaths.keys()) != 0: reg_volumes = {} @@ -436,8 +412,9 @@ def save(self) -> dict: volume_params['display_opacity'] = self._display_opacity self._unsaved_changes = False return volume_params - except Exception: - logging.error("[Software error] AnnotationStructure saving failed with:\n {}".format(traceback.format_exc())) + except Exception as e: + logging.error("[Software error] AnnotationStructure saving failed with: {}.\n {}".format( + e, traceback.format_exc())) def import_registered_volume(self, filepath: str, registration_space: str) -> None: """ @@ -458,27 +435,32 @@ def import_registered_volume(self, filepath: str, registration_space: str) -> No logging.error("[Software error] Error while importing a registered annotation volume.\n {}".format(traceback.format_exc())) def __init_from_scratch(self) -> None: - os.makedirs(self.output_patient_folder, exist_ok=True) - os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name), exist_ok=True) - os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name, 'raw'), exist_ok=True) - os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name, 'display'), exist_ok=True) - - self._usable_input_filepath = input_file_type_conversion(input_filename=self._raw_input_filepath, - output_folder=os.path.join(self._output_patient_folder, - self._timestamp_folder_name, - 'raw')) - image_nib = nib.load(self._usable_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - - if UserPreferencesStructure.getInstance().display_space == 'Patient': - self.__generate_display_volume() + try: + os.makedirs(self.output_patient_folder, exist_ok=True) + os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name), exist_ok=True) + os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name, 'raw'), exist_ok=True) + os.makedirs(os.path.join(self.output_patient_folder, self._timestamp_folder_name, 'display'), exist_ok=True) + + self._usable_input_filepath = input_file_type_conversion(input_filename=self._raw_input_filepath, + output_folder=os.path.join(self._output_patient_folder, + self._timestamp_folder_name, + 'raw')) + self.__generate_standardized_input_volume() + if self._output_patient_folder not in self.raw_input_filepath: + self.__generate_display_volume() + except Exception as e: + logging.error("""[Software error] Initializing annotation structure from scratch failed + for: {} with: {}.\n {}""".format(self._raw_input_filepath, e, traceback.format_exc())) def __reload_from_disk(self, parameters: dict) -> None: """ Fill all variables in their states when the patient was last saved. In addition, tries to accommodate for potentially missing variables without crashing. - @TODO. Might need a prompt if the loading of some elements failed to warn the user. + + Parameters + --------- + parameters: dict + Dictionary containing all information to reload as saved on disk inside the .raidionics file. """ try: if os.path.exists(parameters['raw_input_filepath']): @@ -501,19 +483,7 @@ def __reload_from_disk(self, parameters: dict) -> None: self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] else: # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed - image_nib = nib.load(self._usable_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - self.__generate_display_volume() - - self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) - if os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - else: - image_nib = nib.load(self._usable_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - self.__generate_display_volume() + self.__generate_standardized_input_volume() if 'registered_volume_filepaths' in parameters.keys(): for k in list(parameters['registered_volume_filepaths'].keys()): @@ -525,27 +495,48 @@ def __reload_from_disk(self, parameters: dict) -> None: self.set_generation_type(generation_type=parameters['generation_type'], manual=False) self._parent_mri_uid = parameters['parent_mri_uid'] self._timestamp_uid = parameters['investigation_timestamp_uid'] - self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] + self._timestamp_folder_name = parameters['resample_input_filepath'].split('/')[0] if os.name == 'nt': - self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] + self._timestamp_folder_name = list(PurePath(parameters['resample_input_filepath']).parts)[0] self._display_name = parameters['display_name'] self._display_color = parameters['display_color'] self._display_opacity = parameters['display_opacity'] - except Exception: + except Exception as e: logging.error(""" [Software error] Reloading annotation structure from disk failed - for: {}.\n {}""".format(self.display_name, traceback.format_exc())) + for: {} with: {}.\n {}""".format(self.display_name, e, traceback.format_exc())) + + def __generate_standardized_input_volume(self) -> None: + """ + In order to make sure the annotation volume will be displayed correctly across the three views, a + standardization is necessary to set the volume orientation to a common standard. + """ + try: + if not self._usable_input_filepath or not os.path.exists(self._usable_input_filepath): + raise NameError("Usable input filepath does not exist on disk with value: {}".format( + self._usable_input_filepath)) + + image_nib = nib.load(self._usable_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + except Exception as e: + raise RuntimeError("Input volume standardization failed with: {}".format(e)) def __generate_display_volume(self) -> None: """ - @TODO. What if there is no annotation in the registration space? + Generate a display-compatible copy of the annotation volume, either in raw patient space or any atlas space. + If the viewing should be performed in a desired reference space, but no annotation has been generated for it, + the default annotation in patient space will be shown. + + A display copy of the annotation volume is set up, allowing for on-the-fly modifications. + @TODO. Check if more than one label in the file? """ + base_volume = self._resampled_input_volume + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): - display_space_anno = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] - self._display_volume = deepcopy(display_space_anno) - elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.registered_volumes.keys()) == 0: - self._display_volume = deepcopy(self._resampled_input_volume) + base_volume = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] + self._display_volume = deepcopy(base_volume) if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): diff --git a/utils/data_structures/AtlasStructure.py b/utils/data_structures/AtlasStructure.py index c3aa9f4..9e3211a 100644 --- a/utils/data_structures/AtlasStructure.py +++ b/utils/data_structures/AtlasStructure.py @@ -27,7 +27,6 @@ class AtlasVolume: _resampled_input_volume_filepath = None # Filepath for storing the aforementioned volume _display_name = "" # Visible and editable name for identifying the current Atlas _display_volume = None - _display_volume_filepath = "" # Display MRI volume filepath, in its latest state after potential user modifiers _atlas_space_filepaths = {} # List of atlas structures filepaths on disk expressed in an atlas space _atlas_space_volumes = {} # List of numpy arrays with the atlases structures expressed in an atlas space _parent_mri_uid = "" # Internal unique identifier for the MRI volume to which this annotation is linked @@ -75,7 +74,6 @@ def __reset(self): self._resampled_input_volume_filepath = None self._display_name = "" self._display_volume = None - self._display_volume_filepath = "" self._atlas_space_filepaths = {} self._atlas_space_volumes = {} self._parent_mri_uid = "" @@ -89,36 +87,38 @@ def __reset(self): self._default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] self._unsaved_changes = False - def __init_from_scratch(self): - image_nib = nib.load(self._raw_input_filepath) - # Resampling to standard output for viewing purposes. - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - - if UserPreferencesStructure.getInstance().display_space == 'Patient': - self.__generate_display_volume() + def __init_from_scratch(self) -> None: + """ - if not self._class_description_filename or not os.path.exists(self._class_description_filename): - logging.info("Atlas provided without a description file with location {}.\n".format(self._raw_input_filepath)) - self._class_description_filename = None - return - - self._class_description = pd.read_csv(self._class_description_filename) - self._display_name = self._unique_id - if "Schaefer7" in self._unique_id: - self._display_name = "Schaefer 7" - elif "Schaefer17" in self._unique_id: - self._display_name = "Schaefer 17" - elif "Harvard" in self._unique_id: - self._display_name = "Harvard-Oxford" - elif "BCB" in self._unique_id: - self._display_name = "BCB WM" - elif "Voxels" in self._unique_id: - self._display_name = "BrainGrid Voxels" - elif "BrainGrid" in self._unique_id: - self._display_name = "BrainGrid WM" - elif "MNI" in self._unique_id: - self._display_name = "MNI group" + """ + try: + self.__generate_standardized_input_volume() + # self.__generate_display_volume() + + if not self._class_description_filename or not os.path.exists(self._class_description_filename): + logging.info("Atlas provided without a description file with location {}.\n".format(self._raw_input_filepath)) + self._class_description_filename = None + return + + self._class_description = pd.read_csv(self._class_description_filename) + self._display_name = self._unique_id + if "Schaefer7" in self._unique_id: + self._display_name = "Schaefer 7" + elif "Schaefer17" in self._unique_id: + self._display_name = "Schaefer 17" + elif "Harvard" in self._unique_id: + self._display_name = "Harvard-Oxford" + elif "BCB" in self._unique_id: + self._display_name = "BCB WM" + elif "Voxels" in self._unique_id: + self._display_name = "BrainGrid Voxels" + elif "BrainGrid" in self._unique_id: + self._display_name = "BrainGrid WM" + elif "MNI" in self._unique_id: + self._display_name = "MNI group" + except Exception as e: + logging.error("""[Software error] Initializing atlas structure from scratch failed + for: {} with: {}.\n {}""".format(self._raw_input_filepath, e, traceback.format_exc())) def __reload_from_disk(self, parameters: dict) -> None: """ @@ -126,39 +126,44 @@ def __reload_from_disk(self, parameters: dict) -> None: potentially missing variables without crashing. @TODO. Might need a prompt if the loading of some elements failed to warn the user. """ - self._raw_input_filepath = os.path.join(self._output_patient_folder, parameters['raw_input_filepath']) - self._class_description = pd.read_csv(self._class_description_filename) - # Resampling to standard output for viewing purposes. - image_nib = nib.load(self._raw_input_filepath) - resampled_input_ni = resample_to_output(image_nib, order=0) - self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') - self.__generate_display_volume() - self._display_name = parameters['display_name'] - self._parent_mri_uid = parameters['parent_mri_uid'] - self._timestamp_uid = parameters['investigation_timestamp_uid'] - self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] - if os.name == 'nt': - self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] - - if 'atlas_space_filepaths' in parameters.keys(): - for k in list(parameters['atlas_space_filepaths'].keys()): - self.atlas_space_filepaths[k] = os.path.join(self._output_patient_folder, - parameters['atlas_space_filepaths'][k]) - self.atlas_space_volumes[k] = nib.load(self.atlas_space_filepaths[k]).get_fdata()[:] - - if 'display_colors' in parameters.keys(): - self._class_display_color = {int(k): v for k, v in parameters['display_colors'].items()} - if 'display_opacities' in parameters.keys(): - self._class_display_opacity = {int(k): v for k, v in parameters['display_opacities'].items()} + try: + self._display_name = parameters['display_name'] + self._parent_mri_uid = parameters['parent_mri_uid'] + self._timestamp_uid = parameters['investigation_timestamp_uid'] + self._timestamp_folder_name = parameters['raw_input_filepath'].split('/')[0] + if os.name == 'nt': + self._timestamp_folder_name = list(PurePath(parameters['raw_input_filepath|']).parts)[0] - def load_in_memory(self) -> None: - if UserPreferencesStructure.getInstance().display_space == 'Patient': - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] + self._raw_input_filepath = os.path.join(self._output_patient_folder, parameters['raw_input_filepath']) + self._class_description = pd.read_csv(self._class_description_filename) + + self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, + parameters['resample_input_filepath']) + if os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] else: - self.__generate_display_volume() - else: + # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed + self.__generate_standardized_input_volume() + + if 'atlas_space_filepaths' in parameters.keys(): + for k in list(parameters['atlas_space_filepaths'].keys()): + self.atlas_space_filepaths[k] = os.path.join(self._output_patient_folder, + parameters['atlas_space_filepaths'][k]) + self.atlas_space_volumes[k] = nib.load(self.atlas_space_filepaths[k]).get_fdata()[:] + + if 'display_colors' in parameters.keys(): + self._class_display_color = {int(k): v for k, v in parameters['display_colors'].items()} + if 'display_opacities' in parameters.keys(): + self._class_display_opacity = {int(k): v for k, v in parameters['display_opacities'].items()} + except Exception as e: + logging.error(""" [Software error] Reloading atlas structure from disk failed + for: {} with: {}.\n {}""".format(self.display_name, e, traceback.format_exc())) + + def load_in_memory(self) -> None: + try: self.__generate_display_volume() + except Exception as e: + raise ValueError("[AtlasStructure] Loading in memory failed with: {}".format(e)) def release_from_memory(self) -> None: self._display_volume = None @@ -261,9 +266,6 @@ def set_output_patient_folder(self, output_folder: str) -> None: if self._resampled_input_volume_filepath: self._resampled_input_volume_filepath = self._resampled_input_volume_filepath.replace(self._output_patient_folder, output_folder) - if self._display_volume_filepath: - self._display_volume_filepath = self._display_volume_filepath.replace(self._output_patient_folder, - output_folder) self._output_patient_folder = output_folder @property @@ -312,22 +314,6 @@ def timestamp_folder_name(self, folder_name: str) -> None: self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, rel_path) - if self._display_volume_filepath: - if os.name == 'nt': - path_parts = list(PurePath(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder)).parts[1:]) - rel_path = PurePath() - rel_path = rel_path.joinpath(self._output_patient_folder) - rel_path = rel_path.joinpath(self._timestamp_folder_name) - for x in path_parts: - rel_path = rel_path.joinpath(x) - self._display_volume_filepath = os.fspath(rel_path) - else: - rel_path = '/'.join(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder).split('/')[1:]) - self._display_volume_filepath = os.path.join(self._output_patient_folder, - self._timestamp_folder_name, - rel_path) def get_class_description(self) -> Union[pd.DataFrame, dict]: return self._class_description @@ -339,12 +325,13 @@ def visible_class_labels(self) -> List: def delete(self): if self._raw_input_filepath and os.path.exists(self._raw_input_filepath): os.remove(self._raw_input_filepath) - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - os.remove(self._display_volume_filepath) if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): os.remove(self._resampled_input_volume_filepath) if self._class_description_filename and os.path.exists(self._class_description_filename): os.remove(self._class_description_filename) + if len(self.atlas_space_volumes.keys()) != 0: + for k in self.atlas_space_volumes.keys(): + os.remove(self.atlas_space_volumes[k]) def import_atlas_in_registration_space(self, filepath: str, registration_space: str) -> None: """ @@ -366,10 +353,6 @@ def save(self) -> dict: """ try: # Disk operations - self._display_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, - 'display', self._unique_id + '_display.nii.gz') - nib.save(nib.Nifti1Image(self._display_volume, affine=self._default_affine), self._display_volume_filepath) - self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'display', self._unique_id + '_resampled.nii.gz') @@ -380,33 +363,17 @@ def save(self) -> dict: volume_params = {} volume_params['display_name'] = self._display_name base_patient_folder = self._output_patient_folder - # base_patient_folder = '/'.join(self._output_patient_folder.split('/')[:-1]) # To keep the timestamp folder - # if os.name == 'nt': - # base_patient_folder_parts = list(PurePath(os.path.realpath(self._output_patient_folder)).parts[:-1]) - # base_patient_folder = PurePath() - # for x in base_patient_folder_parts: - # base_patient_folder = base_patient_folder.joinpath(x) - volume_params['raw_input_filepath'] = os.path.relpath(self._raw_input_filepath, base_patient_folder) volume_params['resample_input_filepath'] = os.path.relpath(self._resampled_input_volume_filepath, base_patient_folder) - volume_params['display_volume_filepath'] = os.path.relpath(self._display_volume_filepath, - base_patient_folder) if self._class_description_filename: - # base_patient_folder = '/'.join( - # self._output_patient_folder.split('/')[:-1]) # To reach the root patient folder - # if os.name == 'nt': - # base_patient_folder_parts = list(PurePath(os.path.realpath(self._output_patient_folder)).parts[:-1]) - # base_patient_folder = PurePath() - # for x in base_patient_folder_parts: - # base_patient_folder = base_patient_folder.joinpath(x) volume_params['description_filepath'] = os.path.relpath(self._class_description_filename, self._output_patient_folder) if self.atlas_space_filepaths and len(self.atlas_space_filepaths.keys()) != 0: atlas_volumes = {} for k in list(self.atlas_space_filepaths.keys()): - atlas_volumes[k] = os.path.relpath(self.atlas_space_filepaths[k], self._output_patient_folder) + atlas_volumes[k] = os.path.relpath(self.atlas_space_filepaths[k], base_patient_folder) volume_params['atlas_space_filepaths'] = atlas_volumes volume_params['parent_mri_uid'] = self._parent_mri_uid @@ -418,30 +385,48 @@ def save(self) -> dict: except Exception: logging.error(" [Software error] AtlasStructure saving failed with:\n {}".format(traceback.format_exc())) + def __generate_standardized_input_volume(self) -> None: + """ + In order to make sure the atlas volume will be displayed correctly across the three views, a + standardization is necessary to set the volume orientation to a common standard. + """ + try: + if not self._raw_input_filepath or not os.path.exists(self._raw_input_filepath): + raise NameError("Raw input filepath does not exist on disk with value: {}".format( + self._raw_input_filepath)) + + image_nib = nib.load(self._raw_input_filepath) + resampled_input_ni = resample_to_output(image_nib, order=0) + self._resampled_input_volume = resampled_input_ni.get_fdata()[:].astype('uint8') + except Exception as e: + raise RuntimeError("Input volume standardization failed with: {}".format(e)) + def __generate_display_volume(self) -> None: """ - Generate a display-compatible volume from the raw MRI volume the first time it is loaded in the software. + Generate a display-compatible volume from the raw atlas volume the first time it is loaded in the software. """ + base_volume = self._resampled_input_volume if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ UserPreferencesStructure.getInstance().display_space in self.atlas_space_volumes.keys(): - display_space_atlas = self.atlas_space_volumes[UserPreferencesStructure.getInstance().display_space] - self._display_volume = deepcopy(display_space_atlas) - elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.atlas_space_volumes.keys()) == 0: - self._display_volume = deepcopy(self._resampled_input_volume) + base_volume = self.atlas_space_volumes[UserPreferencesStructure.getInstance().display_space] + + self._display_volume = deepcopy(base_volume) if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.atlas_space_volumes.keys(): logging.warning(""" [Software warning] The selected structure atlas ({}) does not have any expression in {} space. The default structure atlas in patient space is therefore used.""".format(self.display_name, UserPreferencesStructure.getInstance().display_space)) - self._visible_class_labels = list(np.unique(self._display_volume)) - self._class_number = len(self._visible_class_labels) - 1 - self._one_hot_display_volume = np.zeros(shape=(self._display_volume.shape + (self._class_number + 1,)), - dtype='uint8') - - self._class_display_color = {} - self._class_display_opacity = {} - for c in range(1, self._class_number + 1): - self._class_display_color[c] = [255, 255, 255, 255] - self._class_display_opacity[c] = 50 - self._one_hot_display_volume[..., c][self._display_volume == self._visible_class_labels[c]] = 1 + try: + self._visible_class_labels = list(np.unique(self._display_volume)) + self._class_number = len(self._visible_class_labels) - 1 + self._one_hot_display_volume = np.zeros(shape=(self._display_volume.shape + (self._class_number + 1,)), + dtype='uint8') + self._class_display_color = {} + self._class_display_opacity = {} + for c in range(1, self._class_number + 1): + self._class_display_color[c] = [255, 255, 255, 255] + self._class_display_opacity[c] = 50 + self._one_hot_display_volume[..., c][self._display_volume == self._visible_class_labels[c]] = 1 + except Exception as e: + raise IndexError("Generating a one-hot version of the display volume failed with: {}".format(e)) \ No newline at end of file diff --git a/utils/data_structures/MRIVolumeStructure.py b/utils/data_structures/MRIVolumeStructure.py index 279e533..a422934 100644 --- a/utils/data_structures/MRIVolumeStructure.py +++ b/utils/data_structures/MRIVolumeStructure.py @@ -52,7 +52,6 @@ class MRIVolume: _intensity_histogram = None # _display_name = "" # Name shown to the user to identify the current volume, and which can be modified. _display_volume = None - _display_volume_filepath = "" # Display MRI volume filepath, in its latest state after potential user modifiers _registered_volume_filepaths = {} # List of filepaths on disk with the registered volumes _registered_volumes = {} # List of numpy arrays with the registered volumes _default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], @@ -94,7 +93,6 @@ def __reset(self): self._intensity_histogram = None self._display_name = "" self._display_volume = None - self._display_volume_filepath = "" self._registered_volume_filepaths = {} self._registered_volumes = {} self._default_affine = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] @@ -102,20 +100,21 @@ def __reset(self): self._contrast_changed = False def load_in_memory(self) -> None: - if UserPreferencesStructure.getInstance().display_space == 'Patient': - if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): - self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] - else: - # Should not occur unless the patient was not saved after being loaded. - # @behaviour. it is wanted? - self.__generate_display_volume() - - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - else: - self.__generate_display_volume() - else: + """ + When a new patient is selected for display, its corresponding radiological volumes are loaded into memory + for a smoother visual interaction. + The display volume must be recomputed everytime as the display space might have changed since the last + loading of the patient in memory! + """ + try: + if UserPreferencesStructure.getInstance().display_space != 'Patient': + if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): + self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] + else: + self.__generate_standardized_input_volume() self.__generate_display_volume() + except Exception as e: + raise ValueError("[MRIVolumeStructure] Loading in memory failed with: {}".format(e)) def release_from_memory(self) -> None: self._resampled_input_volume = None @@ -186,21 +185,6 @@ def timestamp_folder_name(self, folder_name: str) -> None: self._output_patient_folder).split('/')[1:]) self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, rel_path) - if self._display_volume_filepath: - if os.name == 'nt': - path_parts = list(PurePath(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder)).parts[1:]) - rel_path = PurePath() - rel_path = rel_path.joinpath(self._output_patient_folder) - rel_path = rel_path.joinpath(self._timestamp_folder_name) - for x in path_parts: - rel_path = rel_path.joinpath(x) - self._display_volume_filepath = os.fspath(rel_path) - else: - rel_path = '/'.join(os.path.relpath(self._display_volume_filepath, - self._output_patient_folder).split('/')[1:]) - self._display_volume_filepath = os.path.join(self._output_patient_folder, - self._timestamp_folder_name, rel_path) def set_unsaved_changes_state(self, state: bool) -> None: self._unsaved_changes = state @@ -245,9 +229,6 @@ def set_output_patient_folder(self, output_folder: str) -> None: if self._usable_input_filepath: self._usable_input_filepath = self._usable_input_filepath.replace(self._output_patient_folder, output_folder) - if self._display_volume_filepath: - self._display_volume_filepath = self._display_volume_filepath.replace(self._output_patient_folder, - output_folder) if self._dicom_metadata_filepath: self._dicom_metadata_filepath = self._dicom_metadata_filepath.replace(self._output_patient_folder, output_folder) @@ -324,8 +305,10 @@ def confirm_contrast_modifications(self) -> None: Since contrast adjustment modifications can be cancelled for various reasons (e.g. not satisfied with the selection), the changes should not be saved until the QDialog has been successfully exited. """ - self._unsaved_changes = True - logging.debug("Unsaved changes - MRI volume contrast range edited.") + pass + # No longer saving this info as the contrast will change if viewing the volume in patient or atlas space. + # self._unsaved_changes = True + # logging.debug("Unsaved changes - MRI volume contrast range edited.") def get_intensity_histogram(self): return self._intensity_histogram @@ -354,8 +337,6 @@ def registered_volumes(self, new_volumes: dict) -> None: def delete(self): try: - if self._display_volume_filepath and os.path.exists(self._display_volume_filepath): - os.remove(self._display_volume_filepath) if self._resampled_input_volume_filepath and os.path.exists(self._resampled_input_volume_filepath): os.remove(self._resampled_input_volume_filepath) @@ -379,13 +360,6 @@ def save(self) -> dict: """ try: # Disk operations - if not self._display_volume is None: - self._display_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, - 'display', self._unique_id + '_display.nii.gz') - if not os.path.exists(self._display_volume_filepath) or self._contrast_changed: - nib.save(nib.Nifti1Image(self._display_volume, affine=self._default_affine), - self._display_volume_filepath) - if not self._resampled_input_volume is None: self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'display', @@ -414,10 +388,7 @@ def save(self) -> dict: volume_params['usable_input_filepath'] = self._usable_input_filepath volume_params['resample_input_filepath'] = os.path.relpath(self._resampled_input_volume_filepath, self._output_patient_folder) - volume_params['display_volume_filepath'] = os.path.relpath(self._display_volume_filepath, - self._output_patient_folder) volume_params['sequence_type'] = str(self._sequence_type) - volume_params['contrast_window'] = str(self._contrast_window[0]) + ',' + str(self._contrast_window[1]) if self._dicom_metadata_filepath: volume_params['dicom_metadata_filepath'] = os.path.relpath(self._dicom_metadata_filepath, self._output_patient_folder) @@ -431,8 +402,9 @@ def save(self) -> dict: self._unsaved_changes = False self._contrast_changed = False return volume_params - except Exception: - logging.error(" [Software error] MRIVolumeStructure saving failed with:\n {}".format(traceback.format_exc())) + except Exception as e: + logging.error("[Software error] MRIVolumeStructure saving failed with: {}.\n {}".format( + e, traceback.format_exc())) def import_registered_volume(self, filepath: str, registration_space: str) -> None: """ @@ -454,55 +426,64 @@ def import_registered_volume(self, filepath: str, registration_space: str) -> No logging.error("[Software error] Error while importing a registered radiological volume.\n {}".format(traceback.format_exc())) def __init_from_scratch(self) -> None: - self._timestamp_folder_name = self._timestamp_uid - os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name), exist_ok=True) - os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'raw'), exist_ok=True) - os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'display'), exist_ok=True) - - self._usable_input_filepath = input_file_type_conversion(input_filename=self._raw_input_filepath, - output_folder=os.path.join(self._output_patient_folder, - self._timestamp_folder_name, - 'raw')) - self.__parse_sequence_type() - self.__generate_display_volume() + try: + self._timestamp_folder_name = self._timestamp_uid + os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name), exist_ok=True) + os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'raw'), exist_ok=True) + os.makedirs(os.path.join(self._output_patient_folder, self._timestamp_folder_name, 'display'), exist_ok=True) + + self._usable_input_filepath = input_file_type_conversion(input_filename=self._raw_input_filepath, + output_folder=os.path.join(self._output_patient_folder, + self._timestamp_folder_name, + 'raw')) + self.__generate_standardized_input_volume() + self.__parse_sequence_type() + self.__generate_display_volume() + except Exception as e: + logging.error("""[Software error] Initializing radiological structure from scratch failed + for: {} with: {}.\n {}""".format(self._raw_input_filepath, e, traceback.format_exc())) def __reload_from_disk(self, parameters: dict) -> None: + """ + Reload all radiological volume information and data from disk. + @TODO. Must include a reloading of the DICOM metadata, if they exist. + + Parameters + ---------- + parameters: dict + Dictionary containing all information to reload as saved on disk inside the .raidionics file. + """ try: + self.set_sequence_type(type=parameters['sequence_type'], manual=False) + self._display_name = parameters['display_name'] + + self._timestamp_folder_name = parameters['resample_input_filepath'].split('/')[0] + if os.name == 'nt': + self._timestamp_folder_name = list(PurePath(parameters['resample_input_filepath']).parts)[0] + if os.path.exists(parameters['usable_input_filepath']): self._usable_input_filepath = parameters['usable_input_filepath'] else: self._usable_input_filepath = os.path.join(self._output_patient_folder, parameters['usable_input_filepath']) - self._contrast_window = [int(x) for x in parameters['contrast_window'].split(',')] - - # The resampled volume can only be inside the output patient folder as it is internally computed and cannot be - # manually imported into the software. + # The resampled volume can only be inside the output patient folder as it is internally computed and cannot + # be manually imported into the software. self._resampled_input_volume_filepath = os.path.join(self._output_patient_folder, parameters['resample_input_filepath']) if os.path.exists(self._resampled_input_volume_filepath): self._resampled_input_volume = nib.load(self._resampled_input_volume_filepath).get_fdata()[:] else: # Patient wasn't saved after loading, hence the volume was not stored on disk and must be recomputed - self.__generate_display_volume() + self.__generate_standardized_input_volume() if 'registered_volume_filepaths' in parameters.keys(): for k in list(parameters['registered_volume_filepaths'].keys()): self.registered_volume_filepaths[k] = os.path.join(self._output_patient_folder, parameters['registered_volume_filepaths'][k]) self.registered_volumes[k] = nib.load(self.registered_volume_filepaths[k]).get_fdata()[:] - - # @TODO. Must include a reloading of the DICOM metadata, if they exist. - self._display_volume_filepath = os.path.join(self._output_patient_folder, parameters['display_volume_filepath']) - self._display_volume = nib.load(self._display_volume_filepath).get_fdata()[:] - self._display_name = parameters['display_name'] - self._timestamp_folder_name = parameters['display_volume_filepath'].split('/')[0] - if os.name == 'nt': - self._timestamp_folder_name = list(PurePath(parameters['display_volume_filepath']).parts)[0] - self.set_sequence_type(type=parameters['sequence_type'], manual=False) - self.__generate_intensity_histogram() - except Exception: + except Exception as e: logging.error("""[Software error] Reloading radiological structure from disk failed - for: {}.\n {}""".format(self.display_name, traceback.format_exc())) + for: {} with {}.\n {}""".format(self.display_name, e, traceback.format_exc())) def __parse_sequence_type(self): base_name = self._unique_id.lower() @@ -517,50 +498,55 @@ def __parse_sequence_type(self): else: self._sequence_type = MRISequenceType.T1w - def __generate_intensity_histogram(self): - # Generate the raw intensity histogram for contrast adjustment - self._intensity_histogram = np.histogram(self._resampled_input_volume[self._resampled_input_volume != 0], - bins=30) - - def __generate_display_volume(self) -> None: + def __generate_intensity_histogram(self, input_array: np.ndarray): """ - Generate a display-compatible volume from the raw MRI volume the first time it is loaded in the software. - - If the viewing should be performed in a desired reference space, but no image has been generated for it, - the default image in patient space will be shown. + Generate the raw intensity histogram for manual contrast adjustment. """ - if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ - UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): - display_space_volume = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] + self._intensity_histogram = np.histogram(input_array[input_array != 0], bins=30) - self.__generate_intensity_histogram() + def __generate_standardized_input_volume(self) -> None: + """ + In order to make sure the radiological volume will be displayed correctly across the three views, a + standardization is necessary to set the volume orientation to a common standard. + """ + try: + if not self._usable_input_filepath or not os.path.exists(self._usable_input_filepath): + raise NameError("Usable input filepath does not exist on disk with value: {}".format( + self._usable_input_filepath)) - # The first time, the intensity boundaries must be retrieved - self._contrast_window[0] = int(np.min(display_space_volume)) - self._contrast_window[1] = int(np.max(display_space_volume)) - self.__apply_contrast_scaling_to_display_volume(display_space_volume) - elif UserPreferencesStructure.getInstance().display_space == 'Patient' or len(self.registered_volumes.keys()) == 0: - # If the option is still set to display in one atlas space, but a new image is loaded (in patient space) - # then the new image must be displayed anyway image_nib = nib.load(self._usable_input_filepath) - - # Resampling to standard output for viewing purposes. resampled_input_ni = resample_to_output(image_nib, order=1) self._resampled_input_volume = resampled_input_ni.get_fdata()[:] + except Exception as e: + raise RuntimeError("Input volume standardization failed with: {}".format(e)) - self.__generate_intensity_histogram() + def __generate_display_volume(self) -> None: + """ + Generate a display-compatible copy of the radiological volume, either in raw patient space or any atlas space. + If the viewing should be performed in a desired reference space, but no image has been generated for it, + the default image in patient space will be shown. - # The first time, the intensity boundaries must be retrieved - self._contrast_window[0] = int(np.min(self._resampled_input_volume)) - self._contrast_window[1] = int(np.max(self._resampled_input_volume)) - self.__apply_contrast_scaling_to_display_volume(self._resampled_input_volume) + A display copy of the radiological volume is set up, allowing for on-the-fly contrast modifications. + """ + try: + base_volume = self._resampled_input_volume + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ + UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): + base_volume = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] + + self.__generate_intensity_histogram(input_array=base_volume) + self._contrast_window[0] = int(np.min(base_volume)) + self._contrast_window[1] = int(np.max(base_volume)) + self.__apply_contrast_scaling_to_display_volume(base_volume) + except Exception as e: + raise RuntimeError("Display volume generation failed with: {}".format(e)) if UserPreferencesStructure.getInstance().display_space != 'Patient' and \ UserPreferencesStructure.getInstance().display_space not in self.registered_volumes.keys(): logging.warning(""" [Software warning] The selected image ({} {}) does not have any expression in {} space. The default image in patient space is therefore used.""".format(self.timestamp_folder_name, self.get_sequence_type_str(), UserPreferencesStructure.getInstance().display_space)) - def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray) -> None: + def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray = None) -> None: """ Generate a display volume according to the contrast parameters set by the user. @@ -569,6 +555,12 @@ def __apply_contrast_scaling_to_display_volume(self, display_volume: np.ndarray) display_volume: np.ndarray Base display volume to use to generate a contrast-scaled version of. """ + if display_volume is None: + display_volume = self._resampled_input_volume + if UserPreferencesStructure.getInstance().display_space != 'Patient' and\ + UserPreferencesStructure.getInstance().display_space in self.registered_volumes.keys(): + display_volume = self.registered_volumes[UserPreferencesStructure.getInstance().display_space] + # Scaling data to uint8 image_res = deepcopy(display_volume) image_res[image_res < self._contrast_window[0]] = self._contrast_window[0] diff --git a/utils/data_structures/PatientParametersStructure.py b/utils/data_structures/PatientParametersStructure.py index da0c3e9..4b62694 100644 --- a/utils/data_structures/PatientParametersStructure.py +++ b/utils/data_structures/PatientParametersStructure.py @@ -156,12 +156,16 @@ def release_from_memory(self) -> None: Otherwise, for computer with limited RAM and many opened patients, freezes/crashes might occur. """ logging.debug("Unloading patient {} from memory.".format(self._unique_id)) - for im in self._mri_volumes: - self._mri_volumes[im].release_from_memory() - for an in self._annotation_volumes: - self._annotation_volumes[an].release_from_memory() - for at in self._atlas_volumes: - self._atlas_volumes[at].release_from_memory() + try: + for im in self._mri_volumes: + self._mri_volumes[im].release_from_memory() + for an in self._annotation_volumes: + self._annotation_volumes[an].release_from_memory() + for at in self._atlas_volumes: + self._atlas_volumes[at].release_from_memory() + except Exception as e: + logging.error("""[Software error] Releasing patient from memory failed with: {}.\n {}""".format( + e, traceback.format_exc())) def load_in_memory(self) -> None: """ @@ -172,12 +176,16 @@ def load_in_memory(self) -> None: to load in memory only if the objects is actually being toggled for viewing. """ logging.debug("Loading patient {} from memory.".format(self._unique_id)) - for im in self._mri_volumes: - self._mri_volumes[im].load_in_memory() - for an in self._annotation_volumes: - self._annotation_volumes[an].load_in_memory() - for at in self._atlas_volumes: - self._atlas_volumes[at].load_in_memory() + try: + for im in self._mri_volumes: + self._mri_volumes[im].load_in_memory() + for an in self._annotation_volumes: + self._annotation_volumes[an].load_in_memory() + for at in self._atlas_volumes: + self._atlas_volumes[at].load_in_memory() + except Exception as e: + logging.error("""[Software error] Loading patient in memory failed with: {}.\n {}""".format( + e, traceback.format_exc())) def set_unsaved_changes_state(self, state: bool) -> None: """ @@ -600,42 +608,46 @@ def save_patient(self) -> None: patient, whereby it is already in memory and should not be released. """ logging.info("Saving patient results in: {}".format(self._output_folder)) - self._last_editing_timestamp = datetime.datetime.now(tz=dateutil.tz.gettz(name='Europe/Oslo')) - self._patient_parameters_dict_filename = os.path.join(self._output_folder, self._display_name.strip().lower().replace(" ", "_") + '_scene.raidionics') - self._patient_parameters_dict['Parameters']['Default']['unique_id'] = self._unique_id - self._patient_parameters_dict['Parameters']['Default']['display_name'] = self._display_name - self._patient_parameters_dict['Parameters']['Default']['creation_timestamp'] = self._creation_timestamp.strftime("%d/%m/%Y, %H:%M:%S") - self._patient_parameters_dict['Parameters']['Default']['last_editing_timestamp'] = self._last_editing_timestamp.strftime("%d/%m/%Y, %H:%M:%S") - - self._patient_parameters_dict['Timestamps'] = {} - self._patient_parameters_dict['Volumes'] = {} - self._patient_parameters_dict['Annotations'] = {} - self._patient_parameters_dict['Atlases'] = {} - self._patient_parameters_dict['Reports'] = {} - - # @TODO. Should the timestamp folder_name be going down here before saving each element? - for i, disp in enumerate(list(self._investigation_timestamps.keys())): - self._patient_parameters_dict['Timestamps'][disp] = self._investigation_timestamps[disp].save() + try: + self._last_editing_timestamp = datetime.datetime.now(tz=dateutil.tz.gettz(name='Europe/Oslo')) + self._patient_parameters_dict_filename = os.path.join(self._output_folder, self._display_name.strip().lower().replace(" ", "_") + '_scene.raidionics') + self._patient_parameters_dict['Parameters']['Default']['unique_id'] = self._unique_id + self._patient_parameters_dict['Parameters']['Default']['display_name'] = self._display_name + self._patient_parameters_dict['Parameters']['Default']['creation_timestamp'] = self._creation_timestamp.strftime("%d/%m/%Y, %H:%M:%S") + self._patient_parameters_dict['Parameters']['Default']['last_editing_timestamp'] = self._last_editing_timestamp.strftime("%d/%m/%Y, %H:%M:%S") + + self._patient_parameters_dict['Timestamps'] = {} + self._patient_parameters_dict['Volumes'] = {} + self._patient_parameters_dict['Annotations'] = {} + self._patient_parameters_dict['Atlases'] = {} + self._patient_parameters_dict['Reports'] = {} + + # @TODO. Should the timestamp folder_name be going down here before saving each element? + for i, disp in enumerate(list(self._investigation_timestamps.keys())): + self._patient_parameters_dict['Timestamps'][disp] = self._investigation_timestamps[disp].save() - for i, disp in enumerate(list(self._mri_volumes.keys())): - self._patient_parameters_dict['Volumes'][disp] = self._mri_volumes[disp].save() + for i, disp in enumerate(list(self._mri_volumes.keys())): + self._patient_parameters_dict['Volumes'][disp] = self._mri_volumes[disp].save() - for i, disp in enumerate(list(self._annotation_volumes.keys())): - self._patient_parameters_dict['Annotations'][disp] = self._annotation_volumes[disp].save() + for i, disp in enumerate(list(self._annotation_volumes.keys())): + self._patient_parameters_dict['Annotations'][disp] = self._annotation_volumes[disp].save() - for i, disp in enumerate(list(self._atlas_volumes.keys())): - self._patient_parameters_dict['Atlases'][disp] = self._atlas_volumes[disp].save() + for i, disp in enumerate(list(self._atlas_volumes.keys())): + self._patient_parameters_dict['Atlases'][disp] = self._atlas_volumes[disp].save() - for i, disp in enumerate(list(self._reportings.keys())): - self._patient_parameters_dict['Reports'][disp] = self._reportings[disp].save() + for i, disp in enumerate(list(self._reportings.keys())): + self._patient_parameters_dict['Reports'][disp] = self._reportings[disp].save() - if UserPreferencesStructure.getInstance().export_results_as_rtstruct: - self.__convert_results_as_dicom_rtstruct() + if UserPreferencesStructure.getInstance().export_results_as_rtstruct: + self.__convert_results_as_dicom_rtstruct() - # Saving the json file last, as it must be populated from the previous dumps beforehand - with open(self._patient_parameters_dict_filename, 'w') as outfile: - json.dump(self._patient_parameters_dict, outfile, indent=4, sort_keys=True) - self._unsaved_changes = False + # Saving the json file last, as it must be populated from the previous dumps beforehand + with open(self._patient_parameters_dict_filename, 'w') as outfile: + json.dump(self._patient_parameters_dict, outfile, indent=4, sort_keys=True) + self._unsaved_changes = False + except Exception as e: + logging.error("""[Software error] Saving patient on disk failed with: {}.\n {}""".format( + e, traceback.format_exc())) @property def reportings(self) -> dict: diff --git a/utils/software_config.py b/utils/software_config.py index a37f295..390db9a 100644 --- a/utils/software_config.py +++ b/utils/software_config.py @@ -4,7 +4,7 @@ import traceback from os.path import expanduser import numpy as np -from typing import Union, Any, List +from typing import Union, Any, List, Optional import names from PySide6.QtCore import QSize import logging @@ -179,8 +179,8 @@ def load_patient(self, filename: str, active: bool = True) -> Union[str, Any]: if active: # Doing the following rather than set_active_patient(), to avoid the overhead of doing memory release/load. self.active_patient_name = patient_id - except Exception: - error_message = "[Software error] Error while trying to load a patient: \n" + except Exception as e: + error_message = "[Software error] Error while trying to load a patient with: {}. \n".format(e) error_message = error_message + traceback.format_exc() logging.error(error_message) return patient_id, error_message @@ -230,19 +230,24 @@ def is_patient_list_empty(self) -> bool: bool True if the list is empty, False otherwise. """ - if len(self.patients_parameters.keys()) == 0: - return True - else: - return False + return len(self.patients_parameters.keys()) == 0 - def get_active_patient_uid(self) -> str: + def get_active_patient_uid(self) -> Optional[str]: return self.active_patient_name - def get_active_patient(self) -> str: - return self.patients_parameters[self.active_patient_name] + def get_active_patient(self) -> Optional[PatientParameters]: + if self.active_patient_name in self.patients_parameters.keys(): + return self.patients_parameters[self.active_patient_name] + else: + return None - def get_patient(self, uid: str): - return self.patients_parameters[uid] + def get_patient(self, uid: str) -> PatientParameters: + try: + assert not self.is_patient_list_empty() and uid in self.patients_parameters.keys() + return self.patients_parameters[uid] + except AssertionError: + logging.error("[Software error] Assertion error trying to query a missing patient with UID {}.\n {}".format( + uid, traceback.format_exc())) def get_patient_by_display_name(self, display_name: str) -> Union[PatientParameters, None]: for uid in list(self.patients_parameters.keys()): From 244b934d0cb9e73cddeec3e09dc778b3812092d8 Mon Sep 17 00:00:00 2001 From: dbouget Date: Thu, 26 Sep 2024 15:40:23 +0200 Subject: [PATCH 5/7] Updated version with MNI space display --- .github/workflows/build_macos.yml | 6 +++--- .github/workflows/build_macos_arm.yml | 4 ++-- .github/workflows/build_ubuntu.yml | 2 +- .github/workflows/build_windows.yml | 2 +- assets/Raidionics.nsi | 2 +- assets/main.spec | 2 +- assets/main_arm.spec | 2 +- utils/backend_logic.py | 3 +++ utils/logic/PipelineCreationHandler.py | 4 ++++ utils/software_config.py | 2 +- 10 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build_macos.yml b/.github/workflows/build_macos.yml index bc5c7bd..65703c7 100644 --- a/.github/workflows/build_macos.yml +++ b/.github/workflows/build_macos.yml @@ -15,7 +15,7 @@ env: jobs: build: name: Build packages - runs-on: macos-11 + runs-on: macos-12 steps: - uses: actions/checkout@v4 - name: Set up Python 3.8 @@ -72,8 +72,8 @@ jobs: - name: Make installer run: | git clone https://github.com/dbouget/quickpkg.git - quickpkg/quickpkg dist/Raidionics.app --output Raidionics-1.2.3-macOS.pkg - cp -r Raidionics-1.2.3-macOS.pkg dist/Raidionics-1.2.3-macOS-x86_64.pkg + quickpkg/quickpkg dist/Raidionics.app --output Raidionics-1.2.4-macOS.pkg + cp -r Raidionics-1.2.4-macOS.pkg dist/Raidionics-1.2.4-macOS-x86_64.pkg - name: Upload package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build_macos_arm.yml b/.github/workflows/build_macos_arm.yml index acd0c1c..ce364a7 100644 --- a/.github/workflows/build_macos_arm.yml +++ b/.github/workflows/build_macos_arm.yml @@ -67,8 +67,8 @@ jobs: - name: Make installer run: | git clone https://github.com/dbouget/quickpkg.git - quickpkg/quickpkg dist/Raidionics.app --output Raidionics-1.2.3-macOS.pkg - cp -r Raidionics-1.2.3-macOS.pkg dist/Raidionics-1.2.3-macOS-arm64.pkg + quickpkg/quickpkg dist/Raidionics.app --output Raidionics-1.2.4-macOS.pkg + cp -r Raidionics-1.2.4-macOS.pkg dist/Raidionics-1.2.4-macOS-arm64.pkg - name: Upload package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build_ubuntu.yml b/.github/workflows/build_ubuntu.yml index 7bb033b..55b1373 100644 --- a/.github/workflows/build_ubuntu.yml +++ b/.github/workflows/build_ubuntu.yml @@ -108,7 +108,7 @@ jobs: cp -r dist/Raidionics assets/Raidionics_ubuntu/usr/local/bin dpkg-deb --build --root-owner-group assets/Raidionics_ubuntu ls -la - cp -r assets/Raidionics_ubuntu.deb dist/Raidionics-1.2.3-ubuntu.deb + cp -r assets/Raidionics_ubuntu.deb dist/Raidionics-1.2.4-ubuntu.deb - name: Upload package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml index 43682e0..6354433 100644 --- a/.github/workflows/build_windows.yml +++ b/.github/workflows/build_windows.yml @@ -62,7 +62,7 @@ jobs: - name: Make installer run: | makensis.exe assets/Raidionics.nsi - cp -r assets/Raidionics-1.2.3-win.exe dist/Raidionics-1.2.3-win.exe + cp -r assets/Raidionics-1.2.4-win.exe dist/Raidionics-1.2.4-win.exe - name: Upload package uses: actions/upload-artifact@v4 diff --git a/assets/Raidionics.nsi b/assets/Raidionics.nsi index 8534ec8..8ce92c4 100644 --- a/assets/Raidionics.nsi +++ b/assets/Raidionics.nsi @@ -2,7 +2,7 @@ !define COMP_NAME "SINTEF" !define VERSION "1.2.2" !define DESCRIPTION "Application" -!define INSTALLER_NAME "Raidionics-1.2.3-win.exe" +!define INSTALLER_NAME "Raidionics-1.2.4-win.exe" !define MAIN_APP_EXE "Raidionics.exe" !define INSTALL_TYPE "SetShellVarContext current" !define REG_ROOT "HKLM" diff --git a/assets/main.spec b/assets/main.spec index 590640e..11c2ed3 100644 --- a/assets/main.spec +++ b/assets/main.spec @@ -85,7 +85,7 @@ if sys.platform == "darwin": 'CFBundleIdentifier': 'Raidionics', 'CFBundleInfoDictionaryVersion': '6.0', 'CFBundleName': 'Raidionics', - 'CFBundleVersion': '1.2.3', + 'CFBundleVersion': '1.2.4', 'CFBundlePackageType': 'APPL', 'LSBackgroundOnly': 'false', }, diff --git a/assets/main_arm.spec b/assets/main_arm.spec index d86cf8b..7e4b880 100644 --- a/assets/main_arm.spec +++ b/assets/main_arm.spec @@ -84,7 +84,7 @@ if sys.platform == "darwin": 'CFBundleIdentifier': 'Raidionics', 'CFBundleInfoDictionaryVersion': '6.0', 'CFBundleName': 'Raidionics', - 'CFBundleVersion': '1.2.3', + 'CFBundleVersion': '1.2.4', 'CFBundlePackageType': 'APPL', 'LSBackgroundOnly': 'false', }, diff --git a/utils/backend_logic.py b/utils/backend_logic.py index 894d53b..d508649 100644 --- a/utils/backend_logic.py +++ b/utils/backend_logic.py @@ -100,6 +100,9 @@ def run_pipeline(task: str, model_name: str, patient_parameters: PatientParamete rads_config.set('System', 'output_folder', reporting_folder) rads_config.set('System', 'model_folder', SoftwareConfigResources.getInstance().models_path) pipeline = create_pipeline(model_name, patient_parameters, task) + if pipeline == {}: + queue.put((1, results)) + pipeline_filename = os.path.join(patient_parameters.output_folder, 'rads_pipeline.json') with open(pipeline_filename, 'w', newline='\n') as outfile: json.dump(pipeline, outfile, indent=4) diff --git a/utils/logic/PipelineCreationHandler.py b/utils/logic/PipelineCreationHandler.py index bd20ae0..2649cc2 100644 --- a/utils/logic/PipelineCreationHandler.py +++ b/utils/logic/PipelineCreationHandler.py @@ -57,6 +57,10 @@ def create_pipeline(model_name: str, patient_parameters, task: str) -> dict: """ # The model(s) must be downloaded first, since the pipeline.json file(s) must be used later for assembling # the backend pipeline... Have to organize it better, and prepare reporting pipelines for download? + if 'postop_reporting' in task and "GBM" not in model_name: + logging.warning( + "[Software warning] There is currently no postoperative reporting for the requested type, only GBM is supported.") + return {} download_model(model_name) if task == 'folders_classification': diff --git a/utils/software_config.py b/utils/software_config.py index 390db9a..53bce98 100644 --- a/utils/software_config.py +++ b/utils/software_config.py @@ -25,7 +25,7 @@ class SoftwareConfigResources: _software_home_location = None # Main dump location for the software elements (e.g., models, runtime log) _user_preferences_filename = None # json file containing the user preferences (for when reopening the software). _session_log_filename = None # log filename containing the runtime logging for each software execution and backend. - _software_version = "1.2.3" # Current software version (minor) for selecting which models to use in the backend. + _software_version = "1.2.4" # Current software version (minor) for selecting which models to use in the backend. _software_medical_specialty = "neurology" # Overall medical target [neurology, thoracic] @staticmethod From 0bbfa51117da5be54ea257ad1008430a98ace184 Mon Sep 17 00:00:00 2001 From: dbouget Date: Thu, 26 Sep 2024 16:55:06 +0200 Subject: [PATCH 6/7] Typo fix --- utils/software_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/utils/software_config.py b/utils/software_config.py index 53bce98..2c1e0b2 100644 --- a/utils/software_config.py +++ b/utils/software_config.py @@ -48,7 +48,6 @@ def __setup(self): self._software_home_location = os.path.join(expanduser('~'), '.raidionics') if not os.path.exists(self._software_home_location): os.makedirs(self._software_home_location) - os.makedirs(self._software_home_location) self._user_preferences_filename = os.path.join(expanduser('~'), '.raidionics', 'raidionics_preferences.json') self._session_log_filename = os.path.join(expanduser('~'), '.raidionics', 'session_log.log') self.models_path = os.path.join(expanduser('~'), '.raidionics', 'resources', 'models') From befde116591498f13d7d45275cf0b59e421890c3 Mon Sep 17 00:00:00 2001 From: dbouget Date: Fri, 27 Sep 2024 14:04:05 +0200 Subject: [PATCH 7/7] Macos arm CI fix [skip ci] --- .github/workflows/build_macos_arm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_macos_arm.yml b/.github/workflows/build_macos_arm.yml index ce364a7..c3ecef4 100644 --- a/.github/workflows/build_macos_arm.yml +++ b/.github/workflows/build_macos_arm.yml @@ -18,7 +18,7 @@ jobs: cd tmp python3 -m virtualenv -p python3 venv --clear source venv/bin/activate - python3 -m pip install wheel setuptools + python3 -m pip install wheel setuptools==69.5.1 deactivate - name: Install dependencies