diff --git a/invesalius/constants.py b/invesalius/constants.py index c9c1c8d48..fcb3f75fc 100644 --- a/invesalius/constants.py +++ b/invesalius/constants.py @@ -802,9 +802,23 @@ Z_OFFSET_COLUMN = 5 POINT_OF_INTEREST_TARGET_COLUMN = 6 MEP_COLUMN = 7 -X_COLUMN = 8 -Y_COLUMN = 9 -Z_COLUMN = 10 +UUID = 8 +X_COLUMN = 9 +Y_COLUMN = 10 +Z_COLUMN = 11 + +# The column order in the brain marker panel +# +BRAIN_ID_COLUMN = 0 +BRAIN_SESSION_COLUMN = 1 +BRAIN_MARKER_TYPE_COLUMN = 2 +BRAIN_LABEL_COLUMN = 3 +BRAIN_MEP_COLUMN = 4 +BRAIN_X_MTMS = 5 +BRAIN_Y_MTMS = 6 +BRAIN_R_MTMS = 7 +BRAIN_INTENSITY_MTMS = 8 +BRAIN_UUID = 9 # ------------ Navigation defaults ------------------- @@ -1049,10 +1063,10 @@ "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), + "min": (0.267, 0.004, 0.329), + "low": (0.192, 0.408, 0.556), + "mid": (0.137, 0.718, 0.475), + "max": (0.993, 0.906, 0.144), }, "Grayscale": { # Grayscale (often used for CT/MRI) "min": (0.0, 0.0, 0.0), # Black diff --git a/invesalius/data/markers/marker.py b/invesalius/data/markers/marker.py index f220cd239..9328658fa 100644 --- a/invesalius/data/markers/marker.py +++ b/invesalius/data/markers/marker.py @@ -64,9 +64,9 @@ class Marker: is_target: bool = False is_point_of_interest: bool = False session_id: int = 1 - x_cortex: float = 0 - y_cortex: float = 0 - z_cortex: float = 0 + x_cortex: float = dataclasses.field(default=None) + y_cortex: float = dataclasses.field(default=None) + z_cortex: float = dataclasses.field(default=None) alpha_cortex: float = dataclasses.field(default=None) beta_cortex: float = dataclasses.field(default=None) gamma_cortex: float = dataclasses.field(default=None) @@ -78,6 +78,7 @@ class Marker: # #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) + brain_target_list: list = dataclasses.field(default_factory=list) # x, y, z can be jointly accessed as position @property @@ -209,6 +210,24 @@ def to_csv_row(self): ) return res + def to_brain_targets_dict(self): + return { + "position": self.position, + "orientation": self.orientation, + "colour": self.colour, + "size": self.size, + "label": self.label, + "is_target": self.is_target, + "marker_type": self.marker_type.value, + "session_id": self.session_id, + "mep_value": self.mep_value, + "marker_uuid": self.marker_uuid, + "x_mtms": self.x_mtms, + "y_mtms": self.y_mtms, + "r_mtms": self.r_mtms, + "intensity_mtms": self.intensity_mtms, + } + def to_dict(self): return { "position": self.position, @@ -225,6 +244,8 @@ def to_dict(self): "z_rotation": self.z_rotation, "z_offset": self.z_offset, "mep_value": self.mep_value, + "brain_target_list": self.brain_target_list, + "marker_uuid": self.marker_uuid, } def from_dict(self, d): @@ -270,6 +291,8 @@ def from_dict(self, d): 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) + brain_target_list = d.get("brain_target_list", []) + marker_uuid = d.get("marker_uuid", "") self.size = d["size"] self.label = d["label"] @@ -286,6 +309,8 @@ def from_dict(self, d): self.z_offset = z_offset self.z_rotation = z_rotation self.mep_value = mep_value + self.brain_target_list = brain_target_list + self.marker_uuid = marker_uuid return self diff --git a/invesalius/data/visualization/coil_visualizer.py b/invesalius/data/visualization/coil_visualizer.py index 2535a9cdd..db19257eb 100644 --- a/invesalius/data/visualization/coil_visualizer.py +++ b/invesalius/data/visualization/coil_visualizer.py @@ -139,7 +139,7 @@ def ShowCoil(self, state, coil_name=None): if self.target_coil_actor is not None: self.target_coil_actor.SetVisibility(state) - # self.vector_field_assembly.SetVisibility(state) # LUKATODO: Keep this hidden for now + self.vector_field_assembly.SetVisibility(state) # LUKATODO: Keep this hidden for now if not self.is_navigating: Publisher.sendMessage("Render volume viewer") @@ -262,7 +262,7 @@ def AddCoil(self, coil_name, coil_path): self.coils[coil_name]["path"] = coil_path # LUKATODO: Vector field assembly follows a different pattern for addition, should unify. - # self.vector_field_assembly.SetVisibility(1) + self.vector_field_assembly.SetVisibility(1) def RemoveCoil(self, coil_name=None): if coil_name is not None: @@ -301,4 +301,5 @@ def UpdateCoilPoses(self, m_imgs, coords): self.coils[name]["center_actor"].SetUserMatrix(m_img_vtk) # LUKATODO - # self.vector_field_assembly.SetUserMatrix(m_img_vtk) + + self.vector_field_assembly.SetUserMatrix(m_img_vtk) diff --git a/invesalius/data/visualization/marker_visualizer.py b/invesalius/data/visualization/marker_visualizer.py index 0d56755a1..2da97fa87 100644 --- a/invesalius/data/visualization/marker_visualizer.py +++ b/invesalius/data/visualization/marker_visualizer.py @@ -75,9 +75,12 @@ def __bind_events(self): Publisher.subscribe(self.UnsetTarget, "Unset target") Publisher.subscribe(self.SetTargetTransparency, "Set target transparency") Publisher.subscribe(self.SetCoilAtTarget, "Coil at target") - Publisher.subscribe(self.UpdateVectorField, "Update vector field") + Publisher.subscribe(self.UpdateBrainTargets, "Update brain targets") Publisher.subscribe(self.UpdateNavigationStatus, "Navigation status") Publisher.subscribe(self.UpdateTargetMode, "Set target mode") + Publisher.subscribe( + self.UpdateVectorFieldAssemblyVisibility, "Set vector field assembly visibility" + ) def UpdateNavigationStatus(self, nav_status, vis_status): self.is_navigating = nav_status @@ -85,13 +88,20 @@ def UpdateNavigationStatus(self, nav_status, vis_status): def UpdateTargetMode(self, enabled=False): self.is_target_mode = enabled - def UpdateVectorField(self): + def UpdateVectorFieldAssemblyVisibility(self, enabled=False): + self.vector_field_assembly.SetVisibility(enabled) + # If not navigating, render the scene. + if not self.is_navigating: + self.interactor.Render() + + def UpdateBrainTargets(self, brain_targets): """ Update the vector field assembly to reflect the current vector field. """ # Create a new vector field assembly. - new_vector_field_assembly = self.vector_field_visualizer.CreateVectorFieldAssembly() - + new_vector_field_assembly = self.vector_field_visualizer.CreateVectorFieldAssembly( + brain_targets + ) # Replace the old vector field assembly with the new one. self.actor_factory.ReplaceActor( self.renderer, self.vector_field_assembly, new_vector_field_assembly @@ -446,13 +456,13 @@ def HighlightMarker(self, marker, render=True): # If the marker is a coil target, show the vector field assembly and update its position and orientation, # otherwise, hide the vector field assembly. - if marker_type == MarkerType.COIL_TARGET: - self.vector_field_assembly.SetVisibility(1) - - self.vector_field_assembly.SetPosition(position_flipped) - self.vector_field_assembly.SetOrientation(orientation) - else: - self.vector_field_assembly.SetVisibility(0) + # if marker_type == MarkerType.COIL_TARGET: + # self.vector_field_assembly.SetVisibility(1) + # + # self.vector_field_assembly.SetPosition(position_flipped) + # self.vector_field_assembly.SetOrientation(orientation) + # else: + # self.vector_field_assembly.SetVisibility(0) # Return early if the marker is a target and the coil is at the target. # diff --git a/invesalius/data/visualization/mep_visualizer.py b/invesalius/data/visualization/mep_visualizer.py index fad5abcfe..841a0f9d6 100644 --- a/invesalius/data/visualization/mep_visualizer.py +++ b/invesalius/data/visualization/mep_visualizer.py @@ -84,6 +84,9 @@ def __bind_events(self): # 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.UpdateMEPPointsFromBrainTargets, "Redraw MEP mapping from brain targets" + ) Publisher.subscribe(self.UpdateNavigationStatus, "Navigation status") Publisher.subscribe(self.SetBrainSurface, "Load brain surface actor") Publisher.subscribe(self.OnCloseProject, "Close project data") @@ -131,6 +134,7 @@ def DisplayMotorMap(self, show: bool): self.UpdateVisualization() progress_dialog.Update(value=50, msg="Preparing brain surface...") self.UpdateMEPPoints() + self.UpdateMEPPointsFromBrainTargets() progress_dialog.Close() else: self._config_params["mep_enabled"] = False @@ -353,6 +357,50 @@ def UpdateMEPPoints(self): if self._config_params["mep_enabled"]: self.UpdateVisualization() + def UpdateMEPPointsFromBrainTargets(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._config_params["mep_enabled"]: + return + if not self.surface: + return + brain_markers = [] + for marker in MarkersControl().list: + if marker.brain_target_list: + for m in marker.brain_target_list: + brain_markers.append(m) + + if not brain_markers: + self.points = vtkPolyData() + self.UpdateVisualization() + return + + points = vtkPoints() + + point_data = self.points.GetPointData() + mep_array = vtkDoubleArray() + mep_array.SetName("MEP") + point_data.AddArray(mep_array) + + for marker in brain_markers: + points.InsertNextPoint( + marker["position"][0], -marker["position"][1], marker["position"][2] + ) + 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"] or not self.surface: return diff --git a/invesalius/data/visualization/vector_field_visualizer.py b/invesalius/data/visualization/vector_field_visualizer.py index 34f22a682..c86f0ea23 100644 --- a/invesalius/data/visualization/vector_field_visualizer.py +++ b/invesalius/data/visualization/vector_field_visualizer.py @@ -27,10 +27,12 @@ def SetVectorField(self, vector_field): self.vector_field = vector_field Publisher.sendMessage("Update vector field") - def CreateVectorFieldAssembly(self): + def CreateVectorFieldAssembly(self, vector_field=None): """ Create an assembly for the current vector field. """ + if not vector_field: + vector_field = self.vector_field assembly = vtk.vtkAssembly() actors = [ @@ -40,7 +42,7 @@ def CreateVectorFieldAssembly(self): colour=vector["color"], length_multiplier=vector["length"], ) - for vector in self.vector_field + for vector in vector_field ] for actor in actors: diff --git a/invesalius/gui/dialogs.py b/invesalius/gui/dialogs.py index 0a73588a5..7333d700c 100644 --- a/invesalius/gui/dialogs.py +++ b/invesalius/gui/dialogs.py @@ -1207,9 +1207,9 @@ def ShowEnterMarkerID(default: str) -> str: def ShowEnterMEPValue(default): msg = _("Enter the MEP value (uV)") if sys.platform == "darwin": - dlg = wx.TextEntryDialog(None, "", msg, defaultValue=default) + dlg = wx.TextEntryDialog(None, "", msg, defaultValue=str(default)) else: - dlg = wx.TextEntryDialog(None, msg, "InVesalius 3", value=default) + dlg = wx.TextEntryDialog(None, msg, "InVesalius 3", value=str(default)) dlg.ShowModal() result = dlg.GetValue() # check if the value is a number diff --git a/invesalius/gui/task_navigator.py b/invesalius/gui/task_navigator.py index 11df7b939..40cf6bd6f 100644 --- a/invesalius/gui/task_navigator.py +++ b/invesalius/gui/task_navigator.py @@ -43,6 +43,7 @@ from wx.lib.mixins.listctrl import ColumnSorterMixin import invesalius.constants as const +import invesalius.data.coordinates as dco import invesalius.gui.dialogs as dlg import invesalius.project as prj import invesalius.session as ses @@ -1202,6 +1203,12 @@ def __init__(self, parent, nav_hub): def __bind_events(self): Publisher.subscribe(self.OnCloseProject, "Close project data") + Publisher.subscribe(self.OnUpdateNavigationPanel, "Update navigation panel") + + def OnUpdateNavigationPanel(self): + self.sizer.Fit(self) + if self.GetParent().IsExpanded(): + self.GetParent().Fit() def OnCloseProject(self): self.tracker.ResetTrackerFiducials() @@ -2090,6 +2097,7 @@ def __init__(self, parent, nav_hub): # at most 1080 pixels (a commonly used height in laptops). Otherwise, the height grows linearly with # the screen height. marker_list_height = max(120, int(screen_height / 4)) + self.marker_list_height = marker_list_height marker_list_ctrl = wx.ListCtrl( self, -1, style=wx.LC_REPORT, size=wx.Size(0, marker_list_height) @@ -2118,6 +2126,9 @@ def __init__(self, parent, nav_hub): marker_list_ctrl.InsertColumn(const.MEP_COLUMN, "MEP (uV)") marker_list_ctrl.SetColumnWidth(const.MEP_COLUMN, 45) + marker_list_ctrl.InsertColumn(const.UUID, "UUID") + marker_list_ctrl.SetColumnWidth(const.UUID, 45) + if self.session.GetConfig("debug"): marker_list_ctrl.InsertColumn(const.X_COLUMN, "X") marker_list_ctrl.SetColumnWidth(const.X_COLUMN, 45) @@ -2138,6 +2149,45 @@ def __init__(self, parent, nav_hub): self, self.marker_list_ctrl.GetColumnCount() ) + # Sub List Control + brain_targets_list_ctrl = wx.ListCtrl( + self, style=wx.LC_REPORT, size=wx.Size(0, marker_list_height) + ) + brain_targets_list_ctrl.InsertColumn(const.BRAIN_ID_COLUMN, "#") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_ID_COLUMN, 26) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_SESSION_COLUMN, "Session") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_SESSION_COLUMN, 51) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_MARKER_TYPE_COLUMN, "Type") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_MARKER_TYPE_COLUMN, 77) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_LABEL_COLUMN, "Label") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_LABEL_COLUMN, 95) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_MEP_COLUMN, "MEP (uV)") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_MEP_COLUMN, 45) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_X_MTMS, "X (mm)") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_X_MTMS, 45) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_Y_MTMS, "Y (mm)") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_Y_MTMS, 45) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_R_MTMS, "R (°)") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_R_MTMS, 45) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_INTENSITY_MTMS, "Int. (V/m)") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_INTENSITY_MTMS, 45) + + brain_targets_list_ctrl.InsertColumn(const.BRAIN_UUID, "UUID") + brain_targets_list_ctrl.SetColumnWidth(const.BRAIN_UUID, 45) + brain_targets_list_ctrl.Hide() + + brain_targets_list_ctrl.Bind( + wx.EVT_LIST_ITEM_RIGHT_CLICK, self.OnMouseRightDownBrainTargets + ) + self.brain_targets_list_ctrl = brain_targets_list_ctrl # In the future, it would be better if the panel could initialize itself based on markers in MarkersControl try: self.markers.LoadState() @@ -2151,6 +2201,7 @@ def __init__(self, parent, nav_hub): group_sizer.Add(sizer_delete, 0, wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL, 5) group_sizer.Add(sizer_main_coil, 0, wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL, 5) group_sizer.Add(marker_list_ctrl, 0, wx.EXPAND | wx.ALL, 5) + group_sizer.Add(brain_targets_list_ctrl, 0, wx.EXPAND | wx.ALL, 5) group_sizer.Fit(self) self.SetSizer(group_sizer) @@ -2201,6 +2252,9 @@ def __bind_events(self): Publisher.subscribe(self._UpdateMarkerLabel, "Update marker label") Publisher.subscribe(self._UpdateMEP, "Update marker mep") + Publisher.subscribe(self.SetBrainTarget, "Set brain targets") + # Publisher.subscribe(self.SetVectorField, "Set vector field") + def __get_selected_items(self): """ Returns a (possibly empty) list of the selected items in the list control. @@ -2358,6 +2412,51 @@ def UpdateSeedCoordinates( def UpdateCortexMarker(self, CoGposition, CoGorientation): self.cortex_position_orientation = CoGposition + CoGorientation + def SetBrainTarget(self, brain_targets): + marker_target = self.markers.FindTarget() + if not marker_target: + return + + position = marker_target.position + orientation = marker_target.orientation + position[1] = -position[1] + m_marker_target = dco.coordinates_to_transformation_matrix( + position=position, + orientation=orientation, + axes="sxyz", + ) + + for target in brain_targets: + m_offset_brain = dco.coordinates_to_transformation_matrix( + position=target["position"], + orientation=target["orientation"], + axes="sxyz", + ) + m_brain = m_marker_target @ m_offset_brain + new_position, new_orientation = dco.transformation_matrix_to_coordinates( + m_brain, "sxyz" + ) + new_position[1] = -new_position[1] + marker = self.CreateMarker( + position=new_position.tolist(), + orientation=new_orientation.tolist(), + colour=target["color"], + size=target["length"], + label=str(marker_target.label), + marker_type=MarkerType.BRAIN_TARGET, + ) + marker.marker_uuid = str(uuid.uuid4()) + marker.x_mtms = target["mtms"][0] + marker.y_mtms = target["mtms"][1] + marker.r_mtms = target["mtms"][2] + marker.intensity_mtms = target["mtms"][3] + # TODO: MEP + marker.mep_value = 0 + marker_target.brain_target_list.append(marker.to_brain_targets_dict()) + + Publisher.sendMessage("Redraw MEP mapping from brain targets") + self.markers.SaveState() + def OnMouseRightDown(self, evt): focused_marker_idx = self.marker_list_ctrl.GetFocusedItem() focused_marker = self.__get_marker(focused_marker_idx) @@ -2505,6 +2604,45 @@ def OnMouseRightDown(self, evt): self.PopupMenu(menu_id) menu_id.Destroy() + def OnMouseRightDownBrainTargets(self, evt): + focused_marker_idx = self.brain_targets_list_ctrl.GetFocusedItem() + focused_marker = self.currently_focused_marker.brain_target_list[focused_marker_idx] + self.focused_brain_marker = focused_marker + unique_menu_id = 1 + + # Check if the currently focused marker is the active target. + is_active_target = focused_marker["is_target"] + + # Create the context menu. + menu_id = wx.Menu() + + edit_id = menu_id.Append(unique_menu_id, _("Change label")) # Use non-zero ID + menu_id.Bind(wx.EVT_MENU, self.ChangeLabelBrainTarget, edit_id) + + delete_id = menu_id.Append(unique_menu_id + 2, _("Delete")) + menu_id.Bind(wx.EVT_MENU, self.OnDeleteSelectedBrainTarget, delete_id) + + menu_id.AppendSeparator() + + mep_menu_item = menu_id.Append(unique_menu_id + 3, _("Change MEP value")) + menu_id.Bind(wx.EVT_MENU, self.OnMenuChangeMEPBrainTarget, mep_menu_item) + + create_coil_target_menu_item = menu_id.Append(unique_menu_id + 4, _("Create coil target")) + menu_id.Bind( + wx.EVT_MENU, self.OnCreateCoilTargetFromBrainTargets, create_coil_target_menu_item + ) + + if has_mTMS: + send_brain_target_menu_item = menu_id.Append( + unique_menu_id + 5, _("Send brain target to mTMS") + ) + menu_id.Bind(wx.EVT_MENU, self.OnSendBrainTarget, send_brain_target_menu_item) + + menu_id.AppendSeparator() + + self.PopupMenu(menu_id) + menu_id.Destroy() + # Programmatically set the focus on the marker with the given index, simulating left click. def FocusOnMarker(self, idx): # Deselect the previously focused marker. @@ -2539,6 +2677,51 @@ def FocusOnMarker(self, idx): event.SetEventObject(self.marker_list_ctrl) self.marker_list_ctrl.GetEventHandler().ProcessEvent(event) + def populate_sub_list(self, sub_items_list): + """Populate the sub list""" + self.brain_targets_list_ctrl.DeleteAllItems() + focused_marker_idx = self.marker_list_ctrl.GetFocusedItem() + marker = self.__get_marker(focused_marker_idx) + num_items = focused_marker_idx + brain_targets = [] + for i, sub_item in enumerate(sub_items_list): + list_entry = ["" for _ in range(0, const.BRAIN_UUID + 1)] + list_entry[const.BRAIN_ID_COLUMN] = str(num_items) + "." + str(i) + list_entry[const.BRAIN_SESSION_COLUMN] = str(marker.brain_target_list[i]["session_id"]) + list_entry[const.BRAIN_MARKER_TYPE_COLUMN] = MarkerType.BRAIN_TARGET.human_readable + list_entry[const.BRAIN_LABEL_COLUMN] = marker.brain_target_list[i]["label"] + list_entry[const.BRAIN_MEP_COLUMN] = ( + str(marker.brain_target_list[i]["mep_value"]) + if marker.brain_target_list[i]["mep_value"] + else "" + ) + list_entry[const.BRAIN_X_MTMS] = marker.brain_target_list[i]["x_mtms"] + list_entry[const.BRAIN_Y_MTMS] = marker.brain_target_list[i]["y_mtms"] + list_entry[const.BRAIN_R_MTMS] = marker.brain_target_list[i]["r_mtms"] + list_entry[const.BRAIN_INTENSITY_MTMS] = marker.brain_target_list[i]["intensity_mtms"] + list_entry[const.BRAIN_UUID] = ( + str(marker.brain_target_list[i]["marker_uuid"]) + if marker.brain_target_list[i]["marker_uuid"] + else "" + ) + self.brain_targets_list_ctrl.Append(list_entry) + x, y, z = marker.brain_target_list[i]["position"] + brain_targets.append( + { + "position": [x, -y, z], + "orientation": marker.brain_target_list[i]["orientation"], + "color": marker.brain_target_list[i]["colour"], + "length": marker.brain_target_list[i]["size"], + } + ) + Publisher.sendMessage("Update brain targets", brain_targets=brain_targets) + + def ResizeListCtrl(self, width): + self.brain_targets_list_ctrl.SetMinSize((self.marker_list_ctrl.GetSize()[0], width)) + self.marker_list_ctrl.SetMinSize((self.marker_list_ctrl.GetSize()[0], width)) + self.brain_targets_list_ctrl.SetSize((self.marker_list_ctrl.GetSize()[0], width)) + self.marker_list_ctrl.SetSize((self.marker_list_ctrl.GetSize()[0], width)) + # Called when a marker on the list gets the focus by the user left-clicking on it. def OnMarkerFocused(self, evt): idx = self.marker_list_ctrl.GetFocusedItem() @@ -2548,14 +2731,15 @@ def OnMarkerFocused(self, evt): return marker_id = self.__get_marker_id(idx) - marker = self.markers.list[marker_id] + marker = self.__get_marker(idx) # XXX: There seems to be a bug in WxPython when selecting multiple items on the list using, # e.g., shift and page-up/page-down keys. The bug is that the EVT_LIST_ITEM_SELECTED event # is triggered repeatedly for the same item (the one that was first selected). This is a # workaround to prevent the event from being triggered repeatedly for the same item. - if self.currently_focused_marker is not None and marker == self.currently_focused_marker: - return + # TODO: check here!! + # if self.currently_focused_marker is not None and marker == self.currently_focused_marker: + # return # When selecting multiple markers, e.g., by pressing ctrl while clicking on the markers, EVT_LIST_ITEM_SELECTED # event is triggered for each selected item, without triggering EVT_LIST_ITEM_DESELECTED event for the previously @@ -2569,6 +2753,19 @@ def OnMarkerFocused(self, evt): self.currently_focused_marker = marker self.markers.SelectMarker(marker_id) + self.brain_targets_list_ctrl.DeleteAllItems() + if marker.brain_target_list: + Publisher.sendMessage("Set vector field assembly visibility", enabled=True) + self.populate_sub_list(marker.brain_target_list) + self.brain_targets_list_ctrl.Show() + width = self.marker_list_height / 2 + else: + Publisher.sendMessage("Set vector field assembly visibility", enabled=False) + self.brain_targets_list_ctrl.Hide() + width = self.marker_list_height + self.ResizeListCtrl(width) + Publisher.sendMessage("Update navigation panel") + self.Update() # Called when a marker on the list loses the focus by the user left-clicking on another marker. # @@ -2578,7 +2775,7 @@ def OnMarkerUnfocused(self, evt): def SetCameraToFocusOnMarker(self, evt): idx = self.marker_list_ctrl.GetFocusedItem() - marker = self.markers.list[idx] + marker = self.__get_marker(idx) Publisher.sendMessage("Set camera to focus on marker", marker=marker) def OnCreateCoilTargetFromLandmark(self, evt): @@ -2590,6 +2787,9 @@ def OnCreateCoilTargetFromLandmark(self, evt): self.markers.CreateCoilTargetFromLandmark(marker) + def OnCreateCoilTargetFromBrainTargets(self, evt): + self.markers.CreateCoilTargetFromBrainTarget(self.focused_brain_marker) + def OnCreateCoilTargetFromCoilPose(self, evt): list_index = self.marker_list_ctrl.GetFocusedItem() if list_index == -1: @@ -2632,6 +2832,16 @@ def ChangeLabel(self, evt): ) self.markers.ChangeLabel(marker, new_label) + def ChangeLabelBrainTarget(self, evt): + list_index = self.brain_targets_list_ctrl.GetFocusedItem() + if list_index == -1: + wx.MessageBox(_("No data selected."), _("InVesalius 3")) + return + marker = self.currently_focused_marker.brain_target_list[list_index] + marker["label"] = dlg.ShowEnterMarkerID(marker["label"]) + self.brain_targets_list_ctrl.SetItem(list_index, const.BRAIN_LABEL_COLUMN, marker["label"]) + self.markers.SaveState() + def OnMenuSetTarget(self, evt): idx = self.marker_list_ctrl.GetFocusedItem() if idx == -1: @@ -2783,14 +2993,49 @@ def OnSetEfieldBrainTarget(self, evt): orientation=orientation, ) + def transform_to_mtms(self, coil_position, coil_orientation_euler, brain_position): + """ + Transforms the brain position from InVesalius coordinates to the coil's coordinate system. + + Parameters: + - coil_position: array-like, shape (3,) + The position of the coil in the world coordinate system [x, y, z]. + - coil_orientation_euler: array-like, shape (3,) + The orientation of the coil in Euler angles [roll, pitch, yaw] in radians. + - brain_position: array-like, shape (3,) + The position of the brain in the world coordinate system [x, y, z]. + + Returns: + - brain_position_in_coil_coords: numpy array, shape (3,) + The brain position in the coil's coordinate system. + """ + import invesalius.data.transformations as tr + + # Convert inputs to numpy arrays + coil_position = np.array(coil_position) + brain_position = np.array(brain_position) + + # Convert Euler angles to rotation matrix + coil_rotation_matrix = tr.euler_matrix( + coil_orientation_euler[0], coil_orientation_euler[1], coil_orientation_euler[2], "sxyz" + ) + + # Step 1: Translate brain position to the coil's origin + translated_position = brain_position - coil_position + + # Step 2: Rotate the translated position into the coil's coordinate system + brain_position_in_coil_coords = np.dot(coil_rotation_matrix[:3, :3].T, translated_position) + + return brain_position_in_coil_coords + def OnCreateBrainTargetFromLandmark(self, evt): list_index = self.marker_list_ctrl.GetFocusedItem() - marker = self.__get_marker(list_index) - position = marker.position - orientation = marker.orientation + marker_coil = self.__get_marker(list_index) + position_coil = marker_coil.position + orientation_coil = marker_coil.orientation dialog = dlg.CreateBrainTargetDialog( - marker=position + orientation, brain_actor=self.brain_actor + marker=position_coil + orientation_coil, brain_actor=self.brain_actor ) if dialog.ShowModal() == wx.ID_OK: ( @@ -2805,22 +3050,39 @@ def OnCreateBrainTargetFromLandmark(self, evt): marker = self.CreateMarker( position=position, orientation=orientation, - # XXX: Setting the marker type to 'brain target' is inconsistent with the variable names above ('coil_position_list' etc.); - # however, the dialog shown to the user by this function should be used exclusively for creating brain targets, hence the - # variable naming (and the internal logic of the dialog where it currently returns both coil targets and brain targets) - # should probably be modified to reflect that. marker_type=MarkerType.BRAIN_TARGET, + size=1, + label=str(marker_coil.label), ) - self.markers.AddMarker(marker, render=True, focus=True) + marker.marker_uuid = str(uuid.uuid4()) + # EXAMPLE. TODO with mtms + mtms_coords = self.transform_to_mtms(position_coil, orientation, position) + marker.x_mtms = np.round(mtms_coords[0], 1) + marker.y_mtms = np.round(mtms_coords[1], 1) + marker.r_mtms = np.round(orientation[2], 0) + marker.intensity_mtms = 10 + marker_coil.brain_target_list.append(marker.to_brain_targets_dict()) for position, orientation in zip(brain_position_list, brain_orientation_list): marker = self.CreateMarker( position=list(position), orientation=list(orientation), marker_type=MarkerType.BRAIN_TARGET, + size=1, + label=str(marker_coil.label), ) - self.markers.AddMarker(marker, render=True, focus=False) - + marker.marker_uuid = str(uuid.uuid4()) + mtms_coords = self.transform_to_mtms(position_coil, orientation, position) + marker.x_mtms = np.round(mtms_coords[0], 1) + marker.y_mtms = np.round(mtms_coords[1], 1) + marker.r_mtms = np.round(orientation[2], 0) + marker.intensity_mtms = 10 + marker_coil.brain_target_list.append(marker.to_brain_targets_dict()) + + if marker_coil.brain_target_list: + self.marker_list_ctrl.SetItemBackgroundColour(list_index, wx.Colour(102, 178, 255)) + self.OnMarkerFocused(evt=None) + self.markers.SaveState() dialog.Destroy() def OnMenuRemoveEfieldTarget(self, evt): @@ -2853,6 +3115,20 @@ def OnMenuChangeMEP(self, evt): new_mep = dlg.ShowEnterMEPValue(self.marker_list_ctrl.GetItemText(idx, const.MEP_COLUMN)) self.markers.ChangeMEP(marker, new_mep) + def OnMenuChangeMEPBrainTarget(self, evt): + list_index = self.brain_targets_list_ctrl.GetFocusedItem() + if list_index == -1: + wx.MessageBox(_("No data selected."), _("InVesalius 3")) + return + marker = self.currently_focused_marker.brain_target_list[list_index] + if not marker["mep_value"]: + marker["mep_value"] = "0" + marker["mep_value"] = dlg.ShowEnterMEPValue(str(marker["mep_value"])) + self.brain_targets_list_ctrl.SetItem( + list_index, const.BRAIN_MEP_COLUMN, str(marker["mep_value"]) + ) + Publisher.sendMessage("Redraw MEP mapping from brain targets") + def _UnsetTarget(self, marker): idx = self.__find_marker_index(marker.marker_id) @@ -2885,6 +3161,11 @@ def __get_marker_id(self, idx): """ For an index in self.marker_list_ctrl, returns the corresponding marker_id """ + current_uuid = self.marker_list_ctrl.GetItem(idx, const.UUID).GetText() + for marker in self.markers.list: + if current_uuid == marker.marker_uuid: + marker_id = self.markers.list.index(marker) + return int(marker_id) list_item = self.marker_list_ctrl.GetItem(idx, const.ID_COLUMN) return int(list_item.GetText()) @@ -2937,8 +3218,10 @@ def OnSetBrainTarget(self, evt): size=0.05, marker_type=MarkerType.BRAIN_TARGET, ) - self.markers.AddMarker(new_marker, render=True, focus=True) - + new_marker.marker_uuid = str(uuid.uuid4()) + new_marker.label = str(marker.label) + marker.brain_target_list.append(new_marker.to_brain_targets_dict()) + self.markers.SaveState() dialog.Destroy() def OnSendBrainTarget(self, evt): @@ -2986,7 +3269,11 @@ def OnSelectMarkerByActor(self, actor): if idx_old != -1 and idx_old != idx: self.marker_list_ctrl.Select(idx_old, on=False) - # Focus and select the marker in the list control. + current_uuid = m.marker_uuid + for i in range(self.marker_list_ctrl.GetItemCount()): + if current_uuid == self.marker_list_ctrl.GetItem(i, const.UUID).GetText(): + idx = i + self.marker_list_ctrl.Focus(idx) self.marker_list_ctrl.Select(idx, on=True) break @@ -2998,6 +3285,9 @@ def OnDeleteAllMarkers(self, evt=None): return self.markers.Clear() self.itemDataMap.clear() + Publisher.sendMessage("Set vector field assembly visibility", enabled=False) + self.brain_targets_list_ctrl.DeleteAllItems() + self.brain_targets_list_ctrl.Hide() def OnDeleteFiducialMarker(self, label): indexes = [] @@ -3030,12 +3320,27 @@ def OnDeleteSelectedMarkers(self, evt=None): first_deleted_index = indexes[0] first_existing_index = ( first_deleted_index - if first_deleted_index < len(self.markers.list) - else len(self.markers.list) - 1 + if first_deleted_index < self.marker_list_ctrl.GetItemCount() + else self.marker_list_ctrl.GetItemCount() - 1 ) self.FocusOnMarker(first_existing_index) + def OnDeleteSelectedBrainTarget(self, evt): + list_index = self.brain_targets_list_ctrl.GetFocusedItem() + if list_index == -1: + wx.MessageBox(_("No data selected."), _("InVesalius 3")) + return + brain_target_list = self.currently_focused_marker.brain_target_list + target_uuid = self.brain_targets_list_ctrl.GetItemText(list_index, const.BRAIN_UUID) + # Remove entry with the specified UUID + markers = [ + marker for marker in brain_target_list if marker.get("marker_uuid") != target_uuid + ] + self.currently_focused_marker.brain_target_list = markers + self.OnMarkerFocused(evt=None) + self.markers.SaveState() + def GetNextMarkerLabel(self): return self.markers.GetNextMarkerLabel() @@ -3097,19 +3402,74 @@ def OnCreateMarker( def ParseValue(self, value): value = value.strip() - # Check for integer, float, string encapsulated by quotes, and None. + # Handle None, booleans, empty list, and basic types if value == "None": return None + if value == "True": + return True + if value == "False": + return False + if value == "[]": + return [] + + # Handle lists and dictionaries + if value.startswith("[") and value.endswith("]"): + return self._parse_list(value) + if value.startswith("{") and value.endswith("}"): + return self._parse_dict(value) + + # Try to convert to int or float try: - if "." in value: + if "." in value or "e" in value.lower(): return float(value) return int(value) - except ValueError: - # Check for strings marked by quotes. - if value.startswith('"') and value.endswith('"'): + # Handle quoted strings + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): return value[1:-1] - return value + return value # Return as is if not recognized + + def _parse_list(self, list_str): + """Parse a list from string format.""" + return [ + self.ParseValue(el.strip()) + for el in self._split_by_outer_commas(list_str[1:-1].strip()) + ] + + def _parse_dict(self, dict_str): + """Parse a dictionary from string format.""" + items = self._split_by_outer_commas(dict_str[1:-1].strip()) + return { + self.ParseValue(kv.split(":", 1)[0].strip()): self.ParseValue( + kv.split(":", 1)[1].strip() + ) + for kv in items + } + + def _split_by_outer_commas(self, string): + """Split a string by commas that are not inside brackets or braces.""" + elements = [] + depth = 0 + current_element = [] + + for char in string: + if char in "[{": + depth += 1 + elif char in "]}" and depth > 0: + depth -= 1 + + if char == "," and depth == 0: + elements.append("".join(current_element).strip()) + current_element = [] + else: + current_element.append(char) + + if current_element: + elements.append("".join(current_element).strip()) + + return elements def GetMarkersFromFile(self, filename, overwrite_image_fiducials): try: @@ -3315,11 +3675,6 @@ def _AddMarker(self, marker, render, focus): # Add marker to the marker list in GUI and to the itemDataMap. num_items = self.marker_list_ctrl.GetItemCount() - key = 0 - if len(self.itemDataMap) > 0: - # If itemDataMap is not empty, set the new key as last key + 1 - key = list(self.itemDataMap.keys())[-1] + 1 - list_entry = ["" for _ in range(0, const.X_COLUMN)] list_entry[const.ID_COLUMN] = num_items list_entry[const.SESSION_COLUMN] = str(marker.session_id) @@ -3331,21 +3686,27 @@ def _AddMarker(self, marker, render, focus): "Yes" if marker.is_point_of_interest else "" ) list_entry[const.MEP_COLUMN] = str(marker.mep_value) if marker.mep_value else "" + list_entry[const.UUID] = str(marker.marker_uuid) if marker.marker_uuid else "" if self.session.GetConfig("debug"): list_entry.append(round(marker.x, 1)) list_entry.append(round(marker.y, 1)) list_entry.append(round(marker.z, 1)) + key = 0 + if len(self.itemDataMap) > 0: + # If itemDataMap is not empty, set the new key as last key + 1 + key = len(self.itemDataMap.keys()) + 1 self.marker_list_ctrl.Append(list_entry) self.marker_list_ctrl.SetItemData(num_items, key) + data_map_entry = list_entry.copy() # Add the UUID to the entry in itemDataMap data_map_entry.append(marker.marker_uuid) self.itemDataMap[key] = data_map_entry - if marker.marker_type == MarkerType.BRAIN_TARGET: + if marker.brain_target_list: self.marker_list_ctrl.SetItemBackgroundColour(num_items, wx.Colour(102, 178, 255)) self.marker_list_ctrl.EnsureVisible(num_items) diff --git a/invesalius/navigation/markers.py b/invesalius/navigation/markers.py index 85c3246dc..6f6a7504b 100644 --- a/invesalius/navigation/markers.py +++ b/invesalius/navigation/markers.py @@ -90,7 +90,6 @@ def Clear(self): # currently not be used outside this class. def DeleteMarker(self, marker_id, render=True): marker = self.list[marker_id] - if marker.is_target: self.UnsetTarget(marker_id) if marker.is_point_of_interest: @@ -289,6 +288,32 @@ def CreateCoilTargetFromLandmark(self, marker): self.AddMarker(new_marker) + def CreateCoilTargetFromBrainTarget(self, marker): + new_marker = Marker() + + new_marker.position = marker["position"] + new_marker.orientation = marker["orientation"] + new_marker.marker_type = MarkerType.COIL_TARGET + + # Marker IDs start from zero, hence len(self.markers) will be the ID of the new marker. + new_marker.marker_id = len(self.list) + # Create an uuid for the marker + new_marker.marker_uuid = str(uuid.uuid4()) + # Set the visualization attribute to an empty dictionary. + new_marker.visualization = {} + # Unset the is_target attribute. + new_marker.is_target = False + + self.transformator.ProjectToScalp( + marker=new_marker, + # We are projecting the marker that is on the brain surface; hence, project to the opposite side + # of the scalp because the normal vectors are unreliable on the brain side of the scalp. + opposite_side=True, + ) + new_marker.label = self.GetNextMarkerLabel() + + self.AddMarker(new_marker) + def CreateCoilTargetFromCoilPose(self, marker): new_marker = marker.duplicate() diff --git a/invesalius/net/neuronavigation_api.py b/invesalius/net/neuronavigation_api.py index e0cd426db..3ee57818b 100644 --- a/invesalius/net/neuronavigation_api.py +++ b/invesalius/net/neuronavigation_api.py @@ -334,8 +334,25 @@ def remove_pedal_callback(self, name): def open_orientation_dialog(self, target_id): wx.CallAfter(Publisher.sendMessage, "Open marker orientation dialog", marker_id=target_id) - def stimulation_pulse_received(self): + def stimulation_pulse_received(self, targets): # TODO: If marker should not be created always when receiving a stimulation pulse, add the logic here. + brain_targets = [] + for target in targets: + brain_targets.append( + { + "position": [target.displacement_y, -target.displacement_x, -15], + "orientation": [0, 0, -target.rotation_angle], + "color": [0, 0, 1], + "length": target.intensity / 100, + "mtms": [ + target.displacement_x, + target.displacement_y, + target.rotation_angle, + target.intensity, + ], + } + ) + wx.CallAfter(Publisher.sendMessage, "Set brain targets", brain_targets=brain_targets) wx.CallAfter(Publisher.sendMessage, "Create marker", marker_type=MarkerType.COIL_POSE) def set_vector_field(self, vector_field):