diff --git a/finn/track_application_menus/main_app.py b/finn/track_application_menus/main_app.py index 6355db9d..b47f510d 100644 --- a/finn/track_application_menus/main_app.py +++ b/finn/track_application_menus/main_app.py @@ -5,6 +5,15 @@ import finn from finn.track_application_menus.menu_widget import MenuWidget +from finn.track_data_views.views.layers.track_labels import TrackLabels +from finn.track_data_views.views.layers.track_points import TrackPoints +from finn.track_data_views.views.ortho_views import ( + _get_manager, + paint_event_hook, + point_data_hook, + sync_filters, + track_layers_hook, +) from finn.track_data_views.views.tree_view.tree_widget import TreeWidget @@ -22,4 +31,12 @@ def __init__(self, viewer: finn.Viewer): layout = QVBoxLayout() layout.addWidget(self.menu_widget) + orth_view_manager = _get_manager(viewer) + orth_view_manager.register_layer_hook( + (TrackLabels, TrackPoints), track_layers_hook + ) + orth_view_manager.register_layer_hook((TrackLabels), paint_event_hook) + orth_view_manager.register_layer_hook((TrackPoints), point_data_hook) + orth_view_manager.set_sync_filters(sync_filters) + self.setLayout(layout) diff --git a/finn/track_data_views/views/layers/track_labels.py b/finn/track_data_views/views/layers/track_labels.py index 48925b39..ed18290b 100644 --- a/finn/track_data_views/views/layers/track_labels.py +++ b/finn/track_data_views/views/layers/track_labels.py @@ -1,11 +1,13 @@ 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 @@ -99,34 +101,56 @@ 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) + self.process_click(event, label) - # 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) + def assign_new_label(self, event): + """Function for orthoviews to connect to so the 'm' event can be processed here""" + + new_label(self) + + def process_click(self, event: Event, label: int): + """Process the click event to update the selected nodes""" + + 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) def _get_colormap(self) -> DirectLabelColormap: """Get a DirectLabelColormap that maps node ids to their track ids, and then @@ -208,20 +232,26 @@ 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) ) return new_value, actions - def _revert_paint(self, event): + def _revert_paint(self, _, source_layer: Labels | None = None): """Revert a paint event after it fails validation (no motile tracker Actions have - been created). This keeps the view synced with the backend data. + been created). If a source_layer is provided, the paint event will be reverted on + this layer (this is necessary for orthoviews). This keeps the view synced with + the backend data. """ - super().undo() - def _on_paint(self, event): + if source_layer is not None: + source_layer.undo() # revert on the orthoview + else: + super().undo() + + def _on_paint(self, event, source_layer: Labels | None = None): """Listen to the paint event and check which track_ids have changed""" with self.events.selected_label.blocker(): @@ -267,7 +297,7 @@ def _on_paint(self, event): " If you want to update the track id of the node, please edit the " "edges directly instead." ) - self._revert_paint(event) + self._revert_paint(event, source_layer) self.refresh() return self.tracks_viewer.tracks_controller.update_segmentations( diff --git a/finn/track_data_views/views/layers/track_points.py b/finn/track_data_views/views/layers/track_points.py index 411a17e9..50e181c3 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""" @@ -121,7 +147,10 @@ def _refresh(self): 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,8 @@ def update_point_outline(self, visible: list[int] | str) -> None: 1, ) self.size[index] = math.ceil(self.default_size + 0.3 * self.default_size) + + # 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/ortho_views.py b/finn/track_data_views/views/ortho_views.py new file mode 100644 index 00000000..49615694 --- /dev/null +++ b/finn/track_data_views/views/ortho_views.py @@ -0,0 +1,295 @@ +import importlib +import inspect +import sys +import time + +from finn.layers import Labels, Layer, Points, Shapes +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.notifications import show_info + + +def convert_napari_to_finn(napari_name: str, finn_name: str): + """Convert `napari_name` to `finn_name`, ensuring parent packages are linked too.""" + module = importlib.import_module(finn_name) + sys.modules[napari_name] = module + + # Attach to parent package if there is one + if "." in napari_name: + parent_name, child_name = napari_name.rsplit(".", 1) + parent_module = sys.modules.get(parent_name) + if parent_module is None: + # If parent wasn't imported yet, create a dummy package + import types + + parent_module = types.ModuleType(parent_name) + sys.modules[parent_name] = parent_module + setattr(parent_module, child_name, module) + + +# Map napari imports to finn equivalents +convert_napari_to_finn("napari", "finn") +convert_napari_to_finn("napari.components", "finn.components") +convert_napari_to_finn("napari.components.viewer_model", "finn.components.viewer_model") +convert_napari_to_finn("napari.components.overlays.base", "finn.components.overlays.base") +convert_napari_to_finn("napari.layers", "finn.layers") +convert_napari_to_finn("napari.qt", "finn.qt") +convert_napari_to_finn("napari.utils", "finn.utils") +convert_napari_to_finn("napari.utils.events", "finn.utils.events") +convert_napari_to_finn("napari.utils.events.event", "finn.utils.events.event") +convert_napari_to_finn("napari._vispy.overlays.base", "finn._vispy.overlays.base") +convert_napari_to_finn("napari._vispy.utils.visual", "finn._vispy.utils.visual") + + +# redefinition of copy_layer function +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, + colormap=layer.colormap, + ) + 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 + + +# do not let ruff move these import statements! +from napari_orthogonal_views.ortho_view_manager import _get_manager # noqa +import napari_orthogonal_views.ortho_view_widget as ov_widget # noqa + +ov_widget.copy_layer = copy_layer # replace the copy layer with the customized version +# defined here + +# Define custom sync_filters. By default, all properties are synced forwards and backwards +# between the original layer and its derived copy. However, for Tracks Layers we need +# finer control over some syncing events, because they may have additional attached events +# that shouldn't be triggered due to reverse syncing, or because we need to capture the +# event and process it separately before it gets synced. A dictionary can be defined here +# to disable specific Layer properties for forward or reverse syncing. + + +def get_property_names_from_class(layer_cls): + """Return all property names for a Layer class.""" + res = [] + for name, obj in inspect.getmembers(layer_cls): + # must be a property with a setter + if isinstance(obj, property) and obj.fset is not None: + # skip special or non-sync properties + if name in ("thumbnail", "name"): + continue + res.append(name) + return res + + +sync_filters = { + TrackGraph: { + "forward_exclude": "*", # disable all forward sync (layer is not shown) + "reverse_exclude": "*", # disable all reverse sync + }, + TrackPoints: { + "forward_exclude": {"data"}, # we will sync data separately on TrackPoints as we + # need finer control + "reverse_exclude": set(get_property_names_from_class(Points)) - {"mode"}, + }, + TrackLabels: { + "reverse_exclude": set(get_property_names_from_class(Labels)) + - { + "mode", + "selected_label", + "n_edit_dimensions", + "brush_size", + } # Let TrackLabels handle these properties on its own because it is listening to + # them and we do not want to overwrite through reverse syncing. + }, +} + + +# Define special functions to allow specific behavior on special layer types (TrackLabels, +# and TrackPoints) +# +def point_data_hook(orig_layer: TrackPoints, copied_layer: Points) -> None: + """Hook to connect to sync points data and visualization between original and copied + Points layers. + + Args: + orig_layer (TrackPoints): TracksLabels layer from which the copied layer is + derived. + copied_layer (Points): Points equivalent of the TracksPoints layer. + """ + + # Sync the shown points and their size, as it is not synced by default. We bind to the + # border_color event as this this is emitted when we modify shown points and point + # size on the TrackPoints layer. + def sync_shown_points(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 shown_points_wrapper(event): + return sync_shown_points(orig_layer, copied_layer) + + orig_layer.events.border_color.connect(shown_points_wrapper) + + # Receive data updates from the original layer + def receive_data(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 receive_data_wrapper(): + return receive_data(orig_layer, copied_layer) + + orig_layer.data_updated.connect(receive_data_wrapper) + + # Sync the event that is emitted when a point is moved or deleted. We need to capture + # it on the original layer to process it there, and potentially undo it if it was an + # invalid action (we have no way to judge that on a normal Points layer). + def sync_data_event( + 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_data_wrapper(event): + return 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 paint_event_hook(orig_layer: TrackLabels, copied_layer: Labels) -> None: + """Hook to connect to paint events and process them on the original TracksLabels + layer. + + Args: + orig_layer (TrackLabels): TracksLabels layer from which the copied layer is + derived. + copied_layer (Labels): Labels equivalent of the TracksLabels layer. Instead of + processing paint actions on this copy, we want to send them to the original + layer and process them there. + """ + + def sync_paint(orig_layer: TrackLabels, copied_layer: Labels, event: Event): + """Process paint event on original TrackLabels instance.""" + + if copied_layer.data.ndim > 3: + orig_layer._on_paint(event, copied_layer) + else: + show_info("Painting in the time dimension is not supported") + orig_layer._revert_paint(event, copied_layer) + orig_layer.refresh() + + def paint_wrapper(event: Event): + """Wrap paint event and send to original layer.""" + + return sync_paint(orig_layer, copied_layer, event) + + copied_layer.events.paint.connect(paint_wrapper) + + +def track_layers_hook( + orig_layer: TrackLabels | TrackPoints, copied_layer: Labels | Points +) -> None: + """Hook to capture click events on TrackLabels and TrackPoints derived Labels and + Points layers, and forward them to their original layer. Also, register key binds + for view mode, undo & redo to copied layer, that call functions on the original layer. + + Args: + orig_layer (TrackLabels | TrackPoints): TracksLabels or TrackPoints layer from + which the copied layer is derived. + copied_layer (Labels | Points): Labels or Points equivalent of the TracksLabels + or TrackPoints layer. + """ + + # define the click behavior the layer should respond to + def click( + orig_layer: TrackLabels | TrackPoints, layer: Labels | Points, event: Event + ): + if layer.mode == "pan_zoom": + mouse_press_time = time.time() + dragged = False + yield # start + while event.type == "mouse_move": + dragged = True + yield + if dragged and time.time() - mouse_press_time < 0.5: + dragged = False # treat micro drags as clicks + + if not dragged: + if isinstance(layer, Labels): + 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) + + # Wrap and attach click callback + def click_wrapper(layer, event): + return click(orig_layer, layer, event) + + copied_layer.mouse_drag_callbacks.append(click_wrapper) + + # Bind keys to original layer TracksViewer + 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) + copied_layer.bind_key("d")(orig_layer.tracks_viewer.delete_node) + copied_layer.bind_key("a")(orig_layer.tracks_viewer.create_edge) + copied_layer.bind_key("b")(orig_layer.tracks_viewer.delete_edge) + if isinstance(orig_layer, TrackLabels): + copied_layer.bind_key("m")(orig_layer.assign_new_label) diff --git a/pyproject.toml b/pyproject.toml index d057a77c..4ca05043 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,8 @@ dependencies = [ "fonticon-fontawesome6", "zarr<3", "imagecodecs", # for unzipping sample data + "napari-orthogonal-views @ git+https://github.com/AnniekStok/napari-orthogonal-views.git@db1ac78ec240bfd6657a319828d7d0c7be712ebd" + ] dynamic = [ "version", @@ -389,7 +391,7 @@ xarray = "xr" [tool.pytest.ini_options] # These follow standard library warnings filters syntax. See more here: # https://docs.python.org/3/library/warnings.html#describing-warning-filters -addopts = ["--maxfail=5", "--durations=10", "-ra", "--strict-markers", "--strict-config"] +addopts = ["--maxfail=5", "--durations=10", "-ra", "--strict-markers", "--strict-config", "-p no:napari"] console_output_style = "count" minversion = "8" # log_cli_level = "INFO"