diff --git a/finn/_tests/test_viewer.py b/finn/_tests/test_viewer.py index 46fa1e87..70a68b6a 100644 --- a/finn/_tests/test_viewer.py +++ b/finn/_tests/test_viewer.py @@ -33,9 +33,16 @@ def _assert_shortcuts_exist_for_each_action(type_): actions = _get_provider_actions(type_) shortcuts = {name.partition(":")[-1] for name in get_settings().shortcuts.shortcuts} shortcuts.update(func.__name__ for func in type_.class_keymap.values()) + + actions_to_skip = { + "center_cross_on_mouse" + } # skip this action as we only bind it if the cross widget is imported + for action in actions: + if action.__name__ in actions_to_skip: + continue # Skip this action assert action.__name__ in shortcuts, ( - f"missing shortcut for action '{action.__name__}' on '{type_.__name__}' is missing" + f"missing shortcut for action '{action.__name__}' on '{type_.__name__}'" ) diff --git a/finn/track_application_menus/main_app.py b/finn/track_application_menus/main_app.py index 6355db9d..05cc624b 100644 --- a/finn/track_application_menus/main_app.py +++ b/finn/track_application_menus/main_app.py @@ -13,13 +13,11 @@ class MainApp(QWidget): def __init__(self, viewer: finn.Viewer): super().__init__() + self.viewer = viewer self.menu_widget = MenuWidget(viewer) tree_widget = TreeWidget(viewer) - viewer.window.add_dock_widget(tree_widget, area="bottom", name="Tree View") - layout = QVBoxLayout() layout.addWidget(self.menu_widget) - self.setLayout(layout) diff --git a/finn/track_application_menus/menu_widget.py b/finn/track_application_menus/menu_widget.py index 893f003a..5cf3c7d8 100644 --- a/finn/track_application_menus/menu_widget.py +++ b/finn/track_application_menus/menu_widget.py @@ -2,6 +2,7 @@ import finn from finn.track_application_menus.editing_menu import EditingMenu +from finn.track_data_views.views.view_3d.orthogonal_views import OrthogonalViews from finn.track_data_views.views_coordinator.tracks_viewer import TracksViewer # from motile_tracker.motile.menus.motile_widget import MotileWidget @@ -17,10 +18,11 @@ def __init__(self, viewer: finn.Viewer): # motile_widget = MotileWidget(viewer) editing_widget = EditingMenu(viewer) + self.orth_views = OrthogonalViews(viewer) self.tabwidget = QTabWidget() - # tabwidget.addTab(motile_widget, "Track with Motile") + self.tabwidget.addTab(self.orth_views, "Orthogonal Views") self.tabwidget.addTab(tracks_viewer.tracks_list, "Tracks List") self.tabwidget.addTab(editing_widget, "Edit Tracks") @@ -31,3 +33,4 @@ def __init__(self, viewer: finn.Viewer): self.setWidgetResizable(True) self.setLayout(layout) + self.setMinimumWidth(300) diff --git a/finn/track_data_views/views/layers/track_labels.py b/finn/track_data_views/views/layers/track_labels.py index 48925b39..809b1679 100644 --- a/finn/track_data_views/views/layers/track_labels.py +++ b/finn/track_data_views/views/layers/track_labels.py @@ -1,14 +1,17 @@ from __future__ import annotations import random +import time from typing import TYPE_CHECKING import numpy as np import finn +from finn.layers import Labels from finn.utils import DirectLabelColormap from finn.utils.action_manager import action_manager from finn.utils.notifications import show_info, show_warning +from finn.utils.translations import trans if TYPE_CHECKING: from finn.track_data_views.views_coordinator.tracks_viewer import TracksViewer @@ -50,10 +53,10 @@ def _new_label(layer: TrackLabels, new_track_id=True): # release the mouse layer.colormap = DirectLabelColormap(color_dict=layer.colormap.color_dict) else: - show_info("Calculating empty label on non-numpy array is not supported") + show_info(trans._("Calculating empty label on non-numpy array is not supported")) -class TrackLabels(finn.layers.Labels): +class TrackLabels(Labels): """Extended labels layer that holds the track information and emits and responds to dynamics visualization signals""" @@ -85,6 +88,9 @@ def __init__( ) self.viewer = viewer + self.viewer.dims.events.ndisplay.connect( + lambda: self.update_label_colormap(visible=None) + ) # Key bindings (should be specified both on the viewer (in tracks_viewer) # and on the layer to overwrite finn defaults) @@ -99,34 +105,51 @@ def __init__( self.bind_key("z")(self.tracks_viewer.undo) self.bind_key("r")(self.tracks_viewer.redo) - # Connect click events to node selection - @self.mouse_drag_callbacks.append - def click(_, event): - if ( - event.type == "mouse_press" - and self.mode == "pan_zoom" - and not ( - self.tracks_viewer.mode == "lineage" - and self.viewer.dims.ndisplay == 3 - ) - ): # disable selecting in lineage mode in 3D + # Listen to click, paint events and changing the selected label + self.mouse_drag_callbacks.append(self.click) + self.events.paint.connect(self._on_paint) + self.tracks_viewer.selected_nodes.list_updated.connect(self.update_selected_label) + self.events.selected_label.connect(self._ensure_valid_label) + self.events.mode.connect(self._check_mode) + self.viewer.dims.events.current_step.connect(self._ensure_valid_label) + + # Connect click events to node selection + def click(self, _, event): + if ( + event.type == "mouse_press" + and self.mode == "pan_zoom" + and not ( + self.tracks_viewer.mode == "lineage" and self.viewer.dims.ndisplay == 3 + ) + ): # disable selecting in lineage mode in 3D + # differentiate between click and drag + mouse_press_time = time.time() + dragged = False + yield + # on move + while event.type == "mouse_move": + dragged = True + yield + if dragged and time.time() - mouse_press_time < 0.5: + dragged = False # suppress micro drag events and treat them as click + # on release + if not dragged: label = self.get_value( event.position, view_direction=event.view_direction, dims_displayed=event.dims_displayed, world=True, ) - # check opacity (=visibility) in the colormap - if label is not None and label != 0 and self.colormap.map(label)[-1] != 0: - append = "Shift" in event.modifiers - self.tracks_viewer.selected_nodes.add(label, append) - - # Listen to paint events and changing the selected label - self.events.paint.connect(self._on_paint) - self.tracks_viewer.selected_nodes.list_updated.connect(self.update_selected_label) - self.events.selected_label.connect(self._ensure_valid_label) - self.events.mode.connect(self._check_mode) - self.viewer.dims.events.current_step.connect(self._ensure_valid_label) + self.process_click(event, label) + + def process_click(self, event: Event, label: int): + if ( + label is not None and label != 0 and self.colormap.map(label)[-1] != 0 + ): # check opacity (=visibility) in the colormap + append = "Shift" in event.modifiers + self.tracks_viewer.selected_nodes.add(label, append) + else: + self.tracks_viewer.selected_nodes.reset() def _get_colormap(self) -> DirectLabelColormap: """Get a DirectLabelColormap that maps node ids to their track ids, and then @@ -152,28 +175,32 @@ def _get_colormap(self) -> DirectLabelColormap: def _check_mode(self): """Check if the mode is valid and call the ensure_valid_label function""" - # here disconnecting the event listener is still necessary because - # self.mode = paint triggers the event internally and it is not blocked with - # event.blocker() - self.events.mode.disconnect(self._check_mode) + + self.events.mode.disconnect( + self._check_mode + ) # here disconnecting the event listener is still necessary because + # self.mode = paint triggers the event internally + # and it is not blocked with event.blocker() if self.mode == "polygon": - show_info("Please use the paint tool to update the label") + show_info( + trans._( + "Please use the paint tool to update the label", + ) + ) self.mode = "paint" self._ensure_valid_label() self.events.mode.connect(self._check_mode) def redo(self): - """Overwrite the redo functionality of the labels layer and invoke redo action on - the tracks_viewer.tracks_controller first - """ + """Overwrite the redo functionality of the labels layer and + invoke redo action on the tracks_viewer.tracks_controller first""" self.tracks_viewer.redo() def undo(self): """Overwrite undo function and invoke undo action on the - tracks_viewer.tracks_controller - """ + tracks_viewer.tracks_controller""" self.tracks_viewer.undo() @@ -181,8 +208,9 @@ def _parse_paint_event(self, event_val): """_summary_ Args: - event_val (list[tuple]): A list of paint "atoms" generated by the labels - layer. Each atom is a 3-tuple of arrays containing: + event_val (list[tuple]): A list of paint "atoms" generated by the + labels layer. + Each atom is a 3-tuple of arrays containing: - a numpy multi-index, pointing to the array elements that were changed (a tuple with len ndims) - the values corresponding to those elements before the change @@ -208,8 +236,8 @@ def _parse_paint_event(self, event_val): mask = concatenated_values == old_value indices = tuple(concatenated_indices[dim][mask] for dim in range(ndim)) time_points = np.unique(indices[0]) - for time in time_points: - time_mask = indices[0] == time + for time_point in time_points: + time_mask = indices[0] == time_point actions.append( (tuple(indices[dim][time_mask] for dim in range(ndim)), old_value) ) @@ -262,10 +290,11 @@ def _on_paint(self, event): if len(to_delete) > 0 and len(to_add) > 0: show_warning( - "This paint or fill operation completely replaced one label with a " - "new label. This is currently not supported." - " If you want to update the track id of the node, please edit the " - "edges directly instead." + """This paint or fill operation completely replaced one label + with a new label. + This is currently not supported. + If you want to update the track id of the node, + please edit the edges directly instead.""" ) self._revert_paint(event) self.refresh() @@ -293,8 +322,18 @@ def update_label_colormap(self, visible: list[int] | str) -> None: with self.events.selected_label.blocker(): highlighted = self.tracks_viewer.selected_nodes - # update the opacity of the cyclic label colormap values according to - # whether nodes are visible/invisible/highlighted + # update the opacity of the cyclic label colormap values according + # to whether nodes are visible/invisible/highlighted + self.colormap.color_dict = { + key: np.array( + [*value[:-1], 0.6 if key is not None and key != 0 else value[-1]], + dtype=np.float32, + ) + for key, value in self.colormap.color_dict.items() + } + + # update the opacity of the cyclic label colormap values according + # to whether nodes are visible/invisible/highlighted if visible == "all": self.colormap.color_dict = { key: np.array( @@ -318,8 +357,12 @@ def update_label_colormap(self, visible: list[int] | str) -> None: for node in highlighted: self.colormap.color_dict[node][-1] = 1 # full opacity - # create a new colormap from the updated colors (to ensure refresh) - self.colormap = DirectLabelColormap(color_dict=self.colormap.color_dict) + + self.colormap = DirectLabelColormap( + color_dict=self.colormap.color_dict + ) # create a new colormap from the updated colors + # (otherwise it does not refresh) + self.refresh() def new_colormap(self): """Override existing function to generate new colormap on tracks_viewer and @@ -346,25 +389,25 @@ def _ensure_valid_label(self, event: Event | None = None): """Make sure a valid label is selected, because it is not allowed to paint with a label that already exists at a different timepoint. Scenarios: - 1. If a node with the selected label value (node id) exists at a different time - point, check if there is any node with the same track_id at the current time - point - 1.a if there is a node with the same track id, select that one, so that it - can be used to update an existing node - 1.b if there is no node with the same track id, create a new node id and - paint with the track_id of the selected label. + 1. If a node with the selected label value (node id) exists at a different + time point, check if there is any node with the same track_id at the current + time point + 1.a if there is a node with the same track id, select that one, + so that it can be used to update an existing node + 1.b if there is no node with the same track id, + create a new node id and paint with the track_id of the selected label. This can be used to add a new node with the same track id at a time point - where it does not (yet) exist (anymore). - 2. if there is no existing node with this value in the graph, it is assume that - you want to add a node with the current track id - Retrieve the track_id from self.current_track_id and use it to find if there are - any nodes of this track id at current time point - 3. If no node with this label exists yet, it is valid and can be used to start a - new track id. Therefore, create a new node id and map a new color. - Add it to the dictionary. - 4. If a node with the label exists at the current time point, it is valid and - can be used to update the existing node in a paint event. No action is needed - """ + where it does not (yet) exist (anymore). + 2. if there is no existing node with this value in the graph, + it is assume that you want to add a node with the current track id + Retrieve the track_id from self.current_track_id and use it to find + if there are any nodes of this track id at current time point. + 3. If no node with this label exists yet, it is valid + and can be used to start a new track id. + Therefore, create a new node id and map a new color. Add it to the dictionary. + 4. If a node with the label exists at the current time point, + it is valid and can be used to update the existing node in a paint event. + No action is needed""" if self.tracks_viewer.tracks is not None and self.mode in ( "fill", @@ -386,8 +429,8 @@ def _ensure_valid_label(self, event: Event | None = None): # we are changing the existing node. This is fine pass else: - # if there is already a node in that track in this frame, edit that - # instead + # if there is already a node in that track in this frame, + # edit that instead edit = False if self.selected_track in self.tracks_viewer.tracks.track_id_to_node: for node in self.tracks_viewer.tracks.track_id_to_node[ @@ -409,12 +452,12 @@ def _ensure_valid_label(self, event: Event | None = None): ) # the current node does not exist in the graph. - # Use the current selected_track as the track id (will be a new track if a - # new label was found with "m") + # Use the current selected_track as the track id (will be a new track + # if a new label was found with "m") # Check that the track id is not already in this frame. else: - # if there is already a node in that track in this frame, edit that - # instead + # if there is already a node in that track in this frame, + # edit that instead edit = False if self.selected_track in self.tracks_viewer.tracks.track_id_to_node: for node in self.tracks_viewer.tracks.track_id_to_node[ diff --git a/finn/track_data_views/views/layers/track_points.py b/finn/track_data_views/views/layers/track_points.py index 411a17e9..21e49608 100644 --- a/finn/track_data_views/views/layers/track_points.py +++ b/finn/track_data_views/views/layers/track_points.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +import time from typing import TYPE_CHECKING import numpy as np @@ -13,12 +14,18 @@ if TYPE_CHECKING: from finn.track_data_views.views_coordinator.tracks_viewer import TracksViewer +from psygnal import Signal + +from finn.utils.events import Event + class TrackPoints(finn.layers.Points): """Extended points layer that holds the track information and emits and responds to dynamics visualization signals """ + data_updated = Signal() + @property def _type_string(self) -> str: return ( @@ -77,17 +84,25 @@ def __init__( @self.mouse_drag_callbacks.append def click(layer, event): if event.type == "mouse_press": - # is the value passed from the click event? - point_index = layer.get_value( - event.position, - view_direction=event.view_direction, - dims_displayed=event.dims_displayed, - world=True, - ) - if point_index is not None: - node_id = self.nodes[point_index] - append = "Shift" in event.modifiers - self.tracks_viewer.selected_nodes.add(node_id, append) + # differentiate between click and drag + mouse_press_time = time.time() + dragged = False + yield + # on move + while event.type == "mouse_move": + dragged = True + yield + if dragged and time.time() - mouse_press_time < 0.5: + dragged = False # suppress micro drag events and treat them as click + if not dragged: + # is the value passed from the click event? + point_index = layer.get_value( + event.position, + view_direction=event.view_direction, + dims_displayed=event.dims_displayed, + world=True, + ) + self.process_point_click(point_index, event) # listen to updates of the data self.events.data.connect(self._update_data) @@ -101,11 +116,22 @@ def click(layer, event): # to update the nodes in self.tracks_viewer.selected_nodes self.selected_data.events.items_changed.connect(self._update_selection) + def process_point_click(self, point_index: int | None, event: Event): + """Select the clicked point(s)""" + + if point_index is None: + self.tracks_viewer.selected_nodes.reset() + else: + node_id = self.nodes[point_index] + append = "Shift" in event.modifiers + self.tracks_viewer.selected_nodes.add(node_id, append) + def set_point_size(self, size: int) -> None: """Sets a new default point size""" self.default_size = size - self._refresh() + self.size = self.default_size + self.border_color = self.border_color # emits border color event which triggers updating the sizes as well (size does not have its own event) def _refresh(self): """Refresh the data in the points layer""" @@ -113,15 +139,18 @@ def _refresh(self): self.events.data.disconnect( self._update_data ) # do not listen to new events until updates are complete - self.nodes = list(self.tracks_viewer.tracks.graph.nodes) + self.nodes = list(self.tracks_viewer.tracks.graph.nodes) self.node_index_dict = {node: idx for idx, node in enumerate(self.nodes)} track_ids = [ self.tracks_viewer.tracks.graph.nodes[node][NodeAttr.TRACK_ID.value] for node in self.nodes ] + # this submits two events one where the action is 'ongoing' and one when it is finished self.data = self.tracks_viewer.tracks.get_positions(self.nodes, incl_time=True) + self.data_updated.emit() # emit update signal for the orthogonal views to connect to + self.symbol = self.get_symbols( self.tracks_viewer.tracks, self.tracks_viewer.symbolmap ) @@ -129,8 +158,10 @@ def _refresh(self): self.tracks_viewer.colormap.map(track_id) for track_id in track_ids ] self.properties = {"node_id": self.nodes, "track_id": track_ids} - self.size = self.default_size - self.border_color = [1, 1, 1, 1] + + with self.events.border_color.blocker(): # no need to submit events for this + self.size = self.default_size + self.border_color = [1, 1, 1, 1] self.events.data.connect( self._update_data @@ -227,8 +258,12 @@ def update_point_outline(self, visible: list[int] | str) -> None: self.shown[indices] = True # set border color for selected item - self.border_color = [1, 1, 1, 1] - self.size = self.default_size + with ( + self.events.border_color.blocker() + ): # block the event emitter here to not trigger update in orthogonal views + self.border_color = [1, 1, 1, 1] + with self.events.size.blocker(): + self.size = self.default_size for node in self.tracks_viewer.selected_nodes: index = self.node_index_dict[node] self.border_color[index] = ( @@ -238,4 +273,13 @@ def update_point_outline(self, visible: list[int] | str) -> None: 1, ) self.size[index] = math.ceil(self.default_size + 0.3 * self.default_size) + + # if len(self.tracks_viewer.selected_nodes) > 0: + # self.selected_track = self.tracks_viewer.tracks._get_node_attr( + # self.tracks_viewer.selected_nodes[0], NodeAttr.TRACK_ID.value + # ) + + # emit the event to trigger update in orthogonal views + self.border_color = self.border_color + self.size = self.size self.refresh() diff --git a/finn/track_data_views/views/layers/tracks_layer_group.py b/finn/track_data_views/views/layers/tracks_layer_group.py index 02c17065..2da92e43 100644 --- a/finn/track_data_views/views/layers/tracks_layer_group.py +++ b/finn/track_data_views/views/layers/tracks_layer_group.py @@ -148,6 +148,10 @@ def center_view(self, node): location[dim] + 0.5 ) # use the world location, since the 'step' in viewer.dims.range # already in world units + # Also update the step for the dims that are displayed, in order to sync with + # the orthogonal views + for dim in self.viewer.dims.displayed: + step[dim] = int(location[dim] + 0.5) self.viewer.dims.current_step = step # check whether the new coordinates are inside or outside the field of view, diff --git a/finn/track_data_views/views/view_3d/__init__.py b/finn/track_data_views/views/view_3d/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/finn/track_data_views/views/view_3d/_tests/test_multiple_view_widget.py b/finn/track_data_views/views/view_3d/_tests/test_multiple_view_widget.py new file mode 100644 index 00000000..a2825582 --- /dev/null +++ b/finn/track_data_views/views/view_3d/_tests/test_multiple_view_widget.py @@ -0,0 +1,254 @@ +import numpy as np + +from finn.layers import Image, Labels +from finn.track_data_views.views.view_3d.multiple_view_widget import MultipleViewerWidget + + +def test_multiple_viewer_widget_initialization(make_napari_viewer, qtbot): + """Test that the MultipleViewerWidget initializes correctly.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + # Check that the widget initializes without errors + assert widget.viewer == viewer + assert widget.viewer_model1 is not None + assert widget.viewer_model2 is not None + assert widget.qt_viewer1 is not None + assert widget.qt_viewer2 is not None + + +def test_add_remove_layer(make_napari_viewer, qtbot): + """Test that layers are correctly added and removed in both viewer models.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + # Test image layer + layer = Image(np.zeros((2, 2, 2))) + layer.name = "test_layer" + viewer.add_layer(layer) + + # test labels layer + labels = Labels(np.zeros((2, 2, 2), dtype=np.uint8)) + labels.name = "test_labels_layer" + viewer.add_layer(labels) + + # Check that the layer was added correctly to both viewer models + assert "test_layer" in widget.viewer_model1.viewer_model.layers + assert "test_layer" in widget.viewer_model2.viewer_model.layers + assert isinstance(widget.viewer_model1.viewer_model.layers["test_layer"], Image) + assert isinstance(widget.viewer_model2.viewer_model.layers["test_layer"], Image) + + assert "test_labels_layer" in widget.viewer_model1.viewer_model.layers + assert "test_labels_layer" in widget.viewer_model2.viewer_model.layers + assert isinstance( + widget.viewer_model1.viewer_model.layers["test_labels_layer"], Labels + ) + assert isinstance( + widget.viewer_model2.viewer_model.layers["test_labels_layer"], Labels + ) + + viewer.layers.remove(layer) + + assert "test_layer" not in widget.viewer_model1.viewer_model.layers + assert "test_layer" not in widget.viewer_model2.viewer_model.layers + + +def test_move_layer(make_napari_viewer, qtbot): + """Test moving a layer is synced to the viewer models.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + layer1 = Image(np.zeros((2, 2, 2))) + layer2 = Image(np.ones((2, 2, 2))) + layer1.name = "layer1" + layer2.name = "layer2" + + viewer.add_layer(layer1) + viewer.add_layer(layer2) + + # Check that the layer was added to both viewer models + assert viewer.layers[0].name == layer1.name + assert viewer.layers[1].name == layer2.name + + assert widget.viewer_model1.viewer_model.layers[0].name == layer1.name + assert widget.viewer_model1.viewer_model.layers[1].name == layer2.name + assert widget.viewer_model2.viewer_model.layers[0].name == layer1.name + assert widget.viewer_model2.viewer_model.layers[1].name == layer2.name + + # Move layer1 to the top + viewer.layers.move(1, 0) + assert viewer.layers[0].name == layer2.name + assert viewer.layers[1].name == layer1.name + + # Check that the layer was moved in both viewer models + assert widget.viewer_model1.viewer_model.layers[0].name == layer2.name + assert widget.viewer_model1.viewer_model.layers[1].name == layer1.name + assert widget.viewer_model2.viewer_model.layers[0].name == layer2.name + assert widget.viewer_model2.viewer_model.layers[1].name == layer1.name + + +def test_rename_layer(make_napari_viewer, qtbot): + """Test that layers are added correctly to the viewer models.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + layer1 = Image(np.zeros((2, 2, 2))) + layer1.name = "layer1" + viewer.add_layer(layer1) + + assert widget.viewer_model1.viewer_model.layers[0].name == layer1.name + assert widget.viewer_model2.viewer_model.layers[0].name == layer1.name + + viewer.layers[0].name = "layer1_renamed" + assert viewer.layers[0].name == "layer1_renamed" + assert widget.viewer_model1.viewer_model.layers[0].name == "layer1_renamed" + assert widget.viewer_model2.viewer_model.layers[0].name == "layer1_renamed" + + +def test_update_current_step(make_napari_viewer, qtbot): + """Test if updating the current step in one viewer (model) updates the other.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + layer1 = Image(np.zeros((2, 2, 2, 2))) + layer1.name = "layer1" + viewer.add_layer(layer1) + + assert viewer.dims.current_step == (0, 0, 0, 0) + assert widget.viewer_model1.viewer_model.dims.current_step == (0, 0, 0, 0) + assert widget.viewer_model2.viewer_model.dims.current_step == (0, 0, 0, 0) + + # Update current step and check that the viewer models follow + viewer.dims.current_step = (1, 1, 0, 0) + assert viewer.dims.current_step == (1, 1, 0, 0) + assert widget.viewer_model1.viewer_model.dims.current_step == (1, 1, 0, 0) + assert widget.viewer_model2.viewer_model.dims.current_step == (1, 1, 0, 0) + + widget.viewer_model1.viewer_model.dims.current_step = (0, 0, 1, 1) + assert viewer.dims.current_step == (0, 0, 1, 1) + assert widget.viewer_model1.viewer_model.dims.current_step == (0, 0, 1, 1) + assert widget.viewer_model2.viewer_model.dims.current_step == (0, 0, 1, 1) + + +def test_reset_view(make_napari_viewer, qtbot): + """Test if resetting the view in one viewer (model) resets the other.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + layer1 = Image(np.zeros((2, 2, 2))) + layer1.name = "layer1" + viewer.add_layer(layer1) + + # change camera zoom on a viewer model + widget.viewer_model1.viewer_model.camera.zoom = 5 + assert widget.viewer_model1.viewer_model.camera.zoom == 5 + + # check that resetting the view on the viewer is propagated to the viewer model + viewer.reset_view() + assert widget.viewer_model1.viewer_model.camera.zoom == viewer.camera.zoom + + +def test_layer_selection(make_napari_viewer, qtbot): + """Test syncing layer selection between the viewer and the viewer models.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + layer1 = Image(np.zeros((2, 2, 2))) + layer2 = Image(np.ones((2, 2, 2))) + layer1.name = "layer1" + layer2.name = "layer2" + + viewer.add_layer(layer1) + viewer.add_layer(layer2) + + # Change the layer selection in the veiwer + viewer.layers.selection.active = viewer.layers[0] + assert ( + widget.viewer_model1.viewer_model.layers.selection.active + == widget.viewer_model1.viewer_model.layers[0] + ) + assert ( + widget.viewer_model2.viewer_model.layers.selection.active + == widget.viewer_model2.viewer_model.layers[0] + ) + + viewer.layers.selection.active = viewer.layers[1] + assert ( + widget.viewer_model1.viewer_model.layers.selection.active + == widget.viewer_model1.viewer_model.layers[1] + ) + assert ( + widget.viewer_model2.viewer_model.layers.selection.active + == widget.viewer_model2.viewer_model.layers[1] + ) + + +def test_property_sync(make_napari_viewer, qtbot): + """test if updating the data on a layer in the viewer updates the data in the viewer models and vice versa.""" + + viewer = make_napari_viewer() + viewer.dims.order = [0, 1, 2] + viewer.dims.axis_labels = ["z", "y", "x"] + widget = MultipleViewerWidget(viewer) + qtbot.addWidget(widget) + + widget = MultipleViewerWidget(viewer) + + labels = Labels(np.zeros((2, 2, 2), dtype=np.uint8)) + viewer.add_layer(labels) + + viewer.layers[0].opacity = 0.5 + assert widget.viewer_model1.viewer_model.layers[0].opacity == 0.5 + assert widget.viewer_model2.viewer_model.layers[0].opacity == 0.5 + + widget.viewer_model1.viewer_model.layers[0].visible = False + assert not viewer.layers[0].visible + assert not widget.viewer_model2.viewer_model.layers[0].visible + + viewer.layers[0].data[0][0][0] = 1 + assert widget.viewer_model1.viewer_model.layers[0].data[0][0][0] == 1 + assert widget.viewer_model1.viewer_model.layers[0].data[0][0][0] == 1 + + widget.viewer_model1.viewer_model.layers[0].data[0][0][1] = 2 + assert viewer.layers[0].data[0][0][1] == 2 + assert widget.viewer_model1.viewer_model.layers[0].data[0][0][1] == 2 diff --git a/finn/track_data_views/views/view_3d/cross_widget.py b/finn/track_data_views/views/view_3d/cross_widget.py new file mode 100644 index 00000000..62aaee0b --- /dev/null +++ b/finn/track_data_views/views/view_3d/cross_widget.py @@ -0,0 +1,124 @@ +import numpy as np +from qtpy.QtWidgets import ( + QCheckBox, +) +from superqt.utils import qthrottled + +import finn +from finn.components.layerlist import Extent +from finn.components.viewer_model import ViewerModel +from finn.layers import Vectors +from finn.utils.action_manager import action_manager +from finn.utils.notifications import show_info + + +def center_cross_on_mouse( + viewer_model: finn.components.viewer_model.ViewerModel, +): + """move the cross to the mouse position""" + + if not getattr(viewer_model, "mouse_over_canvas", True): + # There is no way for finn 0.4.15 to check if mouse is over sending canvas. + show_info("Mouse is not over the canvas. You may need to click on the canvas.") + return + + viewer_model.dims.current_step = tuple( + np.round( + [ + max(min_, min(p, max_)) / step + for p, (min_, max_, step) in zip( + viewer_model.cursor.position, viewer_model.dims.range, strict=False + ) + ] + ).astype(int) + ) + + +action_manager.register_action( + name="finn:move_point", + command=center_cross_on_mouse, + description="Move dims point to mouse position", + keymapprovider=ViewerModel, +) + +action_manager.bind_shortcut("finn:move_point", "T") + + +class CrossWidget(QCheckBox): + """ + Widget to control the cross layer. because of the performance reason + the cross update is throttled + """ + + def __init__(self, viewer: finn.Viewer): + super().__init__("Add cross layer") + self.viewer = viewer + self.setChecked(False) + self.stateChanged.connect(self._update_cross_visibility) + self.layer = None + self.viewer.dims.events.order.connect(self.update_cross) + self.viewer.dims.events.ndim.connect(self._update_ndim) + self.viewer.dims.events.current_step.connect(self.update_cross) + self._extent = None + + self._update_extent() + self.viewer.dims.events.connect(self._update_extent) + + @qthrottled(leading=False) + def _update_extent(self): + """ + Calculate the extent of the data. + + Ignores the cross layer itself in calculating the extent. + """ + + extent_list = [ + layer.extent for layer in self.viewer.layers if layer is not self.layer + ] + self._extent = Extent( + data=None, + world=self.viewer.layers._get_extent_world(extent_list), + step=self.viewer.layers._get_step_size(extent_list), + ) + self.update_cross() + + def _update_ndim(self, event): + if self.layer in self.viewer.layers: + self.viewer.layers.remove(self.layer) + self.layer = Vectors(name=".cross", ndim=event.value) + self.layer.vector_style = "line" + self.layer.edge_width = 2 + self.update_cross() + + def _update_cross_visibility(self, state): + if state: + if self.layer is None: + self.layer = Vectors(name=".cross", ndim=self.viewer.dims.ndim) + self.layer.vector_style = "line" + self.layer.edge_width = 2 + self.viewer.layers.append(self.layer) + else: + self.viewer.layers.remove(self.layer) + self.update_cross() + if not np.any(self.layer.edge_color): + self.layer.edge_color = "red" + self.layer.vector_style = "line" + + def update_cross(self): + if self.layer not in self.viewer.layers: + self.setChecked(False) + return + + point = self.viewer.dims.current_step + vec = [] + for i, (lower, upper) in enumerate(self._extent.world.T): + if (upper - lower) / self._extent.step[i] == 1: + continue + point1 = list(point) + point1[i] = (lower + self._extent.step[i] / 2) / self._extent.step[i] + point2 = [0 for _ in point] + point2[i] = (upper - lower) / self._extent.step[i] + vec.append((point1, point2)) + if np.any(self.layer.scale != self._extent.step): + self.layer.scale = self._extent.step + self.layer.data = vec diff --git a/finn/track_data_views/views/view_3d/multiple_view_widget.py b/finn/track_data_views/views/view_3d/multiple_view_widget.py new file mode 100644 index 00000000..2be064ec --- /dev/null +++ b/finn/track_data_views/views/view_3d/multiple_view_widget.py @@ -0,0 +1,455 @@ +import time + +from qtpy.QtWidgets import ( + QVBoxLayout, + QWidget, +) + +import finn +from finn.components.viewer_model import ViewerModel +from finn.layers import Labels, Layer, Points, Shapes +from finn.qt import QtViewer +from finn.track_data_views.views.layers.track_graph import TrackGraph +from finn.track_data_views.views.layers.track_labels import TrackLabels +from finn.track_data_views.views.layers.track_points import TrackPoints +from finn.utils.events import Event +from finn.utils.events.event import WarningEmitter + + +def copy_layer(layer: Layer, name: str = ""): + if isinstance( + layer, TrackGraph + ): # instead of showing the tracks (not very useful on 3D data because they are collapsed to a single frame), + # use an empty shapes layer as substitute to ensure that the layer indices in the orthogonal viewer models + # match with those in the main viewer + res_layer = Shapes( + name=layer.name, + data=[], + ) + + elif isinstance(layer, TrackLabels): + res_layer = Labels( + data=layer.data, + name=layer.name, + opacity=layer.opacity, + scale=layer.scale, + ) + elif isinstance(layer, TrackPoints): + res_layer = Points( + data=layer.data, + name=layer.name, + symbol=layer.symbol, + face_color=layer.face_color, + size=layer.size, + properties=layer.properties, + border_color=layer.border_color, + scale=layer.scale, + blending="translucent_no_depth", + ) + else: + res_layer = Layer.create(*layer.as_layer_data_tuple()) + + res_layer.metadata["viewer_name"] = name + return res_layer + + +def get_property_names(layer: Layer): + klass = layer.__class__ + res = [] + for event_name, event_emitter in layer.events.emitters.items(): + if isinstance(event_emitter, WarningEmitter): + continue + if event_name in ("thumbnail", "name"): + continue + if ( + isinstance(getattr(klass, event_name, None), property) + and getattr(klass, event_name).fset is not None + ): + res.append(event_name) + return res + + +class own_partial: + """ + Workaround for deepcopy not copying partial functions + (Qt widgets are not serializable) + """ + + def __init__(self, func, *args, **kwargs): + self.func = func + self.args = args + self.kwargs = kwargs + + def __call__(self, *args, **kwargs): + return self.func(*(self.args + args), **{**self.kwargs, **kwargs}) + + +class ViewerModelContainer: + """ + A container that holds a ViewerModel and manages synchronization across different views. + """ + + def __init__(self, title: str, rel_order: tuple[int]): + self.title = title + self.rel_order = rel_order + self.viewer_model = ViewerModel(title) + self.viewer_model.axes.visible = True + self._block = False + + def add_layer(self, orig_layer: Layer, index: int): + """Set the layers of the contained ViewerModel.""" + self.viewer_model.layers.insert(index, copy_layer(orig_layer, self.title)) + copied_layer = self.viewer_model.layers[orig_layer.name] + + # sync name + def sync_name_wrapper(event): + return self.sync_name(orig_layer, copied_layer, event) + + orig_layer.events.name.connect(sync_name_wrapper) + + # sync properties + if not isinstance(orig_layer, TrackGraph): # ignore trackgraph layers + for property_name in get_property_names(orig_layer): + # sync forward (from original layer to copied layer) + if not ( + isinstance(orig_layer, TrackPoints) and property_name == "data" + ): # we will sync data separately on TrackPoints as we need finer control + getattr(orig_layer.events, property_name).connect( + own_partial( + self.sync_property, + property_name, + orig_layer, + copied_layer, + ) + ) + + # in the case of a TrackLabels or TrackPoints layer, sync only specific properties backwards. Otherwise, sync all properties + if not isinstance( + orig_layer, (TrackLabels | TrackPoints) + ) or property_name in ( + "mode", + "selected_label", + "n_edit_dimensions", + "brush_size", + ): + getattr(copied_layer.events, property_name).connect( + own_partial( + self.sync_property, + property_name, + copied_layer, + orig_layer, + ) + ) + + # forward click events and key binds in the case of TrackLabels and TrackPoints layers + if isinstance(orig_layer, (TrackLabels | TrackPoints)): + + def click_wrapper(layer, event): + # Access orig_layer here + return self.click(orig_layer, layer, event) + + copied_layer.mouse_drag_callbacks.append(click_wrapper) + + copied_layer.bind_key("q")(orig_layer.tracks_viewer.toggle_display_mode) + copied_layer.bind_key("z")(orig_layer.tracks_viewer.undo) + copied_layer.bind_key("r")(orig_layer.tracks_viewer.redo) + + # if the original layer is a TrackLabels instance, forward paint events on its derived TrackLabels instances to the original layer + if isinstance(orig_layer, TrackLabels): + + def paint_wrapper(event): + return self.sync_paint(orig_layer, event) + + copied_layer.events.paint.connect(paint_wrapper) + + elif isinstance(orig_layer, Labels): + # if the original layer is a normal labels layer, we still want to connect to the paint event, + # because we need it in order to invoke syncing between the different viewers. + # (Paint event does not trigger 'data' event by itself). + # We do not need to connect to the eraser and fill bucket separately. + + copied_layer.events.paint.connect( + lambda event: self.update_data( + source=copied_layer, target=orig_layer, event=event + ) # copy data from copied_layer to orig_layer (orig_layer emits signal, which triggers update on other viewer models, if present) + ) + orig_layer.events.paint.connect( + lambda event: self.update_data( + source=orig_layer, target=copied_layer, event=event + ) # copy data from orig_layer to copied_layer (copied_layer emits signal but we don't process it) + ) + + # if the original layer is a TrackPoints layer, make sure the visible points are synced (when switching between 'all' and 'lineage' mode) + # and make sure that moving a point forwards the event to the original layer for processing (or resetting, if a seg_layer is present) + elif isinstance(orig_layer, TrackPoints): + + def shown_points_wrapper(event): + return self.sync_shown_points(orig_layer, copied_layer) + + orig_layer.events.border_color.connect(shown_points_wrapper) + + def receive_data_wrapper(): + return self.receive_data(orig_layer, copied_layer) + + orig_layer.data_updated.connect(receive_data_wrapper) + + def sync_data_wrapper(event): + return self.sync_data_event(orig_layer, copied_layer, event) + + copied_layer._sync_data_wrapper = sync_data_wrapper + copied_layer.events.data.connect(sync_data_wrapper) + + def update_data(self, source: Labels, target: Labels, event: Event) -> None: + """Copy data from source layer to target layer, which triggers a data event on the target layer. Block syncing to itself (VM1 -> orig -> VM1 is blocked, but VM1 -> orig -> VM2 is not blocked) + Args: + source: the source Labels layer + target: the target Labels layer + event: the event to be triggered (not used)""" + + self._block = True # no syncing to itself is necessary + target.data = source.data # trigger data event so that it can sync to other viewer models (only if target layer is orig_layer) + self._block = False + + def receive_data(self, orig_layer: TrackPoints, copied_layer: Points) -> None: + """Respond to signal from the original layer, to update the data""" + + copied_layer.events.data.disconnect(copied_layer._sync_data_wrapper) + copied_layer.data = orig_layer.data + copied_layer.events.data.connect(copied_layer._sync_data_wrapper) + + def sync_data_event( + self, orig_layer: TrackPoints, copied_layer: Points, event: Event + ) -> None: + """Send the event that is emitted when a point is moved or deleted to the original layer""" + + if hasattr(event, "action") and event.action in ("added", "changed", "removed"): + with orig_layer.events.blocker_all(): # try to suppress updating visibility + orig_layer.selected_data = ( + copied_layer.selected_data + ) # make sure the same data is selected + orig_layer._update_data(event) + + def sync_shown_points(self, orig_layer: TrackPoints, copied_layer: Points) -> None: + """Sync the visible points between original TrackPoints layer and Points layers in ViewerModel instances (this is not a synced property)""" + + with copied_layer.events.blocker_all(): + copied_layer.size = orig_layer.size + copied_layer.shown = orig_layer.shown + + copied_layer.refresh() + + def sync_name(self, orig_layer: Layer, copied_layer: Layer, event: Event): + """Forward the renaming event from original layer to copied layer""" + + copied_layer.name = orig_layer.name + + def sync_paint(self, orig_layer: TrackLabels, event: Event): + """Sync paint event to original TrackLabels instance""" + + orig_layer._on_paint(event) + + def click( + self, + orig_layer: TrackLabels | TrackPoints, + layer: TrackLabels | Points, + event: Event, + ): + """Forward the click event from the ViewerModel to the original TracksLabels layer + args: + orig_layer: original TrackLabels or TrackPoints layer + layer: the TrackLabels or Points layer on this ViewerModel + event: the click event + """ + if layer.mode == "pan_zoom": + mouse_press_time = time.time() + dragged = False + yield + # on move + while event.type == "mouse_move": + dragged = True + yield + if dragged and time.time() - mouse_press_time < 0.5: + dragged = False # suppress micro drag events and treat them as click + # on release + if not dragged: + if isinstance(layer, TrackLabels): + label = layer.get_value( + event.position, + view_direction=event.view_direction, + dims_displayed=event.dims_displayed, + world=True, + ) + orig_layer.process_click(event, label) + + if isinstance(layer, Points): + point_index = layer.get_value( + event.position, + view_direction=event.view_direction, + dims_displayed=event.dims_displayed, + world=True, + ) + orig_layer.process_point_click(point_index, event) + + def sync_property( + self, property_name: str, source_layer: Layer, target_layer: Layer, event: Event + ): + """Sync a property of a layer in this viewer model.""" + + if self._block: + return + + self._block = True + setattr( + target_layer, + property_name, + getattr(source_layer, property_name), + ) + self._block = False + + +class MultipleViewerWidget(QWidget): + """The main widget of the example.""" + + def __init__(self, viewer: finn.Viewer): + super().__init__() + self.viewer = viewer + self.viewer.axes.visible = True + self.viewer.axes.events.visible.connect(self.set_orth_views_dims_order) + self.viewer_model1 = ViewerModelContainer(title="model1", rel_order=(-2, -3, -1)) + self.viewer_model2 = ViewerModelContainer(title="model2", rel_order=(-1, -2, -3)) + self.qt_viewer1 = QtViewer(self.viewer_model1.viewer_model) + self.qt_viewer2 = QtViewer(self.viewer_model2.viewer_model) + viewer_splitter = QVBoxLayout() + viewer_splitter.addWidget(self.qt_viewer1) + viewer_splitter.addWidget(self.qt_viewer2) + viewer_splitter.setContentsMargins(0, 0, 0, 0) + + self.setLayout(viewer_splitter) + + # Add the layers currently in the viewer + for i, layer in enumerate(self.viewer.layers): + self.viewer_model1.add_layer(layer, i) + self.viewer_model2.add_layer(layer, i) + + # Connect to events + self.viewer.layers.events.inserted.connect(self._layer_added) + self.viewer.layers.events.removed.connect(self._layer_removed) + self.viewer.layers.events.moved.connect(self._layer_moved) + self.viewer.layers.selection.events.active.connect(self._layer_selection_changed) + self.viewer.events.reset_view.connect(self._reset_view) + self.viewer.dims.events.current_step.connect(self._update_current_step) + self.viewer_model1.viewer_model.dims.events.current_step.connect( + self._update_current_step + ) + self.viewer_model2.viewer_model.dims.events.current_step.connect( + self._update_current_step + ) + + # Adjust dimensions for orthogonal views + self.set_orth_views_dims_order() + + def set_orth_views_dims_order(self): + """The the order of the z,y,x dims in the orthogonal views, by using the rel_order attribute of the viewer models""" + + # TODO: allow the user to provide the dimension order and names. + axis_labels = ("t", "z", "y", "x") # assume default axis labels for now + order = list(self.viewer.dims.order) + + if len(order) > 2: + # model 1 axis order (e.g. xz view) + m1_order = list(order) + m1_order[-3:] = ( + m1_order[self.viewer_model1.rel_order[0]], + m1_order[self.viewer_model1.rel_order[1]], + m1_order[self.viewer_model1.rel_order[2]], + ) + self.viewer_model1.viewer_model.dims.order = m1_order + + # model 2 axis order (e.g. yz view) + m2_order = list(order) + m2_order[-3:] = ( + m2_order[self.viewer_model2.rel_order[0]], + m2_order[self.viewer_model2.rel_order[1]], + m2_order[self.viewer_model2.rel_order[2]], + ) + + self.viewer_model2.viewer_model.dims.order = m2_order + + if len(order) == 3: # assume we have zyx axes + self.viewer.dims.axis_labels = axis_labels[1:] + self.viewer_model1.viewer_model.dims.axis_labels = axis_labels[1:] + self.viewer_model2.viewer_model.dims.axis_labels = axis_labels[1:] + elif len(order) == 4: # assume we have tzyx axes + self.viewer.dims.axis_labels = axis_labels + self.viewer_model1.viewer_model.dims.axis_labels = axis_labels + self.viewer_model2.viewer_model.dims.axis_labels = axis_labels + + # whether or not the axis should be visible + self.viewer_model1.viewer_model.axes.visible = self.viewer.axes.visible + self.viewer_model2.viewer_model.axes.visible = self.viewer.axes.visible + + def _reset_view(self): + """Propagate the reset view event""" + + self.viewer_model1.viewer_model.reset_view() + self.viewer_model2.viewer_model.reset_view() + + def _layer_selection_changed(self, event): + """Update of current active layers""" + + if event.value is None: + self.viewer_model1.viewer_model.layers.selection.active = None + self.viewer_model2.viewer_model.layers.selection.active = None + return + + if event.value.name in self.viewer_model1.viewer_model.layers: + self.viewer_model1.viewer_model.layers.selection.active = ( + self.viewer_model1.viewer_model.layers[event.value.name] + ) + if event.value.name in self.viewer_model2.viewer_model.layers: + self.viewer_model2.viewer_model.layers.selection.active = ( + self.viewer_model2.viewer_model.layers[event.value.name] + ) + + def _update_current_step(self, event): + """Sync the current step between different viewer models""" + + for model in [ + self.viewer, + self.viewer_model1.viewer_model, + self.viewer_model2.viewer_model, + ]: + if model.dims is event.source: + continue + model.dims.current_step = event.value + + def _layer_added(self, event): + """Add layer to additional other viewer models""" + + if event.value.name not in self.viewer_model1.viewer_model.layers: + self.viewer_model1.add_layer(event.value, event.index) + + if event.value.name not in self.viewer_model2.viewer_model.layers: + self.viewer_model2.add_layer(event.value, event.index) + + self.set_orth_views_dims_order() + + def _layer_removed(self, event): + """Remove layer in all viewer models""" + + layer_name = event.value.name + if layer_name in self.viewer_model1.viewer_model.layers: + self.viewer_model1.viewer_model.layers.pop(layer_name) + if layer_name in self.viewer_model2.viewer_model.layers: + self.viewer_model2.viewer_model.layers.pop(layer_name) + + self.set_orth_views_dims_order() + + def _layer_moved(self, event): + """Update order of layers in all viewer models""" + + dest_index = ( + event.new_index if event.new_index < event.index else event.new_index + 1 + ) + self.viewer_model1.viewer_model.layers.move(event.index, dest_index) + self.viewer_model2.viewer_model.layers.move(event.index, dest_index) diff --git a/finn/track_data_views/views/view_3d/orthogonal_views.py b/finn/track_data_views/views/view_3d/orthogonal_views.py new file mode 100644 index 00000000..9e95fec5 --- /dev/null +++ b/finn/track_data_views/views/view_3d/orthogonal_views.py @@ -0,0 +1,25 @@ +from qtpy.QtWidgets import QVBoxLayout, QWidget + +import finn +from finn.track_data_views.views.view_3d.cross_widget import CrossWidget +from finn.track_data_views.views.view_3d.multiple_view_widget import MultipleViewerWidget + + +class OrthogonalViews(QWidget): + """A widget that combines the multiple viewer widget and cross widget into single widget""" + + def __init__( + self, + viewer: finn.Viewer, + ): + super().__init__() + + viewer = viewer + multiple_viewer_widget = MultipleViewerWidget(viewer) + cross_widget = CrossWidget(viewer) + + layout = QVBoxLayout() + layout.addWidget(multiple_viewer_widget) + layout.addWidget(cross_widget) + + self.setLayout(layout)