diff --git a/icons/brain_eye.png b/icons/brain_eye.png new file mode 100644 index 000000000..597683454 Binary files /dev/null and b/icons/brain_eye.png differ diff --git a/invesalius/constants.py b/invesalius/constants.py index b550942c5..37005dcfb 100644 --- a/invesalius/constants.py +++ b/invesalius/constants.py @@ -801,9 +801,10 @@ TARGET_COLUMN = 4 Z_OFFSET_COLUMN = 5 POINT_OF_INTEREST_TARGET_COLUMN = 6 -X_COLUMN = 7 -Y_COLUMN = 8 -Z_COLUMN = 9 +MEP_COLUMN = 7 +X_COLUMN = 8 +Y_COLUMN = 9 +Z_COLUMN = 10 # ------------ Navigation defaults ------------------- @@ -1004,10 +1005,89 @@ } MARKER_FILE_MAGICK_STRING = "##INVESALIUS3_MARKER_FILE_" -CURRENT_MARKER_FILE_VERSION = 3 -SUPPORTED_MARKER_FILE_VERSIONS = [0, 1, 2, 3] +CURRENT_MARKER_FILE_VERSION = 4 +SUPPORTED_MARKER_FILE_VERSIONS = [0, 1, 2, 3, 4] WILDCARD_MARKER_FILES = _("Marker scanner coord files (*.mkss)|*.mkss") +# Motor mapping visualization + +DEFAULT_MEP_CONFIG_PARAMS = { + "mep_enabled": False, + "enabled_once": False, + "threshold_down": 0, + "range_up": 1, + "brain_surface_index": None, + "mep_colormap": "Viridis", + "gaussian_sharpness": 0.4, + "gaussian_radius": 20, + "bounds": None, + "colormap_range_uv": {"min": 50, "low": 200, "mid": 600, "max": 1000}, +} + + +MEP_COLORMAP_DEFINITIONS = { + "BlueCyanYellowRed": { # Blue, Cyan, Yellow, Red + "min": (0.0, 0.0, 1.0), + "low": (0.0, 1.0, 1.0), + "mid": (1.0, 1.0, 0.0), + "max": (1.0, 0.0, 0.0), + }, + "GreenYellowOrangeRed": { # Green, Yellow, Orange, Red + "min": (0.0, 1.0, 0.0), + "low": (1.0, 1.0, 0.0), + "mid": (1.0, 0.647, 0.0), + "max": (1.0, 0.0, 0.0), + }, + "PurpleBlueGreenYellow": { # Purple, Blue, Green, Yellow + "min": (0.5, 0.0, 0.5), + "low": (0.0, 0.0, 1.0), + "mid": (0.0, 1.0, 0.0), + "max": (1.0, 1.0, 0.0), + }, + "BlackGrayWhiteRed": { # Black, Gray, White, Red (grayscale with highlight) + "min": (0.0, 0.0, 0.0), + "low": (0.5, 0.5, 0.5), + "mid": (1.0, 1.0, 1.0), + "max": (1.0, 0.0, 0.0), + }, + "Viridis": { # Viridis (perceptually uniform) + "min": (0.267004, 0.004874, 0.329415), + "low": (0.226337, 0.31071, 0.577055), + "mid": (0.993248, 0.906157, 0.143936), + "max": (0.968627, 0.813008, 0.0), + }, + "Grayscale": { # Grayscale (often used for CT/MRI) + "min": (0.0, 0.0, 0.0), # Black + "low": (0.25, 0.25, 0.25), # Dark Gray + "mid": (0.75, 0.75, 0.75), # Light Gray + "max": (1.0, 1.0, 1.0), # White + }, + "HotMetal": { # Hot Metal (useful for highlighting hot spots) + "min": (0.0, 0.0, 0.0), # Black + "low": (0.5, 0.0, 0.0), # Dark Red + "mid": (1.0, 0.5, 0.0), # Orange + "max": (1.0, 1.0, 1.0), # White + }, + "Rainbow": { # Rainbow (although not perceptually uniform, still common) + "min": (0.0, 0.0, 1.0), # Blue + "low": (0.0, 1.0, 0.0), # Green + "mid": (1.0, 1.0, 0.0), # Yellow + "max": (1.0, 0.0, 0.0), # Red + }, + "Bone": { # Bone (specifically designed for CT bone visualization) + "min": (0.0, 0.0, 0.0), # Black + "low": (0.388, 0.224, 0.0), # Brown + "mid": (0.902, 0.827, 0.631), # Beige + "max": (1.0, 1.0, 1.0), # White + }, + "InvertedGrayscale": { # Inverted Grayscale (sometimes used for PET) + "min": (1.0, 1.0, 1.0), # White + "low": (0.75, 0.75, 0.75), # Light Gray + "mid": (0.25, 0.25, 0.25), # Dark Gray + "max": (0.0, 0.0, 0.0), # Black + }, +} + # Keycodes for moving markers using the keyboard MOVE_MARKER_LEFT_KEYCODE = 65 # A MOVE_MARKER_RIGHT_KEYCODE = 68 # D diff --git a/invesalius/data/markers/marker.py b/invesalius/data/markers/marker.py index aafc444b7..f220cd239 100644 --- a/invesalius/data/markers/marker.py +++ b/invesalius/data/markers/marker.py @@ -75,6 +75,9 @@ class Marker: z_offset: float = 0.0 visualization: dict = dataclasses.field(default_factory=dict) marker_uuid: str = "" + # #TODO: add a reference to original coil marker to relate it to MEP + # in micro Volts (but scale in milli Volts for display) + mep_value: float = dataclasses.field(default=None) # x, y, z can be jointly accessed as position @property @@ -160,7 +163,11 @@ def to_csv_header(cls): res = [ field.name for field in dataclasses.fields(cls) - if (field.name != "version" and field.name != "marker_uuid") + if ( + field.name != "version" + and field.name != "marker_uuid" + and field.name != "visualization" + ) ] res.extend(["x_world", "y_world", "z_world", "alpha_world", "beta_world", "gamma_world"]) return "\t".join(map(lambda x: f'"{x}"', res)) @@ -217,6 +224,7 @@ def to_dict(self): "cortex_position_orientation": self.cortex_position_orientation, "z_rotation": self.z_rotation, "z_offset": self.z_offset, + "mep_value": self.mep_value, } def from_dict(self, d): @@ -245,20 +253,10 @@ def from_dict(self, d): else: marker_type = MarkerType.COIL_TARGET.value - if all( - [ - k in d - for k in [ - "x_cortex", - "y_cortex", - "z_cortex", - "alpha_cortex", - "beta_cortex", - "gamma_cortex", - ] - ] - ): - cortex_position_orientation = [ + cortex_position_orientation = ( + d["cortex_position_orientation"] + if "cortex_position_orientation" in d + else [ d["x_cortex"], d["y_cortex"], d["z_cortex"], @@ -266,12 +264,12 @@ def from_dict(self, d): d["beta_cortex"], d["gamma_cortex"], ] - else: - cortex_position_orientation = [None, None, None, None, None, None] + ) z_offset = d.get("z_offset", 0.0) z_rotation = d.get("z_rotation", 0.0) is_point_of_interest = d.get("is_point_of_interest", False) + mep_value = d.get("mep_value", None) self.size = d["size"] self.label = d["label"] @@ -287,6 +285,7 @@ def from_dict(self, d): self.cortex_position_orientation = cortex_position_orientation self.z_offset = z_offset self.z_rotation = z_rotation + self.mep_value = mep_value return self diff --git a/invesalius/data/surface.py b/invesalius/data/surface.py index d6a0ec7f7..a4692c7c6 100644 --- a/invesalius/data/surface.py +++ b/invesalius/data/surface.py @@ -216,6 +216,7 @@ def __bind_events(self): Publisher.subscribe(self.OnSplitSurface, "Split surface") Publisher.subscribe(self.OnLargestSurface, "Create surface from largest region") Publisher.subscribe(self.OnSeedSurface, "Create surface from seeds") + Publisher.subscribe(self.GetVisibleSurfaceActor, "Get visible surface actor") Publisher.subscribe(self.OnDuplicate, "Duplicate surfaces") Publisher.subscribe(self.OnRemove, "Remove surfaces") @@ -1129,6 +1130,20 @@ def OnChangeSurfaceName(self, index, name): def OnShowSurface(self, index, visibility): self.ShowActor(index, visibility) + def GetVisibleSurfaceActor(self): + """ + Gets the first visible surface actor. + """ + index = 0 + for key in self.actors_dict: + if self.actors_dict[key].GetVisibility(): + index = key + break + + Publisher.sendMessage( + "Load visible surface actor", actor=self.actors_dict[index], index=index + ) + def ShowActor(self, index, value): """ Show or hide actor, according to given actor index and value. diff --git a/invesalius/data/visualization/mep_visualizer.py b/invesalius/data/visualization/mep_visualizer.py new file mode 100644 index 000000000..f11ecbbe9 --- /dev/null +++ b/invesalius/data/visualization/mep_visualizer.py @@ -0,0 +1,505 @@ +# -------------------------------------------------------------------------- +# Software: InVesalius - Software de Reconstrucao 3D de Imagens Medicas +# Copyright: (C) 2001 Centro de Pesquisas Renato Archer +# Homepage: http://www.softwarepublico.gov.br +# Contact: invesalius@cti.gov.br +# License: GNU - GPL 2 (LICENSE.txt/LICENCA.txt) +# -------------------------------------------------------------------------- +# Este programa e software livre; voce pode redistribui-lo e/ou +# modifica-lo sob os termos da Licenca Publica Geral GNU, conforme +# publicada pela Free Software Foundation; de acordo com a versao 2 +# da Licenca. +# +# Este programa eh distribuido na expectativa de ser util, mas SEM +# QUALQUER GARANTIA; sem mesmo a garantia implicita de +# COMERCIALIZACAO ou de ADEQUACAO A QUALQUER PROPOSITO EM +# PARTICULAR. Consulte a Licenca Publica Geral GNU para obter mais +# detalhes. +# -------------------------------------------------------------------------- +from copy import deepcopy + +import numpy as np +import wx +from vtk import vtkColorTransferFunction +from vtkmodules.vtkCommonCore import ( + vtkDoubleArray, + vtkPoints, +) +from vtkmodules.vtkCommonDataModel import ( + vtkImageData, + vtkPolyData, +) +from vtkmodules.vtkFiltersCore import ( + vtkDecimatePro, + vtkImplicitPolyDataDistance, + vtkPolyDataNormals, + vtkResampleWithDataSet, + vtkTriangleFilter, +) +from vtkmodules.vtkFiltersPoints import ( + vtkGaussianKernel, + vtkPointInterpolator, +) +from vtkmodules.vtkRenderingAnnotation import vtkScalarBarActor +from vtkmodules.vtkRenderingCore import ( + vtkActor, + vtkPointGaussianMapper, + vtkPolyDataMapper, +) + +import invesalius.constants as const +import invesalius.data.transformations as transformations +import invesalius.gui.dialogs as dialogs +import invesalius.session as ses +from invesalius.data.markers.marker import Marker, MarkerType +from invesalius.navigation.markers import MarkersControl +from invesalius.pubsub import pub as Publisher + + +class MEPVisualizer: + def __init__(self): + self.points = vtkPolyData() + self.surface = None + self.decimate_surface = None + self.colored_surface_actor = None + self.point_actor = None + + self.colorBarActor = None + self.actors_dict = {} # Dictionary to store all actors created by the MEP visualizer + + self.marker_storage = None + self.first_load = True + + self.is_navigating = False + + self.dims_size = 20 + + self._config_params = deepcopy(const.DEFAULT_MEP_CONFIG_PARAMS) + self.__bind_events() + self._LoadUserParameters() + + # --- Event Handling --- + + def __bind_events(self): + Publisher.subscribe(self._ResetConfigParams, "Reset MEP Config") + Publisher.subscribe(self.UpdateConfig, "Save Preferences") + # Publisher.subscribe(self.UpdateMEPPoints, "Update marker list") + # Publisher.subscribe(self.SetBrainSurface, "Set MEP brain surface") + Publisher.subscribe(self.UpdateMEPPoints, "Redraw MEP mapping") + Publisher.subscribe(self.UpdateNavigationStatus, "Navigation status") + Publisher.subscribe(self.SetBrainSurface, "Load visible surface actor") + Publisher.subscribe(self.OnCloseProject, "Close project data") + + # --- Configuration Management --- + + def _LoadUserParameters(self): + session = ses.Session() + config = session.GetConfig("mep_configuration") + if config: # If there is a configuration saved in a previous session + config["enabled_once"] = False + config["mep_enabled"] = False + if ( + config["brain_surface_index"] is not None + ): # If there is a surface already selected then there is no point of showing the message + config["enabled_once"] = True + self._config_params.update(config) + else: + session.SetConfig("mep_configuration", self._config_params) + + def _ResetConfigParams(self): + defaults = deepcopy(const.DEFAULT_MEP_CONFIG_PARAMS) + if self._config_params["enabled_once"]: + defaults["enabled_once"] = True + defaults["bounds"] = self._config_params[ + "bounds" + ] # Keep the bounds of the surface if it was already selected (to avoid recalculating) + defaults["brain_surface_index"] = self._config_params[ + "brain_surface_index" + ] # Keep the previously selected index + + ses.Session().SetConfig("mep_configuration", defaults) + self._config_params = deepcopy(defaults) + + def _SaveUserParameters(self): + ses.Session().SetConfig("mep_configuration", self._config_params) + + def UpdateConfig(self): + self._config_params = deepcopy(ses.Session().GetConfig("mep_configuration")) + # self.UpdateMEPPoints([]) + self.UpdateVisualization() + + # --- Motor Map Display --- + + def DisplayMotorMap(self, show: bool): + if show: + self._config_params["mep_enabled"] = True + if self._config_params["brain_surface_index"] is None: + wx.MessageBox( + "Please select a brain surface from preferences.", + "MEP Mapping", + wx.OK | wx.ICON_INFORMATION, + ) + self._config_params["enabled_once"] = True + self._SaveUserParameters() + Publisher.sendMessage("Open preferences menu", page=0) + return False + progress_dialog = dialogs.BrainSurfaceLoadingProgressWindow() + if self.colorBarActor: + Publisher.sendMessage("Remove surface actor from viewer", actor=self.colorBarActor) + Publisher.sendMessage("Remove surface actor from viewer", actor=self.surface) + self.colorBarActor = self.CreateColorbarActor() + if self._config_params["brain_surface_index"] is not None: # Hides the original surface + Publisher.sendMessage( + "Show surface", + index=self._config_params["brain_surface_index"], + visibility=False, + ) + if self.first_load: + Publisher.sendMessage( + "Show single surface", + index=self._config_params["brain_surface_index"], + visibility=True, + ) + Publisher.sendMessage("Get visible surface actor") + self.first_load = False + progress_dialog.Update(value=50, msg="Preparing brain surface...") + self.UpdateVisualization() + self.UpdateMEPPoints() + progress_dialog.Close() + else: + self._config_params["mep_enabled"] = False + self._CleanupVisualization() + if self._config_params["brain_surface_index"] is not None: # Shows the original surface + Publisher.sendMessage( + "Show surface", + index=self._config_params["brain_surface_index"], + visibility=True, + ) + self._SaveUserParameters() + + return True + + # --- Data Interpolation and Visualization --- + + def InterpolateData(self): + surface = self.surface + points = self.points + if not surface: + # TODO: Show modal dialog to select a surface from project + return + if not points: + raise ValueError("MEP Visualizer: No point data found") + + bounds = np.array(self._config_params["bounds"]) + gaussian_sharpness = self._config_params["gaussian_sharpness"] + gaussian_radius = self._config_params["gaussian_radius"] + dims_size = self.dims_size + dims = np.array([dims_size, dims_size, dims_size]) + + box = vtkImageData() + box.SetDimensions(dims) + box.SetSpacing((bounds[1::2] - bounds[:-1:2]) / (dims - 1)) + box.SetOrigin(bounds[::2]) + + gaussian_kernel = vtkGaussianKernel() + gaussian_kernel.SetSharpness(gaussian_sharpness) + gaussian_kernel.SetRadius(gaussian_radius) + + interpolator = vtkPointInterpolator() + interpolator.SetInputData(box) + interpolator.SetSourceData(points) + interpolator.SetKernel(gaussian_kernel) + + resample = vtkResampleWithDataSet() + + polydata = surface.GetMapper().GetInput() + resample.SetInputData(polydata) + resample.SetSourceConnection(interpolator.GetOutputPort()) + resample.SetPassPointArrays(1) + resample.Update() + return resample.GetOutput() + + def _CustomColormap(self, choice=None): + """ + Creates a color transfer function with a 4-color heatmap. + + Args: + choice (str): The name of the color combination (refer to the 'color_maps' dictionary). + + Returns: + vtkColorTransferFunction: The created color transfer function. + """ + + color_function = vtkColorTransferFunction() + choice = choice or self._config_params["mep_colormap"] + + color_maps = const.MEP_COLORMAP_DEFINITIONS + + if choice in color_maps: + for value, color in color_maps[choice].items(): + color_function.AddRGBPoint(self._config_params["colormap_range_uv"][value], *color) + else: + raise ValueError( + f"Invalid choice '{choice}'. Choose from: {', '.join(color_maps.keys())}" + ) + + return color_function + + def QueryBrainSurface(self): + Publisher.sendMessage( + "Show single surface", + index=self._config_params["brain_surface_index"], + visibility=True, + ) + Publisher.sendMessage("Get visible surface actor") + + def DecimateBrainSurface(self): + triangle_filter = vtkTriangleFilter() + triangle_filter.SetInputData( + self.surface.GetMapper().GetInput() + ) # Use vtkPolyData directly + triangle_filter.Update() # Perform triangulation + triangulated_polydata = triangle_filter.GetOutput() + + # Setup the decimation filter + decimate_filter = vtkDecimatePro() + decimate_filter.SetInputData(triangulated_polydata) + decimate_filter.SetTargetReduction(0.8) # Reduce the number of triangles by 80% + decimate_filter.PreserveTopologyOn() # Preserve the topology + decimate_filter.Update() + return decimate_filter + + def SetBrainSurface(self, actor: vtkActor, index: int): + self.surface = actor + self.decimate_surface = self.DecimateBrainSurface() + self.actors_dict[id(actor)] = actor + self._config_params["bounds"] = list(np.array(actor.GetBounds())) + self._config_params["brain_surface_index"] = index + self.UpdateVisualization() + # hide the original surface if MEP is enabled + if self._config_params["mep_enabled"]: + Publisher.sendMessage("Show surface", index=index, visibility=False) + self._SaveUserParameters() + + def _FilterMarkers(self, markers): + """ + Checks if the markers were updated and if those updated are of type coil_target + """ + + self.marker_storage = self.marker_storage or markers + + if not markers: + return [], False + + # Check if the markers are coil target markers + coil_markers = [] + for marker in markers: + if marker.marker_type == MarkerType.COIL_TARGET: + coil_markers.append(marker) + + # check if the coil markers were changed compared to the stored markers + skip = coil_markers == self.marker_storage + + return coil_markers, skip + + def collision_detection(self, m_point): + distance_function = vtkImplicitPolyDataDistance() + distance_function.SetInput(self.decimate_surface.GetOutput()) + distance = distance_function.EvaluateFunction(m_point[:3, -1]) + if distance > 30 or distance < 1: + print("no collision") + return m_point[:3, -1] + + t_translation = transformations.translation_matrix([0, 0, -distance]) + m_point_t = m_point @ t_translation + return m_point_t[:3, -1] + + def projection_on_surface(self, marker): + print("projecting target on brain surface") + a, b, g = np.radians(marker.orientation[:3]) + r_ref = transformations.euler_matrix(a, b, g, axes="sxyz") + t_ref = transformations.translation_matrix( + [marker.position[0], marker.position[1], marker.position[2]] + ) + m_point = transformations.concatenate_matrices(t_ref, r_ref) + m_point[1, -1] = -m_point[1, -1] + new_coord = self.collision_detection(m_point) + + return new_coord + + def UpdateMEPPoints(self): + """ + Updates or creates the point data with MEP values from a list of markers. + + Args: + markers (List[Marker]): The list of marker objects to add/update points for. + clear_old (bool, default=False): If True, clears all existing points before updating. + """ + if not self.surface: + return + + markers, skip = self._FilterMarkers(MarkersControl().list) + + if not markers: + self.points = vtkPolyData() + self.UpdateVisualization() + return + if skip: # Saves computation if the markers are not updated or irrelevant + return + + points = vtkPoints() + + point_data = self.points.GetPointData() + mep_array = vtkDoubleArray() + mep_array.SetName("MEP") + point_data.AddArray(mep_array) + + for marker in markers: + if not marker.x_cortex and not marker.y_cortex and not marker.z_cortex: + projected_point = self.projection_on_surface(marker) + marker.cortex_position_orientation = [ + projected_point[0], + -projected_point[1], + projected_point[2], + marker.orientation[0], + marker.orientation[1], + marker.orientation[2], + ] + + points.InsertNextPoint(marker.x_cortex, -marker.y_cortex, marker.z_cortex) + mep_value = marker.mep_value or 0 + mep_array.InsertNextValue(mep_value) + MarkersControl().SaveState() + + self.points.SetPoints(points) + self.points.GetPointData().SetActiveScalars("MEP") + self.points.Modified() + if self._config_params["mep_enabled"]: + self.UpdateVisualization() + + def UpdateVisualization(self): + if not self._config_params["mep_enabled"]: + return + + self._CleanupVisualization() + + interpolated_data = self.InterpolateData() + self.colored_surface_actor = self.CreateColoredSurface(interpolated_data) + self.point_actor = self.CreatePointActor( + self.points, (self._config_params["threshold_down"], self._config_params["range_up"]) + ) + self.colorBarActor = self.CreateColorbarActor() + + Publisher.sendMessage("AppendActor", actor=self.colored_surface_actor) + Publisher.sendMessage("AppendActor", actor=self.surface) + Publisher.sendMessage("AppendActor", actor=self.point_actor) + Publisher.sendMessage("AppendActor", actor=self.colorBarActor) + + if not self.is_navigating: + Publisher.sendMessage("Render volume viewer") + + def CreateColorbarActor(self, lut=None) -> vtkActor: + if lut is None: + lut = self._CustomColormap(self._config_params["mep_colormap"]) + colorBarActor = vtkScalarBarActor() + colorBarActor.SetLookupTable(lut) + colorBarActor.SetNumberOfLabels(4) + colorBarActor.SetLabelFormat("%4.0f") + colorBarActor.SetTitle("µV ") + # FIXME: Set the title to be a little bit smaller + colorBarActor.SetTitleRatio(0.1) + colorBarActor.SetMaximumWidthInPixels(70) + # move title to below + colorBarActor.GetTitleTextProperty().SetFontSize(1) + + # FIXME: Set the label text size to be a little bit smaller + label_text_property = colorBarActor.GetLabelTextProperty() + label_text_property.SetFontSize(9) + # label_text_property.SetColor(0, 0, 0) + + colorBarActor.SetLabelTextProperty(label_text_property) + + # resize colorbar + colorBarActor.SetWidth(0.1) + colorBarActor.SetHeight(0.3) + + colorBarActor.SetVisibility(1) + + # set position of the colorbar to the bottom left corner + colorBarActor.GetPositionCoordinate().SetCoordinateSystemToNormalizedDisplay() + colorBarActor.GetPositionCoordinate().SetValue(0.06, 0.06) + # Track the created actor + self.actors_dict[id(colorBarActor)] = colorBarActor + return colorBarActor + + def CreateColoredSurface(self, poly_data) -> vtkActor: + """Creates the actor for the surface with color mapping.""" + normals = vtkPolyDataNormals() + normals.SetInputData(poly_data) + normals.ComputePointNormalsOn() + normals.ComputeCellNormalsOff() + normals.Update() + + data_range = (self._config_params["threshold_down"], self._config_params["range_up"]) + + mapper = vtkPolyDataMapper() + mapper.SetInputData(normals.GetOutput()) + if data_range is None: + data_range = (self._config_params["threshold_down"], self._config_params["range_up"]) + mapper.SetScalarRange(data_range) + + lut = self._CustomColormap() + mapper.SetLookupTable(lut) + + actor = vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetInterpolationToGouraud() + self.actors_dict[id(actor)] = actor + return actor + + def CreatePointActor(self, points, data_range): + """Creates the actor for the data points.""" + point_mapper = vtkPointGaussianMapper() + point_mapper.SetInputData(points) + point_mapper.SetScalarRange(data_range) + point_mapper.SetScaleFactor(1) + point_mapper.EmissiveOff() + point_mapper.SetSplatShaderCode( + "//VTK::Color::Impl\n" + "float dist = dot(offsetVCVSOutput.xy,offsetVCVSOutput.xy);\n" + "if (dist > 1.0) {\n" + " discard;\n" + "} else {\n" + " float scale = (1.0 - dist);\n" + " ambientColor *= scale;\n" + " diffuseColor *= scale;\n" + "}\n" + ) + + lut = self._CustomColormap() + point_mapper.SetLookupTable(lut) + + point_actor = vtkActor() + point_actor.SetMapper(point_mapper) + + self.actors_dict[id(point_actor)] = point_actor + return point_actor + + def _CleanupVisualization(self): + for actor in self.actors_dict.values(): + Publisher.sendMessage("Remove surface actor from viewer", actor=actor) + + self.actors_dict.clear() + + if not self.is_navigating: + Publisher.sendMessage("Render volume viewer") + + def UpdateNavigationStatus(self, nav_status, vis_status): + self.is_navigating = nav_status + + def OnCloseProject(self): + """Cleanup the visualization when the project is closed.""" + self.DisplayMotorMap(False) + self._config_params["mep_enabled"] = False + self._config_params["enabled_once"] = False + self._SaveUserParameters() diff --git a/invesalius/gui/dialogs.py b/invesalius/gui/dialogs.py index 04ede8caa..4aed757c6 100644 --- a/invesalius/gui/dialogs.py +++ b/invesalius/gui/dialogs.py @@ -1205,6 +1205,34 @@ def ShowEnterMarkerID(default: str) -> str: return result +def ShowEnterMEPValue(default): + msg = _("Enter the MEP value (uV)") + if sys.platform == "darwin": + dlg = wx.TextEntryDialog(None, "", msg, defaultValue=default) + else: + dlg = wx.TextEntryDialog(None, msg, "InVesalius 3", value=default) + dlg.ShowModal() + result = dlg.GetValue() + # check if the value is a number + try: + result = float(result) + except ValueError: + result = None + # if the value is not a number, raise error message + if result is None: + msg = _("The value entered is not a number.") + if sys.platform == "darwin": + dlg = wx.MessageDialog(None, "", msg, wx.OK) + else: + dlg = wx.MessageDialog(None, msg, "InVesalius 3", wx.OK) + dlg.ShowModal() + dlg.Destroy() + + dlg.Destroy() + + return result + + def ShowConfirmationDialog(msg: str = _("Proceed?")) -> int: # msg = _("Do you want to delete all markers?") if sys.platform == "darwin": @@ -5944,6 +5972,28 @@ def Close(self) -> None: self.dlg.Destroy() +class BrainSurfaceLoadingProgressWindow: + def __init__(self): + title = "InVesalius 3" + message = _("Loading brain surface...") + style = wx.PD_APP_MODAL | wx.PD_CAN_ABORT + parent = wx.GetApp().GetTopWindow() + + self.dlg = wx.ProgressDialog(title, message, parent=parent, style=style) + self.dlg.Show() + + def Update(self, msg: Optional[str] = None, value=None) -> None: + if value: + self.dlg.Update(int(value), msg) + elif msg: + self.dlg.Pulse(msg) + else: + self.dlg.Pulse() + + def Close(self) -> None: + self.dlg.Destroy() + + class SurfaceSmoothingProgressWindow: def __init__(self): title = "InVesalius 3" diff --git a/invesalius/gui/preferences.py b/invesalius/gui/preferences.py index c0b13f4f7..d9887fbb7 100644 --- a/invesalius/gui/preferences.py +++ b/invesalius/gui/preferences.py @@ -4,11 +4,14 @@ import numpy as np import wx +import wx.lib.colourselect as csel +from matplotlib import colors as mcolors import invesalius.constants as const import invesalius.data.vtk_utils as vtk_utils import invesalius.gui.dialogs as dlg import invesalius.gui.log as log +import invesalius.gui.widgets.gradient as grad import invesalius.session as ses from invesalius import inv_paths, utils from invesalius.gui.language_dialog import ComboBoxLanguage @@ -76,6 +79,9 @@ def __init__( self.book.SetMinClientSize((min_width * 2, min_height * 2)) self.book.SetSelection(page) + self.Bind(wx.EVT_BUTTON, self.OnOK, id=wx.ID_OK) + self.Bind(wx.EVT_CHAR_HOOK, self.OnCharHook) + sizer = wx.BoxSizer(wx.VERTICAL) sizer.Add(self.book, 1, wx.EXPAND | wx.ALL) sizer.Add(btnsizer, 0, wx.GROW | wx.RIGHT | wx.TOP | wx.BOTTOM, 5) @@ -86,6 +92,17 @@ def __init__( def __bind_events(self): Publisher.subscribe(self.LoadPreferences, "Load Preferences") + def OnOK(self, event): + Publisher.sendMessage("Save Preferences") + self.EndModal(wx.ID_OK) + + def OnCharHook(self, event): + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.EndModal(wx.ID_CANCEL) + if event.GetKeyCode() == wx.WXK_RETURN: + self.OnOK(event) + event.Skip() + def GetPreferences(self): values = {} @@ -144,6 +161,15 @@ class VisualizationTab(wx.Panel): def __init__(self, parent): wx.Panel.__init__(self, parent) + self.session = ses.Session() + + self.colormaps = [str(cmap) for cmap in const.MEP_COLORMAP_DEFINITIONS.keys()] + self.number_colors = 4 + self.cluster_volume = None + + self.conf = dict(self.session.GetConfig("mep_configuration")) + self.conf["mep_colormap"] = self.conf.get("mep_colormap", "Viridis") + bsizer = wx.StaticBoxSizer(wx.VERTICAL, self, _("3D Visualization")) lbl_inter = wx.StaticText(bsizer.GetStaticBox(), -1, _("Surface Interpolation ")) rb_inter = self.rb_inter = wx.RadioBox( @@ -178,14 +204,18 @@ def __init__(self, parent): majorDimension=3, style=wx.RA_SPECIFY_COLS | wx.NO_BORDER, ) - bsizer_slices.Add(lbl_inter_sl, 0, wx.TOP | wx.LEFT | wx.FIXED_MINSIZE, 10) bsizer_slices.Add(rb_inter_sl, 0, wx.TOP | wx.LEFT | wx.FIXED_MINSIZE, 0) border = wx.BoxSizer(wx.VERTICAL) - border.Add(bsizer_slices, 1, wx.EXPAND | wx.ALL | wx.FIXED_MINSIZE, 10) + border.Add(bsizer_slices, 0, wx.EXPAND | wx.ALL | wx.FIXED_MINSIZE, 10) border.Add(bsizer, 1, wx.EXPAND | wx.ALL | wx.FIXED_MINSIZE, 10) + # Creating MEP Mapping BoxSizer + if self.conf.get("enabled_once") is True: + self.bsizer_mep = self.InitMEPMapping(None) + border.Add(self.bsizer_mep, 0, wx.EXPAND | wx.ALL | wx.FIXED_MINSIZE, 10) + self.SetSizerAndFit(border) self.Layout() @@ -197,6 +227,298 @@ def GetSelection(self): } return options + def InitMEPMapping(self, event): + # Adding a new sized for MEP Mapping options + # Structured as follows: + # MEP Mapping + # - Surface Selection -> ComboBox + # - Gaussian Radius -> SpinCtrlDouble + # - Gaussian Standard Deviation -> SpinCtrlDouble + # - Select Colormap -> ComboBox + Image frame + # - Color Map Values + # -- Min Value -> SpinCtrlDouble + # -- Low Value -> SpinCtrlDouble + # -- Mid Value -> SpinCtrlDouble + # -- Max Value -> SpinCtrlDouble + # TODO: Add a button to apply the colormap to the current volume + # TODO: Store MEP visualization settings in a + + bsizer_mep = wx.StaticBoxSizer(wx.VERTICAL, self, _("TMS Motor Mapping")) + + # Surface Selection + try: + default_colour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_MENUBAR) + except AttributeError: + default_colour = wx.SystemSettings_GetColour(wx.SYS_COLOUR_MENUBAR) + self.SetBackgroundColour(default_colour) + + # Initializing the project singleton + from invesalius import project as prj + + self.proj = prj.Project() + + combo_brain_surface_name = wx.ComboBox( + bsizer_mep.GetStaticBox(), -1, size=(210, 23), style=wx.CB_DROPDOWN | wx.CB_READONLY + ) + if sys.platform != "win32": + combo_brain_surface_name.SetWindowVariant(wx.WINDOW_VARIANT_SMALL) + # TODO: Sending the event to the MEP Visualizer to update the surface + # combo_brain_surface_name.Bind( + # wx.EVT_COMBOBOX, self.OnComboNameBrainSurface) + combo_brain_surface_name.Bind(wx.EVT_COMBOBOX, self.OnComboName) + + for n in range(len(self.proj.surface_dict)): + combo_brain_surface_name.Insert(str(self.proj.surface_dict[n].name), n) + + self.combo_brain_surface_name = combo_brain_surface_name + index = self.conf.get("brain_surface_index") + if index is not None: + self.combo_brain_surface_name.SetSelection(index) + + # Mask colour + button_colour = csel.ColourSelect( + bsizer_mep.GetStaticBox(), -1, colour=(0, 0, 255), size=(22, -1) + ) + button_colour.Bind(csel.EVT_COLOURSELECT, self.OnSelectColour) + self.button_colour = button_colour + + # Sizer which represents the first line + line1 = wx.BoxSizer(wx.HORIZONTAL) + line1.Add(combo_brain_surface_name, 1, wx.ALL | wx.EXPAND | wx.GROW, 7) + line1.Add(button_colour, 0, wx.ALL | wx.EXPAND | wx.GROW, 7) + + surface_sel_lbl = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Brain Surface:")) + surface_sel_lbl.SetFont(wx.Font(9, wx.DEFAULT, wx.NORMAL, wx.BOLD)) + surface_sel_sizer = wx.BoxSizer(wx.HORIZONTAL) + + surface_sel_sizer.Add(surface_sel_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 5) + # fixed_sizer.AddSpacer(7) + surface_sel_sizer.Add(line1, 0, wx.EXPAND | wx.GROW | wx.LEFT | wx.RIGHT, 5) + + # Gaussian Radius Line + lbl_gaussian_radius = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Gaussian Radius:")) + self.spin_gaussian_radius = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(64, 23), inc=0.5 + ) + self.spin_gaussian_radius.Enable(1) + self.spin_gaussian_radius.SetRange(1, 99) + self.spin_gaussian_radius.SetValue(self.conf.get("gaussian_radius")) + + self.spin_gaussian_radius.Bind( + wx.EVT_TEXT, partial(self.OnSelectGaussianRadius, ctrl=self.spin_gaussian_radius) + ) + self.spin_gaussian_radius.Bind( + wx.EVT_SPINCTRL, partial(self.OnSelectGaussianRadius, ctrl=self.spin_gaussian_radius) + ) + + line_gaussian_radius = wx.BoxSizer(wx.HORIZONTAL) + line_gaussian_radius.AddMany( + [ + (lbl_gaussian_radius, 1, wx.EXPAND | wx.GROW | wx.TOP | wx.RIGHT | wx.LEFT, 0), + (self.spin_gaussian_radius, 0, wx.ALL | wx.EXPAND | wx.GROW, 0), + ] + ) + + # Gaussian Standard Deviation Line + lbl_std_dev = wx.StaticText( + bsizer_mep.GetStaticBox(), -1, _("Gaussian Standard Deviation:") + ) + self.spin_std_dev = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(64, 23), inc=0.01 + ) + self.spin_std_dev.Enable(1) + self.spin_std_dev.SetRange(0.01, 5.0) + self.spin_std_dev.SetValue(self.conf.get("gaussian_sharpness")) + + self.spin_std_dev.Bind(wx.EVT_TEXT, partial(self.OnSelectStdDev, ctrl=self.spin_std_dev)) + self.spin_std_dev.Bind( + wx.EVT_SPINCTRL, partial(self.OnSelectStdDev, ctrl=self.spin_std_dev) + ) + + line_std_dev = wx.BoxSizer(wx.HORIZONTAL) + line_std_dev.AddMany( + [ + (lbl_std_dev, 1, wx.EXPAND | wx.GROW | wx.TOP | wx.RIGHT | wx.LEFT, 0), + (self.spin_std_dev, 0, wx.ALL | wx.EXPAND | wx.GROW, 0), + ] + ) + + # Select Colormap Line + lbl_colormap = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Select Colormap:")) + lbl_colormap.SetFont(wx.Font(9, wx.DEFAULT, wx.NORMAL, wx.BOLD)) + + self.combo_thresh = wx.ComboBox( + bsizer_mep.GetStaticBox(), + -1, + "", # size=(15,-1), + choices=self.colormaps, + style=wx.CB_DROPDOWN | wx.CB_READONLY, + ) + self.combo_thresh.Bind(wx.EVT_COMBOBOX, self.OnSelectColormap) + # by default use the initial value set in the configuration + self.combo_thresh.SetSelection(self.colormaps.index(self.conf.get("mep_colormap"))) + # self.combo_thresh.SetSelection(0) + + colors_gradient = self.GenerateColormapColors( + self.conf.get("mep_colormap"), self.number_colors + ) + + self.gradient = grad.GradientDisp( + bsizer_mep.GetStaticBox(), -1, -5000, 5000, -5000, 5000, colors_gradient + ) + + colormap_gradient_sizer = wx.BoxSizer(wx.HORIZONTAL) + colormap_gradient_sizer.AddMany( + [ + (self.combo_thresh, 0, wx.EXPAND | wx.GROW | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5), + (self.gradient, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5), + ] + ) + + colormap_sizer = wx.BoxSizer(wx.VERTICAL) + + colormap_sizer.AddMany( + [ + (lbl_colormap, 0, wx.TOP | wx.BOTTOM | wx.LEFT, 5), + (colormap_gradient_sizer, 0, wx.GROW | wx.SHRINK | wx.LEFT | wx.RIGHT, 5), + ] + ) + + colormap_custom = wx.BoxSizer(wx.VERTICAL) + + lbl_colormap_ranges = wx.StaticText( + bsizer_mep.GetStaticBox(), -1, _("Custom Colormap Ranges") + ) + lbl_colormap_ranges.SetFont(wx.Font(9, wx.DEFAULT, wx.NORMAL, wx.BOLD)) + + lbl_min = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Min Value (uV):")) + + self.spin_min = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(70, 23), inc=10 + ) + self.spin_min.Enable(1) + self.spin_min.SetRange(0, 10000) + self.spin_min.SetValue(self.conf.get("colormap_range_uv").get("min")) + + lbl_low = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Low Value (uV):")) + self.spin_low = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(70, 23), inc=10 + ) + self.spin_low.Enable(1) + self.spin_low.SetRange(0, 10000) + self.spin_low.SetValue(self.conf.get("colormap_range_uv").get("low")) + + lbl_mid = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Mid Value (uV):")) + self.spin_mid = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(70, 23), inc=10 + ) + self.spin_mid.Enable(1) + self.spin_mid.SetRange(0, 10000) + self.spin_mid.SetValue(self.conf.get("colormap_range_uv").get("mid")) + + lbl_max = wx.StaticText(bsizer_mep.GetStaticBox(), -1, _("Max Value (uV):")) + self.spin_max = wx.SpinCtrlDouble( + bsizer_mep.GetStaticBox(), -1, "", size=wx.Size(70, 23), inc=10 + ) + self.spin_max.Enable(1) + self.spin_max.SetRange(0, 10000) + self.spin_max.SetValue(self.conf.get("colormap_range_uv").get("max")) + + line_cm_texts = wx.BoxSizer(wx.HORIZONTAL) + line_cm_texts.AddMany( + [ + (lbl_min, 1, wx.EXPAND | wx.GROW | wx.RIGHT | wx.LEFT, 5), + (lbl_low, 1, wx.EXPAND | wx.GROW | wx.RIGHT | wx.LEFT, 5), + (lbl_mid, 1, wx.EXPAND | wx.GROW | wx.RIGHT | wx.LEFT, 5), + (lbl_max, 1, wx.EXPAND | wx.GROW | wx.RIGHT | wx.LEFT, 5), + ] + ) + + line_cm_spins = wx.BoxSizer(wx.HORIZONTAL) + line_cm_spins.AddMany( + [ + (self.spin_min, 0, wx.RIGHT | wx.LEFT | wx.EXPAND | wx.GROW, 12), + (self.spin_low, 0, wx.RIGHT | wx.LEFT | wx.EXPAND | wx.GROW, 12), + (self.spin_mid, 0, wx.RIGHT | wx.LEFT | wx.EXPAND | wx.GROW, 12), + (self.spin_max, 0, wx.RIGHT | wx.LEFT | wx.EXPAND | wx.GROW, 12), + ] + ) + + # Binding events for the colormap ranges + for ctrl in zip( + [self.spin_min, self.spin_low, self.spin_mid, self.spin_max], + ["min", "low", "mid", "max"], + ): + ctrl[0].Bind( + wx.EVT_TEXT, partial(self.OnSelectColormapRange, ctrl=ctrl[0], key=ctrl[1]) + ) + ctrl[0].Bind( + wx.EVT_SPINCTRL, partial(self.OnSelectColormapRange, ctrl=ctrl[0], key=ctrl[1]) + ) + + colormap_custom.AddMany( + [ + (lbl_colormap_ranges, 0, wx.TOP | wx.BOTTOM | wx.LEFT, 5), + (line_cm_texts, 0, wx.GROW | wx.SHRINK | wx.LEFT | wx.RIGHT, 0), + (line_cm_spins, 0, wx.GROW | wx.SHRINK | wx.LEFT | wx.RIGHT, 0), + ] + ) + + # Reset to defaults button + btn_reset = wx.Button(bsizer_mep.GetStaticBox(), -1, _("Reset to defaults")) + btn_reset.Bind(wx.EVT_BUTTON, self.ResetMEPSettings) + + # centered button reset + colormap_custom.Add(btn_reset, 0, wx.ALIGN_CENTER | wx.TOP, 15) + + colormap_sizer.Add(colormap_custom, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5) + bsizer_mep.AddMany( + [ + (surface_sel_sizer, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5), + (line_gaussian_radius, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5), + (line_std_dev, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5), + (colormap_sizer, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5), + ] + ) + + return bsizer_mep + + def ResetMEPSettings(self, event): + # fire an event that will reset the MEP settings to the default values in MEP Visualizer + Publisher.sendMessage("Reset MEP Config") + # self.session.SetConfig('mep_configuration', self.conf) + self.UpdateMEPFromSession() + + def UpdateMEPFromSession(self): + self.conf = dict(self.session.GetConfig("mep_configuration")) + self.spin_gaussian_radius.SetValue(self.conf.get("gaussian_radius")) + self.spin_std_dev.SetValue(self.conf.get("gaussian_sharpness")) + + self.combo_thresh.SetSelection(self.colormaps.index(self.conf.get("mep_colormap"))) + partial(self.OnSelectColormap, event=None, ctrl=self.combo_thresh) + partial(self.OnSelectColormapRange, event=None, ctrl=self.spin_min, key="min") + + ranges = self.conf.get("colormap_range_uv") + ranges = dict(ranges) + self.spin_min.SetValue(ranges.get("min")) + self.spin_low.SetValue(ranges.get("low")) + self.spin_mid.SetValue(ranges.get("mid")) + self.spin_max.SetValue(ranges.get("max")) + + def OnSelectStdDev(self, evt, ctrl): + self.conf["gaussian_sharpness"] = ctrl.GetValue() + # Save the configuration + self.session.SetConfig("mep_configuration", self.conf) + + def OnSelectGaussianRadius(self, evt, ctrl): + self.conf["gaussian_radius"] = ctrl.GetValue() + # Save the configuration + self.session.SetConfig("mep_configuration", self.conf) + + def OnSelectColormapRange(self, evt, ctrl, key): + self.conf["colormap_range_uv"][key] = ctrl.GetValue() + self.session.SetConfig("mep_configuration", self.conf) + def LoadSelection(self, values): rendering = values[const.RENDERING] surface_interpolation = values[const.SURFACE_INTERPOLATION] @@ -206,6 +528,67 @@ def LoadSelection(self, values): self.rb_inter.SetSelection(int(surface_interpolation)) self.rb_inter_sl.SetSelection(int(slice_interpolation)) + def OnSelectColormap(self, event=None): + self.conf["mep_colormap"] = self.colormaps[self.combo_thresh.GetSelection()] + colors = self.GenerateColormapColors(self.conf.get("mep_colormap"), self.number_colors) + + # Save the configuration + self.session.SetConfig("mep_configuration", self.conf) + Publisher.sendMessage("Save Preferences") + self.UpdateGradient(self.gradient, colors) + + def GenerateColormapColors(self, colormap_name, number_colors=4): + # Extract colors and positions + color_def = const.MEP_COLORMAP_DEFINITIONS[colormap_name] + colors = list(color_def.values()) + positions = [0.0, 0.25, 0.5, 1.0] # Assuming even spacing between colors + + # Create LinearSegmentedColormap + cmap = mcolors.LinearSegmentedColormap.from_list( + colormap_name, list(zip(positions, colors)) + ) + colors_gradient = [ + ( + int(255 * cmap(i)[0]), + int(255 * cmap(i)[1]), + int(255 * cmap(i)[2]), + int(255 * cmap(i)[3]), + ) + for i in np.linspace(0, 1, number_colors) + ] + + return colors_gradient + + def UpdateGradient(self, gradient, colors): + gradient.SetGradientColours(colors) + gradient.Refresh() + gradient.Update() + + self.Refresh() + self.Update() + self.Show(True) + + def OnComboName(self, evt): + from invesalius import project as prj + + self.proj = prj.Project() + surface_index = self.combo_brain_surface_name.GetSelection() + Publisher.sendMessage("Show single surface", index=surface_index, visibility=True) + Publisher.sendMessage("Get visible surface actor") + Publisher.sendMessage("Press motor map button", pressed=True) + + self.button_colour.SetColour( + [int(value * 255) for value in self.proj.surface_dict[surface_index].colour] + ) + + def OnSelectColour(self, evt): + colour = [value / 255.0 for value in self.button_colour.GetColour()] + Publisher.sendMessage( + "Set surface colour", + surface_index=self.combo_brain_surface_name.GetSelection(), + colour=colour, + ) + class LoggingTab(wx.Panel): def __init__(self, parent): diff --git a/invesalius/gui/task_imports.py b/invesalius/gui/task_imports.py index ec62c33ae..c48dd1fa9 100644 --- a/invesalius/gui/task_imports.py +++ b/invesalius/gui/task_imports.py @@ -183,6 +183,7 @@ def __init__(self, parent): (_("Tractography"), tractography.TaskPanel), (_("E-Field"), efield.TaskPanel), (_("fMRI support"), fmrisupport.TaskPanel), + # (_("MEP mapping"), mepmapping.TaskPanel), # TODO: Add marker file import and export colored stl ] style = fpb.CaptionBarStyle() diff --git a/invesalius/gui/task_mepmapping.py b/invesalius/gui/task_mepmapping.py new file mode 100644 index 000000000..56b238525 --- /dev/null +++ b/invesalius/gui/task_mepmapping.py @@ -0,0 +1,346 @@ +# -------------------------------------------------------------------------- +# Software: InVesalius - Software de Reconstrucao 3D de Imagens Medicas +# Copyright: (C) 2001 Centro de Pesquisas Renato Archer +# Homepage: http://www.softwarepublico.gov.br +# Contact: invesalius@cti.gov.br +# License: GNU - GPL 2 (LICENSE.txt/LICENCA.txt) +# -------------------------------------------------------------------------- +# Este programa e software livre; voce pode redistribui-lo e/ou +# modifica-lo sob os termos da Licenca Publica Geral GNU, conforme +# publicada pela Free Software Foundation; de acordo com a versao 2 +# da Licenca. +# +# Este programa eh distribuido na expectativa de ser util, mas SEM +# QUALQUER GARANTIA; sem mesmo a garantia implicita de +# COMERCIALIZACAO ou de ADEQUACAO A QUALQUER PROPOSITO EM +# PARTICULAR. Consulte a Licenca Publica Geral GNU para obter mais +# detalhes. +# -------------------------------------------------------------------------- + +import sys + +import matplotlib.pyplot as plt +import nibabel as nb +import numpy as np +import wx +import wx.lib.colourselect as csel +import wx.lib.masked.numctrl +import wx.lib.scrolledpanel as scrolled + +import invesalius.constants as const +import invesalius.gui.dialogs as dlg +import invesalius.session as ses +import invesalius.utils as utils +from invesalius.data.slice_ import Slice +from invesalius.i18n import tr as _ +from invesalius.pubsub import pub as Publisher + + +class TaskPanel(wx.Panel): + def __init__(self, parent): + wx.Panel.__init__(self, parent) + + inner_panel = InnerTaskPanel(self) + + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(inner_panel, 1, wx.EXPAND | wx.GROW | wx.BOTTOM | wx.RIGHT | wx.LEFT, 7) + sizer.Fit(self) + + self.SetSizer(sizer) + self.Fit() + self.Update() + self.SetAutoLayout(1) + + +class InnerTaskPanel(wx.Panel): + def __init__(self, parent): + wx.Panel.__init__(self, parent) + + # White background looks better + background_colour = wx.Colour(255, 255, 255) + self.SetBackgroundColour(background_colour) + + self.__bind_events() + + self.session = ses.Session() + self.slc = Slice() + self.colormaps = [str(cmap) for cmap in const.MEP_COLORMAP_DEFINITIONS] + + self.current_colormap = self.colormaps[0] + self.number_colors = 10 + self.cluster_volume = None + self.zero_value = 0 + + line0 = wx.StaticText(self, -1, _("Motor Mapping Configuration")) + + # Button for import config coil file + # tooltip = _("Select the brain surface to be mapped on.") + # btn_load = wx.Button(self, -1, _("Load"), size=wx.Size(65, 23)) + # btn_load.SetToolTip(tooltip) + # btn_load.Enable(1) + # btn_load.Bind(wx.EVT_BUTTON, self.OnLoadFmri) + # self.btn_load = btn_load + + # Create a horizontal sizer to represent button save + line1 = wx.BoxSizer(wx.VERTICAL) + + # add surface panel window + self.surface_panel = SurfaceProperties(self) + line1.Add(self.surface_panel, 5, wx.LEFT | wx.EXPAND | wx.GROW | wx.TOP | wx.RIGHT, 2) + line1.AddSpacer(5) + # line1.Add(btn_load, 5, wx.LEFT | wx.TOP | wx.RIGHT, 1) + + line3 = wx.StaticText(self, -1, _("Markers Import/Export:")) + + # Add all lines into main sizer + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.AddSpacer(7) + sizer.Add(line0, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + sizer.AddSpacer(5) + # sizer.Add(line1, 0, wx.GROW|wx.EXPAND|wx.LEFT|wx.RIGHT, 5) + sizer.Add(line1, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT, 3) + sizer.AddSpacer(5) + sizer.Add(line3, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + sizer.AddSpacer(5) + + sizer.Fit(self) + + self.SetSizerAndFit(sizer) + self.Update() + self.SetAutoLayout(1) + + def __bind_events(self): + pass + + def OnSelectColormap(self, event=None): + self.current_colormap = self.colormaps[self.combo_thresh.GetSelection()] + colors = self.GenerateColormapColors(self.current_colormap, self.number_colors) + + self.UpdateGradient(self.gradient, colors) + + if isinstance(self.cluster_volume, np.ndarray): + self.apply_colormap(self.current_colormap, self.cluster_volume, self.zero_value) + + def GenerateColormapColors(self, colormap_name, number_colors=10): + cmap = plt.get_cmap(colormap_name) + colors_gradient = [ + ( + int(255 * cmap(i)[0]), + int(255 * cmap(i)[1]), + int(255 * cmap(i)[2]), + int(255 * cmap(i)[3]), + ) + for i in np.linspace(0, 1, number_colors) + ] + + return colors_gradient + + def UpdateGradient(self, gradient, colors): + gradient.SetGradientColours(colors) + gradient.Refresh() + gradient.Update() + + self.Refresh() + self.Update() + self.Show(True) + + def OnLoadFmri(self, event=None): + filename = dlg.ShowImportOtherFilesDialog(id_type=const.ID_NIFTI_IMPORT) + filename = utils.decode(filename, const.FS_ENCODE) + + fmri_data = nb.squeeze_image(nb.load(filename)) + fmri_data = nb.as_closest_canonical(fmri_data) + fmri_data.update_header() + + cluster_volume_original = fmri_data.get_fdata().T[:, ::-1].copy() + # Normalize the data to 0-1 range + cluster_volume_normalized = (cluster_volume_original - np.min(cluster_volume_original)) / ( + np.max(cluster_volume_original) - np.min(cluster_volume_original) + ) + # Convert data to 8-bit integer + self.cluster_volume = (cluster_volume_normalized * 255).astype(np.uint8) + + self.zero_value = int( + (0.0 - np.min(cluster_volume_original)) + / (np.max(cluster_volume_original) - np.min(cluster_volume_original)) + * 255 + ) + + if self.slc.matrix.shape != self.cluster_volume.shape: + wx.MessageBox( + ("The overlay volume does not match the underlying structural volume"), + ("InVesalius 3"), + ) + + else: + self.slc.aux_matrices["color_overlay"] = self.cluster_volume + # 3. Show colors + self.slc.to_show_aux = "color_overlay" + self.apply_colormap(self.current_colormap, self.cluster_volume, self.zero_value) + + def apply_colormap(self, colormap, cluster_volume, zero_value): + # 2. Attribute different hue accordingly + cmap = plt.get_cmap(colormap) + + # new way + # Flatten the data to 1D + cluster_volume_unique = np.unique(cluster_volume) + # Map the scaled data to colors + colors = cmap(cluster_volume_unique / 255) + # Create a dictionary where keys are scaled data and values are colors + color_dict = {val: color for val, color in zip(cluster_volume_unique, map(tuple, colors))} + + self.slc.aux_matrices_colours["color_overlay"] = color_dict + # add transparent color for nans and non GM voxels + if zero_value in self.slc.aux_matrices_colours["color_overlay"]: + self.slc.aux_matrices_colours["color_overlay"][zero_value] = (0.0, 0.0, 0.0, 0.0) + else: + print("Zero value not found in color_overlay. No data is set as transparent.") + + Publisher.sendMessage("Reload actual slice") + + +class SurfaceProperties(scrolled.ScrolledPanel): + def __init__(self, parent): + scrolled.ScrolledPanel.__init__(self, parent) + try: + default_colour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_MENUBAR) + except AttributeError: + default_colour = wx.SystemSettings_GetColour(wx.SYS_COLOUR_MENUBAR) + self.SetBackgroundColour(default_colour) + + self.surface_list = [] + + # LINE 1 + + # Combo related to mask name + combo_surface_name = wx.ComboBox(self, -1, style=wx.CB_DROPDOWN | wx.CB_READONLY) + # combo_surface_name.SetSelection(0) + if sys.platform != "win32": + combo_surface_name.SetWindowVariant(wx.WINDOW_VARIANT_SMALL) + combo_surface_name.Bind(wx.EVT_COMBOBOX, self.OnComboName) + self.combo_surface_name = combo_surface_name + + # Mask colour + button_colour = csel.ColourSelect(self, -1, colour=(0, 0, 255), size=(22, -1)) + button_colour.Bind(csel.EVT_COLOURSELECT, self.OnSelectColour) + self.button_colour = button_colour + + # Sizer which represents the first line + line1 = wx.BoxSizer(wx.HORIZONTAL) + line1.Add(combo_surface_name, 1, wx.LEFT | wx.EXPAND | wx.GROW | wx.TOP | wx.RIGHT, 7) + line1.Add(button_colour, 0, wx.TOP | wx.RIGHT, 7) + + # LINE 2 + text_transparency = wx.StaticText(self, -1, _("Brain Surface:")) + + # MIX LINE 2 AND 3 + # flag_link = wx.EXPAND | wx.GROW | wx.RIGHT + fixed_sizer = wx.BoxSizer(wx.HORIZONTAL) + + fixed_sizer.Add(text_transparency, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT, 5) + # fixed_sizer.AddSpacer(7) + fixed_sizer.Add(line1, 0, wx.EXPAND | wx.GROW | wx.LEFT | wx.RIGHT, 5) + + # LINE 4 + # # Add all lines into main sizer + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(fixed_sizer, 0, wx.GROW | wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP | wx.BOTTOM, 10) + # sizer.Add(line1, 1, wx.GROW | wx.EXPAND | wx.TOP, 10) + + sizer.Fit(self) + + self.SetSizerAndFit(sizer) + self.Update() + self.SetAutoLayout(1) + + self.SetupScrolling() + + self.Bind(wx.EVT_SIZE, self.OnResize) + + self.__bind_events() + + def OnResize(self, evt): + self.SetupScrolling() + + def __bind_events(self): + Publisher.subscribe(self.InsertNewSurface, "Update surface info in GUI") + Publisher.subscribe(self.ChangeSurfaceName, "Change surface name") + Publisher.subscribe(self.OnCloseProject, "Close project data") + Publisher.subscribe(self.OnRemoveSurfaces, "Remove surfaces") + + def OnRemoveSurfaces(self, surface_indexes): + s = self.combo_surface_name.GetSelection() + ns = 0 + + old_dict = self.surface_list + new_dict = [] + i = 0 + for n, (name, index) in enumerate(old_dict): + if n not in surface_indexes: + new_dict.append([name, i]) + if s == n: + ns = i + i += 1 + self.surface_list = new_dict + + self.combo_surface_name.SetItems([n[0] for n in self.surface_list]) + + if self.surface_list: + self.combo_surface_name.SetSelection(ns) + + def OnCloseProject(self): + self.CloseProject() + + def CloseProject(self): + n = self.combo_surface_name.GetCount() + for i in range(n - 1, -1, -1): + self.combo_surface_name.Delete(i) + self.surface_list = [] + + def ChangeSurfaceName(self, index, name): + self.surface_list[index][0] = name + self.combo_surface_name.SetString(index, name) + + def InsertNewSurface(self, surface): + index = surface.index + name = surface.name + colour = [int(value * 255) for value in surface.colour] + i = 0 + try: + i = self.surface_list.index([name, index]) + overwrite = True + except ValueError: + overwrite = False + + if overwrite: + self.surface_list[i] = [name, index] + else: + self.surface_list.append([name, index]) + i = len(self.surface_list) - 1 + + self.combo_surface_name.SetItems([n[0] for n in self.surface_list]) + self.combo_surface_name.SetSelection(i) + # transparency = 100*surface.transparency + # print("Button color: ", colour) + self.button_colour.SetColour(colour) + # self.slider_transparency.SetValue(int(transparency)) + # Publisher.sendMessage('Update surface data', (index)) + + def OnComboName(self, evt): + surface_name = evt.GetString() + surface_index = evt.GetSelection() + self.button_colour.SetColour( + [int(value * 255) for value in self.surface_list[surface_index][2]] + ) + Publisher.sendMessage( + "Change surface selected", surface_index=self.surface_list[surface_index][1] + ) + + def OnSelectColour(self, evt): + colour = [value / 255.0 for value in evt.GetValue()] + Publisher.sendMessage( + "Set surface colour", + surface_index=self.combo_surface_name.GetSelection(), + colour=colour, + ) diff --git a/invesalius/gui/task_navigator.py b/invesalius/gui/task_navigator.py index de8dacd65..600cdb35f 100644 --- a/invesalius/gui/task_navigator.py +++ b/invesalius/gui/task_navigator.py @@ -150,6 +150,7 @@ def __init__(self, parent): self.tracker = nav_hub.tracker self.image = nav_hub.image self.navigation = nav_hub.navigation + self.mep_visualizer = nav_hub.mep_visualizer # Fold panel style style = fpb.CaptionBarStyle() @@ -1094,6 +1095,7 @@ def __init__(self, parent, nav_hub): self.image = nav_hub.image self.pedal_connector = nav_hub.pedal_connector self.neuronavigation_api = nav_hub.neuronavigation_api + self.mep_visualizer = nav_hub.mep_visualizer self.__bind_events() @@ -1145,6 +1147,7 @@ def __init__(self, parent, nav_hub): self.robot = nav_hub.robot self.icp = nav_hub.icp self.image = nav_hub.image + self.mep_visualizer = nav_hub.mep_visualizer self.nav_status = False self.target_mode = False @@ -1339,6 +1342,25 @@ def __init__(self, parent, nav_hub): ) self.robot_free_drive_button = robot_free_drive_button + # Toggle button for displaying TMS motor mapping on brain + tooltip = _("Show TMS motor mapping on brain") + BMP_MOTOR_MAP = wx.Bitmap( + str(inv_paths.ICON_DIR.joinpath("brain_eye.png")), wx.BITMAP_TYPE_PNG + ) + show_motor_map_button = wx.ToggleButton( + self, -1, "", style=pbtn.PB_STYLE_SQUARE, size=ICON_SIZE + ) + show_motor_map_button.SetBackgroundColour(GREY_COLOR) + show_motor_map_button.SetBitmap(BMP_MOTOR_MAP) + show_motor_map_button.SetToolTip(tooltip) + show_motor_map_button.SetValue(False) + show_motor_map_button.Enable(True) + + show_motor_map_button.Bind( + wx.EVT_TOGGLEBUTTON, partial(self.OnShowMotorMapButton, ctrl=show_motor_map_button) + ) + self.show_motor_map_button = show_motor_map_button + # Sizers start_navigation_button_sizer = wx.BoxSizer(wx.VERTICAL) start_navigation_button_sizer.AddMany( @@ -1357,6 +1379,7 @@ def __init__(self, parent, nav_hub): (efield_checkbox), (lock_to_target_button), (show_coil_button), + (show_motor_map_button), ] ) @@ -1417,6 +1440,9 @@ def __bind_events(self): Publisher.subscribe(self.HideTargetButton, "Hide target button") Publisher.subscribe(self.PressTargetModeButton, "Press target mode button") + Publisher.subscribe(self.PressMotorMapButton, "Press motor map button") + Publisher.subscribe(self.EnableMotorMapButton, "Enable motor map button") + # Conditions for enabling 'target mode' button: Publisher.subscribe(self.TrackObject, "Track object") @@ -1813,6 +1839,21 @@ def OnRobotFreeDriveButton(self, evt=None, ctrl=None): else: Publisher.sendMessage("Neuronavigation to Robot: Set free drive", set=False) + # TMS Motor Mapping related + # 'Motor Map' button + def PressMotorMapButton(self, pressed=False): + self.UpdateToggleButton(self.show_motor_map_button, pressed) + self.OnShowMotorMapButton() + + def EnableMotorMapButton(self, enabled=False): + self.EnableToggleButton(self.show_motor_map_button, enabled) + self.UpdateToggleButton(self.show_motor_map_button) + + def OnShowMotorMapButton(self, evt=None, ctrl=None): + pressed = self.show_motor_map_button.GetValue() + if self.mep_visualizer.DisplayMotorMap(show=pressed): + self.UpdateToggleButton(self.show_motor_map_button) + class MarkersPanel(wx.Panel, ColumnSorterMixin): def __init__(self, parent, nav_hub): @@ -1941,6 +1982,9 @@ def __init__(self, parent, nav_hub): marker_list_ctrl.InsertColumn(const.POINT_OF_INTEREST_TARGET_COLUMN, "Efield Target") marker_list_ctrl.SetColumnWidth(const.POINT_OF_INTEREST_TARGET_COLUMN, 45) + marker_list_ctrl.InsertColumn(const.MEP_COLUMN, "MEP (uV)") + marker_list_ctrl.SetColumnWidth(const.MEP_COLUMN, 45) + if self.session.GetConfig("debug"): marker_list_ctrl.InsertColumn(const.X_COLUMN, "X") marker_list_ctrl.SetColumnWidth(const.X_COLUMN, 45) @@ -2015,6 +2059,7 @@ def __bind_events(self): Publisher.subscribe(self._UnsetTarget, "Unset target") Publisher.subscribe(self._UnsetPointOfInterest, "Unset point of interest") Publisher.subscribe(self._UpdateMarkerLabel, "Update marker label") + Publisher.subscribe(self._UpdateMEP, "Update marker mep") def __get_selected_items(self): """ @@ -2094,6 +2139,7 @@ def _DeleteMultiple(self, markers): self.marker_list_ctrl.SetItem(n, const.ID_COLUMN, str(m_id - reduction_in_m_id)) self.marker_list_ctrl.Show() + Publisher.sendMessage("Redraw MEP mapping") def _SetPointOfInterest(self, marker): idx = self.__find_marker_index(marker.marker_id) @@ -2131,6 +2177,20 @@ def _UpdateMarkerLabel(self, marker): if current_uuid == uuid: self.itemDataMap[key][const.LABEL_COLUMN] = marker.label + def _UpdateMEP(self, marker): + idx = self.__find_marker_index(marker.marker_id) + self.marker_list_ctrl.SetItem(idx, const.MEP_COLUMN, str(marker.mep_value)) + + # Update the marker label in self.itemDataMap so that sorting works + uuid = marker.marker_uuid + for key, data in self.itemDataMap.items(): + current_uuid = data[-1] + if current_uuid == uuid: + self.itemDataMap[key][const.MEP_COLUMN] = marker.mep_value + + # Trigger redraw MEP mapping + Publisher.sendMessage("Redraw MEP mapping") + @staticmethod def __list_fiducial_labels(): """Return the list of marker labels denoting fiducials.""" @@ -2203,8 +2263,10 @@ def OnMouseRightDown(self, evt): # Show 'Set as target'/'Unset target' menu item only if the marker is a coil target. if is_coil_target: + mep_menu_item = menu_id.Append(unique_menu_id + 4, _("Change MEP value")) + menu_id.Bind(wx.EVT_MENU, self.OnMenuChangeMEP, mep_menu_item) if is_active_target: - target_menu_item = menu_id.Append(unique_menu_id + 4, _("Unset target")) + target_menu_item = menu_id.Append(unique_menu_id + 5, _("Unset target")) menu_id.Bind(wx.EVT_MENU, self.OnMenuUnsetTarget, target_menu_item) if has_mTMS: brain_target_menu_item = menu_id.Append( @@ -2212,7 +2274,7 @@ def OnMouseRightDown(self, evt): ) menu_id.Bind(wx.EVT_MENU, self.OnSetBrainTarget, brain_target_menu_item) else: - target_menu_item = menu_id.Append(unique_menu_id + 4, _("Set as target")) + target_menu_item = menu_id.Append(unique_menu_id + 5, _("Set as target")) menu_id.Bind(wx.EVT_MENU, self.OnMenuSetTarget, target_menu_item) # Show 'Create coil target' menu item if the marker is a coil pose. @@ -2623,6 +2685,13 @@ def OnMenuUnsetTarget(self, evt): marker_id = self.__get_marker_id(idx) self.markers.UnsetTarget(marker_id) + def OnMenuChangeMEP(self, evt): + idx = self.marker_list_ctrl.GetFocusedItem() + marker = self.__get_marker(idx) + + new_mep = dlg.ShowEnterMEPValue(self.marker_list_ctrl.GetItemText(idx, const.MEP_COLUMN)) + self.markers.ChangeMEP(marker, new_mep) + def _UnsetTarget(self, marker): idx = self.__find_marker_index(marker.marker_id) @@ -2768,6 +2837,7 @@ def OnDeleteAllMarkers(self, evt=None): return self.markers.Clear() self.itemDataMap.clear() + Publisher.sendMessage("Redraw MEP mapping") def OnDeleteFiducialMarker(self, label): indexes = [] @@ -2822,6 +2892,7 @@ def OnCreateMarker( session_id=None, marker_type=None, cortex_position_orientation=None, + mep_value=None, ): if label is None: label = self.GetNextMarkerLabel() @@ -2855,6 +2926,7 @@ def OnCreateMarker( session_id=session_id, marker_type=marker_type, cortex_position_orientation=cortex_position_orientation, + mep_value=mep_value, ) self.markers.AddMarker(marker, render=True, focus=True) @@ -2924,6 +2996,7 @@ def GetMarkersFromFile(self, filename, overwrite_image_fiducials): utils.debug(e) self.marker_list_ctrl.Show() + Publisher.sendMessage("Redraw MEP mapping") Publisher.sendMessage("Render volume viewer") Publisher.sendMessage("Update UI for refine tab") self.markers.SaveState() @@ -3041,6 +3114,7 @@ def CreateMarker( cortex_position_orientation=None, z_offset=0.0, z_rotation=0.0, + mep_value=None, ): """ Create a new marker object. @@ -3065,6 +3139,7 @@ def CreateMarker( ) marker.z_offset = z_offset marker.z_rotation = z_rotation + marker.mep_value = mep_value # Marker IDs start from zero, hence len(self.markers) will be the ID of the new marker. marker.marker_id = len(self.markers.list) @@ -3096,6 +3171,7 @@ def _AddMarker(self, marker, render, focus): list_entry[const.POINT_OF_INTEREST_TARGET_COLUMN] = ( "Yes" if marker.is_point_of_interest else "" ) + list_entry[const.MEP_COLUMN] = str(marker.mep_value) if marker.mep_value else "" if self.session.GetConfig("debug"): list_entry.append(round(marker.x, 1)) diff --git a/invesalius/navigation/markers.py b/invesalius/navigation/markers.py index 63f7c379a..85c3246dc 100644 --- a/invesalius/navigation/markers.py +++ b/invesalius/navigation/markers.py @@ -76,6 +76,9 @@ def AddMarker(self, marker, render=True, focus=False): orientation=marker.orientation, ) + if marker.mep_value: + Publisher.sendMessage("Update marker mep", marker=marker) + if render: # this behavior could be misleading self.SaveState() @@ -104,6 +107,9 @@ def DeleteMarker(self, marker_id, render=True): self.SaveState() + if marker.mep_value: + Publisher.sendMessage("Redraw MEP mapping") + def DeleteMultiple(self, marker_ids): markers = [] for m_id in sorted(marker_ids, reverse=True): @@ -206,6 +212,11 @@ def FindPointOfInterest(self): def ChangeLabel(self, marker, new_label): marker.label = str(new_label) Publisher.sendMessage("Update marker label", marker=marker) + self.SaveState() + + def ChangeMEP(self, marker, new_mep): + marker.mep_value = new_mep + Publisher.sendMessage("Update marker mep", marker=marker) self.SaveState() diff --git a/invesalius/navigation/navigation.py b/invesalius/navigation/navigation.py index 4a5cc9563..7f74a2889 100644 --- a/invesalius/navigation/navigation.py +++ b/invesalius/navigation/navigation.py @@ -36,6 +36,7 @@ import invesalius.project as prj import invesalius.session as ses from invesalius.data.markers.marker import MarkerType +from invesalius.data.visualization.mep_visualizer import MEPVisualizer from invesalius.i18n import tr as _ from invesalius.navigation.image import Image from invesalius.navigation.iterativeclosestpoint import IterativeClosestPoint @@ -68,6 +69,7 @@ def __init__(self, window=None): icp=self.icp, ) self.markers = MarkersControl(robot=self.robot) + self.mep_visualizer = MEPVisualizer() class QueueCustom(queue.Queue): diff --git a/invesalius/session.py b/invesalius/session.py index 39dfc33ae..7d03b479f 100644 --- a/invesalius/session.py +++ b/invesalius/session.py @@ -289,6 +289,7 @@ def _ReadState(self) -> bool: success = False if os.path.exists(STATE_PATH): print("Restoring a previous state...") + print("State file found.", STATE_PATH) state_file = open(STATE_PATH) try: