diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 4e755fc4..56bf1024 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -110,6 +110,10 @@ The tracking results can also be visualized as a lineage tree. You can open the lineage tree widget via ``Plugins`` > ``Motile Tracker`` > ``Widget - Lineage View``. For more details, go to the :doc:`Tree View ` documentation. +Tracking from scratch +********************* +Instead of automatic tracking, it is also possible to manually track from scratch. The ``Tracks List`` widget offers the option to create an empty tree that you can populate yourself by adding nodes as points or as segmentation labels. + Displaying feature measurements ******************************* If you are tracking with a segmentation layer, you can select size and shape features to measure in the ``Features`` widget. diff --git a/src/motile_tracker/application_menus/layer_dropdown.py b/src/motile_tracker/application_menus/layer_dropdown.py new file mode 100644 index 00000000..b6ad1d83 --- /dev/null +++ b/src/motile_tracker/application_menus/layer_dropdown.py @@ -0,0 +1,239 @@ +import contextlib +import weakref + +import napari +from psygnal import Signal +from qtpy.QtWidgets import QComboBox + + +class LayerDropdown(QComboBox): + """QComboBox widget with functions for updating the selected layer and to update the + list of options when the list of layers is modified. + + Args: + viewer (napari.Viewer): the viewer whose layers are listed. + layer_types (tuple): the layer types to list. + allow_none (bool): whether to add a 'No selection' entry. Defaults to False. + exclude_types (tuple): layer types to leave out of the list. + follow_active (bool): when True (the default), the dropdown follows the active + layer in the viewer. Set to False to only change the selection when the user + explicitly picks a layer from the dropdown. + """ + + layer_changed = Signal(str) + + def __init__( + self, + viewer: napari.Viewer, + layer_types: tuple, + allow_none=False, + exclude_types: tuple = (), + follow_active: bool = True, + ): + super().__init__() + + self.viewer = viewer + self.layer_types = layer_types + self.exclude_types = exclude_types + self.allow_none = allow_none + self.follow_active = follow_active + self.selected_layer = None + self._deleted = False + + # track rename callbacks so we can disconnect them at cleanup + self._rename_callbacks: dict[int, tuple[weakref.ref, callable]] = {} + self.destroyed.connect(self._on_destroyed) # for reference cleanup + + # viewer connections + self.viewer.layers.events.inserted.connect(self._on_insert) + self.viewer.layers.events.changed.connect(self._update_dropdown) + self.viewer.layers.events.removed.connect(self._on_removed) + if self.follow_active: + self.viewer.layers.selection.events.changed.connect( + self._on_selection_changed + ) + + self.currentTextChanged.connect(self._emit_layer_changed) + + # layers that are already present must be watched for renames too + for layer in self.viewer.layers: + self._watch_name(layer) + + self._update_dropdown() + + def _watch_name(self, layer) -> bool: + """Start tracking name changes of this layer, if it is one we list. + + Returns: + True if the layer is listed (and is now watched), False otherwise. + """ + + if not isinstance(layer, self.layer_types) or isinstance( + layer, self.exclude_types + ): + return False + + if id(layer) not in self._rename_callbacks: # never connect twice + cb = self._make_weak_rename_cb() + layer.events.name.connect(cb) + self._rename_callbacks[id(layer)] = (weakref.ref(layer), cb) + + return True + + def _make_weak_rename_cb(self): + """Create a weak callback to track name updates but do not let the layer keep the + widget alive forever.""" + + self_ref = weakref.ref(self) + + def _rename_cb(event=None): + self_obj = self_ref() + if self_obj is None or self_obj._deleted: + return + with contextlib.suppress(AttributeError, RuntimeError): + self_obj._update_dropdown() + + return _rename_cb + + def _on_insert(self, event) -> None: + """Update dropdown and make new layer responsive to name changes""" + + if self._deleted: + return + + if self._watch_name(event.value): + self._update_dropdown() + + def _on_removed(self, event) -> None: + """Disconnect signals and update dropdown when a layer is removed.""" + + if self._deleted: + return + + layer = event.value + pair = self._rename_callbacks.pop(id(layer), None) + if pair is not None: + layer_ref, cb = pair + layer_obj = layer_ref() if layer_ref else None + target = layer_obj if layer_obj else layer + with contextlib.suppress(AttributeError, RuntimeError, TypeError): + target.events.name.disconnect(cb) + + self._update_dropdown() + + def _on_selection_changed(self): + """Update the active layer when the selection changes""" + if self._deleted: + return + + try: + if len(self.viewer.layers.selection) == 1: + selected = self.viewer.layers.selection.active + if ( + isinstance(selected, self.layer_types) + and not isinstance(selected, self.exclude_types) + and selected != self.selected_layer + ): + self.setCurrentText(selected.name) + self._emit_layer_changed() + except (AttributeError, RuntimeError, TypeError): + pass + + def _update_dropdown(self, event=None) -> None: + """Update the layers in the dropdown""" + + if self._deleted: + return + + try: + previous = self.currentText() + # Block signals while rebuilding: clear()/addItem() emit currentTextChanged + # for the transient empty/partial states, which would momentarily report a + # None/other selection and (e.g.) tear down a connected source layer just + # because an unrelated layer was added. Emit once at the end, only if the + # effective selection really changed. + self.blockSignals(True) + try: + self.clear() + + layers = [ + layer + for layer in self.viewer.layers + if isinstance(layer, self.layer_types) + and not isinstance(layer, self.exclude_types) + ] + + names = [] + if self.allow_none: + self.addItem("No selection") + names.append("No selection") + + for layer in layers: + self.addItem(layer.name) + names.append(layer.name) + + # restore previous selection if still valid + if previous in names: + self.setCurrentText(previous) + finally: + self.blockSignals(False) + + if self.currentText() != previous: + self._emit_layer_changed() + except (AttributeError, RuntimeError, TypeError): + pass + + def set_layer_types(self, layer_types: tuple, exclude_types: tuple = ()) -> None: + """Change which layer types are listed (and which to exclude) and refresh.""" + + self.layer_types = layer_types + self.exclude_types = exclude_types + + # layers that were not listed before may be listed now + for layer in self.viewer.layers: + self._watch_name(layer) + + self._update_dropdown() + + def _emit_layer_changed(self) -> None: + """Emit a signal holding the currently selected layer""" + + if self._deleted: + return + + try: + name = self.currentText() + if name != "No selection" and name in self.viewer.layers: + self.selected_layer = self.viewer.layers[name] + else: + self.selected_layer = None + name = "" + self.layer_changed.emit(name) + except (AttributeError, RuntimeError, TypeError): + pass + + def _on_destroyed(self, *args): + """Disconnect everything cleanly""" + + self._deleted = True + + with contextlib.suppress(AttributeError, RuntimeError, TypeError): + self.viewer.layers.events.inserted.disconnect(self._on_insert) + self.viewer.layers.events.changed.disconnect(self._update_dropdown) + self.viewer.layers.events.removed.disconnect(self._on_removed) + if self.follow_active: + self.viewer.layers.selection.events.changed.disconnect( + self._on_selection_changed + ) + + for layer_ref, cb in self._rename_callbacks.values(): + layer_obj = layer_ref() if layer_ref else None + target = layer_obj + if target: + with contextlib.suppress(AttributeError, RuntimeError, TypeError): + target.events.name.disconnect(cb) + + self._rename_callbacks.clear() + + with contextlib.suppress(AttributeError, RuntimeError, TypeError): + self.currentTextChanged.disconnect(self._emit_layer_changed) diff --git a/src/motile_tracker/application_menus/track_list_widget.py b/src/motile_tracker/application_menus/track_list_widget.py index 6c27d965..a887b212 100644 --- a/src/motile_tracker/application_menus/track_list_widget.py +++ b/src/motile_tracker/application_menus/track_list_widget.py @@ -4,11 +4,15 @@ QWidget, ) +from motile_tracker.application_menus.tracking_from_scratch_widget import ( + TrackingFromScratch, +) from motile_tracker.data_views.views_coordinator.tracks_viewer import TracksViewer class TrackListWidget(QWidget): - """Creates or finds a TracksViewer and displays its TrackList widget. + """Creates or finds a TracksViewer and displays its TrackList widget, with the + controls to create an empty tracking tree (tracking from scratch) above it. This is only used in case the user wants to open the trackslist from the plugins menu. """ @@ -17,7 +21,10 @@ def __init__(self, viewer: napari.Viewer): super().__init__() tracks_viewer = TracksViewer.get_instance(viewer) + self.tracking_from_scratch = TrackingFromScratch(viewer) + layout = QVBoxLayout() + layout.addWidget(self.tracking_from_scratch) layout.addWidget(tracks_viewer.tracks_list) self.setLayout(layout) diff --git a/src/motile_tracker/application_menus/tracking_from_scratch_widget.py b/src/motile_tracker/application_menus/tracking_from_scratch_widget.py new file mode 100644 index 00000000..6cb0e2e1 --- /dev/null +++ b/src/motile_tracker/application_menus/tracking_from_scratch_widget.py @@ -0,0 +1,113 @@ +import napari +from funtracks.data_model import SolutionTracks +from funtracks.utils.tracksdata_utils import create_empty_graphview_graph +from napari.layers import Image, Labels +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from motile_tracker.application_menus.layer_dropdown import LayerDropdown +from motile_tracker.data_views.views_coordinator.tracks_viewer import TracksViewer + + +class TrackingFromScratch(QWidget): + """Widget to create an empty tracking tree (with either point or label tracks), to + track from scratch. The tree starts out without any nodes; nodes are added by + manually annotating in the track layers.""" + + def __init__(self, viewer: napari.Viewer): + super().__init__() + + self.viewer = viewer + self.tracks_viewer = TracksViewer.get_instance(viewer) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + exp = QLabel() + exp.setWordWrap(True) + exp.setTextFormat(Qt.MarkdownText) + exp.setText( + "*Create an empty tree with either point or label tracks. The array size is " + "taken from the selected Image or Labels layer.*" + ) + + create_box = QGroupBox("Create empty tracks") + create_layout = QVBoxLayout(create_box) + create_layout.addWidget(exp) + create_layout.addWidget(QLabel("Select an Image or Labels layer")) + # follow_active=False: the dropdown should only change when the user explicitly + # picks a layer from it, not when the active layer in the viewer changes. + self.size_layer_dropdown = LayerDropdown( + self.viewer, (Image, Labels), follow_active=False + ) + self.size_layer_dropdown.layer_changed.connect(self._update_buttons) + create_layout.addWidget(self.size_layer_dropdown) + + start_row = QHBoxLayout() + self.start_points_btn = QPushButton("Track with Points") + self.start_points_btn.clicked.connect(lambda: self._start_tracking("points")) + self.start_labels_btn = QPushButton("Track with Labels") + self.start_labels_btn.clicked.connect(lambda: self._start_tracking("labels")) + start_row.addWidget(self.start_points_btn) + start_row.addWidget(self.start_labels_btn) + create_layout.addLayout(start_row) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(create_box) + + self._update_buttons() + + def _update_buttons(self, *args) -> None: + """Enable the start buttons only when a size layer is selected.""" + + has_size = self.size_layer_dropdown.selected_layer is not None + self.start_points_btn.setEnabled(has_size) + self.start_labels_btn.setEnabled(has_size) + + def _start_tracking(self, mode: str) -> None: + """Create a new empty tree with empty TrackPoints/TrackGraph layers (and a + TrackLabels layer for ``mode == 'labels'``). The array size is taken from the + selected Image/Labels layer. + + Args: + mode (str): "points" to track with points, "labels" to track with a + segmentation. + """ + + layer = self.size_layer_dropdown.selected_layer + if layer is None: + return + + if mode == "labels": + # an empty segmentation-backed graph: registering the 'mask' attribute and + # the segmentation shape makes SolutionTracks expose an (empty) segmentation, + # so a TrackLabels layer is created and grows as labels are added. + graph = create_empty_graphview_graph( + node_attributes=["pos", "area", "mask", "bbox"], + position_attrs=["pos"], + ndim=layer.data.ndim, + ) + graph._update_metadata(segmentation_shape=layer.data.shape) + else: + graph = create_empty_graphview_graph( + node_attributes=["pos"], + position_attrs=["pos"], + ndim=layer.data.ndim, + ) + + tracks = SolutionTracks( + graph=graph, + scale=layer.scale, + ndim=layer.ndim, + time_attr="t", + pos_attr="pos", + ) + self.tracks_viewer.tracks_list.add_tracks(tracks, f"{layer.name}_manual_tracks") + self.tracks_viewer.set_new_track_id() diff --git a/src/motile_tracker/data_views/views/layers/track_graph.py b/src/motile_tracker/data_views/views/layers/track_graph.py index d6de855b..b391ce0f 100644 --- a/src/motile_tracker/data_views/views/layers/track_graph.py +++ b/src/motile_tracker/data_views/views/layers/track_graph.py @@ -13,6 +13,7 @@ from motile_tracker.data_views.views_coordinator.tracks_viewer import ( TracksViewer, ) +import polars as pl def update_napari_tracks( @@ -48,9 +49,14 @@ def update_napari_tracks( pos_keys = list(position_key) if isinstance(position_key, list) else [position_key] # One batch query instead of O(N) per-node queries - df = graph.node_attrs( - attr_keys=[DEFAULT_ATTR_KEYS.NODE_ID, time_key, tracklet_key] + pos_keys - ) + if len(graph.node_ids()) > 0: + df = graph.node_attrs( + attr_keys=[DEFAULT_ATTR_KEYS.NODE_ID, time_key, tracklet_key] + pos_keys + ) + else: + df = pl.DataFrame( + schema=[DEFAULT_ATTR_KEYS.NODE_ID, time_key, tracklet_key] + pos_keys + ) node_ids = df[DEFAULT_ATTR_KEYS.NODE_ID].to_list() track_ids_arr = df[tracklet_key].to_numpy() @@ -104,6 +110,11 @@ def __init__( self.tracks_viewer.tracks, ) + if len(track_data) == 0: + # a single dummy row is needed for the empty layer, but its column count + # must match the tracks dimensionality (id, t, [z], y, x). + track_data = np.zeros((1, track_data.shape[1]), dtype=float) + super().__init__( data=track_data, graph=track_edges, @@ -126,6 +137,12 @@ def _refresh(self): self.tracks_viewer.tracks, ) + if len(track_data) == 0: + # napari's Tracks layer cannot handle empty data (it indexes the first + # timepoint), so keep a single dummy row when the graph becomes empty + # (e.g. after undoing the very first action). Same as in __init__. + track_data = np.zeros((1, track_data.shape[1]), dtype=float) + self.data = track_data self.graph = track_edges self.tracks_layer_graph = copy.deepcopy(self.graph) diff --git a/src/motile_tracker/data_views/views/layers/track_points.py b/src/motile_tracker/data_views/views/layers/track_points.py index 21d70da6..72b5ac2c 100644 --- a/src/motile_tracker/data_views/views/layers/track_points.py +++ b/src/motile_tracker/data_views/views/layers/track_points.py @@ -67,7 +67,10 @@ def __init__( self.nodes = tracks_viewer.tracks.graph.node_ids() self.node_index_dict = {node: idx for idx, node in enumerate(self.nodes)} - points = self.tracks_viewer.tracks.get_positions(self.nodes, incl_time=True) + if len(self.nodes) > 0: + points = self.tracks_viewer.tracks.get_positions(self.nodes, incl_time=True) + else: + points = np.empty((0, self.tracks_viewer.tracks.ndim)) track_ids = self.tracks_viewer.tracks.get_track_ids(self.nodes) colors = self._map_track_colors(track_ids) @@ -247,13 +250,14 @@ def _update_data(self, event: Event): new_point = event.value[-1] attributes = self._create_node_attrs(new_point) try: - new_node_id = self.tracks_viewer.tracks._get_new_node_ids(1)[0] - UserAddNode( - self.tracks_viewer.tracks, - node=new_node_id, - attributes=attributes, - force=self.tracks_viewer.force, - ) + with self.tracks_viewer.center_node.blocked(): + new_node_id = self.tracks_viewer.tracks._get_new_node_ids(1)[0] + UserAddNode( + self.tracks_viewer.tracks, + node=new_node_id, + attributes=attributes, + force=self.tracks_viewer.force, + ) except InvalidActionError as e: if e.forceable: @@ -323,9 +327,14 @@ def _map_track_colors(self, track_ids: list[int]) -> np.ndarray: colormap.map has a large fixed per-call overhead (cache lookup, dtype, reshape), so mapping the whole array at once is ~290x faster than calling it per node (or even once per unique track id): for ~37k nodes / 142 unique ids, ~1ms vs ~300ms. + + With no nodes (an empty tracks graph, e.g. when tracking from scratch) a single + white color is returned instead of a (0, 4) array: napari's ColorManager treats + the color argument as *the* current color when the layer holds no data, and + feeding it an empty array raises in `transform_color`. """ if len(track_ids) == 0: - return np.empty((0, 4)) + return np.ones((1, 4)) return self.tracks_viewer.colormap.map(np.asarray(track_ids)) def get_symbols(self, tracks: Tracks, symbolmap: dict[NodeType, str]) -> list[str]: diff --git a/src/motile_tracker/data_views/views/layers/tracks_layer_group.py b/src/motile_tracker/data_views/views/layers/tracks_layer_group.py index 0fefbadd..4547d254 100644 --- a/src/motile_tracker/data_views/views/layers/tracks_layer_group.py +++ b/src/motile_tracker/data_views/views/layers/tracks_layer_group.py @@ -47,11 +47,7 @@ def set_tracks(self, tracks, name): else: self.seg_layer = None - if ( - self.tracks is not None - and self.tracks.graph is not None - and self.tracks.graph.num_nodes() != 0 - ): + if self.tracks is not None and self.tracks.graph is not None: self.tracks_layer = TrackGraph( name=self.name + "_tracks", tracks_viewer=self.tracks_viewer, diff --git a/src/motile_tracker/data_views/views/tree_view/tree_widget_utils.py b/src/motile_tracker/data_views/views/tree_view/tree_widget_utils.py index e849b5f8..cef77bb6 100644 --- a/src/motile_tracker/data_views/views/tree_view/tree_widget_utils.py +++ b/src/motile_tracker/data_views/views/tree_view/tree_widget_utils.py @@ -5,6 +5,7 @@ import napari.layers import numpy as np import pandas as pd +import polars as pl import tracksdata as td from funtracks.data_model import Tracks from tracksdata.constants import DEFAULT_ATTR_KEYS @@ -107,8 +108,10 @@ def extract_sorted_tracks( all_keys = list( {DEFAULT_ATTR_KEYS.NODE_ID, time_key, tracklet_key} | set(node_feature_keys) ) - df_attrs = solution_nx_graph.node_attrs(attr_keys=all_keys) - + if len(solution_nx_graph.node_ids()) != 0: + df_attrs = solution_nx_graph.node_attrs(attr_keys=all_keys) + else: + df_attrs = pl.DataFrame(schema=all_keys) node_ids_list = df_attrs[DEFAULT_ATTR_KEYS.NODE_ID].to_list() node_to_time = dict(zip(node_ids_list, df_attrs[time_key].to_list(), strict=True)) node_to_track_id = dict( diff --git a/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py b/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py index 4d9b23f2..6a698959 100644 --- a/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py +++ b/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py @@ -162,7 +162,10 @@ def set_new_track_id(self) -> None: the track id if the tracks.max_track_id value is used already.""" self.selected_track = self.tracks.max_track_id # to check if available - if self.selected_track in self.tracks.track_id_to_node: + if ( + self.selected_track in self.tracks.track_id_to_node + or self.selected_track == 0 + ): self.selected_track = self.tracks.get_next_track_id() self.set_track_id_color(self.selected_track) self.update_track_id.emit() diff --git a/tests/application_menus/test_layer_dropdown.py b/tests/application_menus/test_layer_dropdown.py new file mode 100644 index 00000000..5f0c0580 --- /dev/null +++ b/tests/application_menus/test_layer_dropdown.py @@ -0,0 +1,422 @@ +"""Tests for LayerDropdown, the combo box that mirrors the viewer's layer list. + +Uses ``napari.components.ViewerModel`` rather than ``make_napari_viewer``: the +dropdown only touches ``viewer.layers`` (its events and name lookup), so no Qt +window is needed, and a ViewerModel is much cheaper to build. A ``qapp`` is still +required because the dropdown itself is a QComboBox. +""" + +import numpy as np +import pytest +from napari.components import ViewerModel +from napari.layers import Image, Labels, Points + +from motile_tracker.application_menus.layer_dropdown import LayerDropdown + + +@pytest.fixture +def viewer(qapp): + """A headless viewer model (qapp requested so QWidgets can be constructed).""" + + return ViewerModel() + + +@pytest.fixture +def collect(): + """Return (emissions, connect) to record layer_changed emissions.""" + + def _collect(dropdown: LayerDropdown) -> list[str]: + emissions: list[str] = [] + dropdown.layer_changed.connect(emissions.append) + return emissions + + return _collect + + +def add_image(viewer, name: str, shape=(5, 10, 10)) -> Image: + return viewer.add_image(np.zeros(shape, dtype=np.uint16), name=name) + + +def add_labels(viewer, name: str, shape=(5, 10, 10)) -> Labels: + return viewer.add_labels(np.zeros(shape, dtype=np.uint16), name=name) + + +def items(dropdown: LayerDropdown) -> list[str]: + return [dropdown.itemText(i) for i in range(dropdown.count())] + + +def test_lists_only_matching_layer_types(viewer): + """Only layers of the requested types show up in the dropdown.""" + + add_image(viewer, "img") + add_labels(viewer, "seg") + viewer.add_points(np.zeros((2, 3)), name="pts") + + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + assert items(dropdown) == ["img"] + + dropdown = LayerDropdown(viewer, (Image, Points), follow_active=False) + assert set(items(dropdown)) == {"img", "pts"} + + +def test_exclude_types_are_left_out(viewer): + """exclude_types wins over layer_types for layers matching both.""" + + add_image(viewer, "img") + viewer.add_points(np.zeros((2, 3)), name="pts") + + dropdown = LayerDropdown( + viewer, (Image, Points), exclude_types=(Points,), follow_active=False + ) + assert items(dropdown) == ["img"] + + +def test_allow_none_adds_no_selection_entry(viewer, collect): + """'No selection' is listed first and maps to a None layer and an empty name.""" + + add_image(viewer, "img") + + dropdown = LayerDropdown(viewer, (Image,), allow_none=True, follow_active=False) + emissions = collect(dropdown) + + assert items(dropdown) == ["No selection", "img"] + assert dropdown.currentText() == "No selection" + assert dropdown.selected_layer is None + + dropdown.setCurrentText("img") + assert dropdown.selected_layer is viewer.layers["img"] + assert emissions == ["img"] + + dropdown.setCurrentText("No selection") + assert dropdown.selected_layer is None + assert emissions == ["img", ""] + + +def test_empty_viewer_selects_nothing(viewer, collect): + """With no matching layers there is nothing to select and nothing to emit.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + emissions = collect(dropdown) + + assert items(dropdown) == [] + assert dropdown.selected_layer is None + assert emissions == [] + + +def test_insert_adds_layer_and_registers_rename_callback(viewer, collect): + """A newly added matching layer is listed, selected, and watched for renames.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + emissions = collect(dropdown) + + layer = add_image(viewer, "img") + + assert items(dropdown) == ["img"] + assert dropdown.selected_layer is layer + assert emissions == ["img"] + assert id(layer) in dropdown._rename_callbacks + + +def test_insert_of_unlisted_type_is_ignored(viewer, collect): + """Adding a layer of an excluded type leaves the dropdown alone.""" + + add_image(viewer, "img") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + emissions = collect(dropdown) + + points = viewer.add_points(np.zeros((2, 3)), name="pts") + + assert items(dropdown) == ["img"] + assert dropdown.selected_layer is viewer.layers["img"] + assert emissions == [] + assert id(points) not in dropdown._rename_callbacks + + +def test_unrelated_insert_keeps_selection_without_re_emitting(viewer, collect): + """Rebuilding the list for an unrelated layer must not re-emit layer_changed. + + Regression guard: clear()/addItem() emit currentTextChanged for the transient + empty state, which used to report a None selection and tear down the connected + source layer just because another layer was added. + """ + + first = add_image(viewer, "img") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + emissions = collect(dropdown) + + add_image(viewer, "other") + + assert set(items(dropdown)) == {"img", "other"} + assert dropdown.currentText() == "img" + assert dropdown.selected_layer is first + assert emissions == [] + + +def test_rename_updates_dropdown(viewer, collect): + """Renaming a layer added after construction refreshes the entries.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + layer = add_image(viewer, "img") + emissions = collect(dropdown) + + layer.name = "renamed" + + assert items(dropdown) == ["renamed"] + assert dropdown.selected_layer is layer + assert emissions == ["renamed"] + + +def test_rename_of_pre_existing_layer_updates_dropdown(viewer): + """Layers loaded before the dropdown was built are watched for renames too. + + Regression guard: rename callbacks used to be hooked up only in _on_insert, so + renaming an image that was loaded before the menu left a stale dropdown entry. + """ + + layer = add_image(viewer, "img") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + assert id(layer) in dropdown._rename_callbacks + + layer.name = "renamed" + + assert items(dropdown) == ["renamed"] + assert dropdown.selected_layer is layer + + +def test_pre_existing_layers_of_other_types_are_not_watched(viewer): + """Only listed layers get a rename hook, whenever they were added.""" + + points = viewer.add_points(np.zeros((2, 3)), name="pts") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + + assert dropdown._rename_callbacks == {} + + points.name = "renamed" + assert items(dropdown) == [] + + +def test_set_layer_types_watches_newly_listed_layers(viewer): + """Layers that only become listed after set_layer_types are watched from then on.""" + + image = add_image(viewer, "img") + points = viewer.add_points(np.zeros((2, 3)), name="pts") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + assert id(points) not in dropdown._rename_callbacks + + dropdown.set_layer_types((Image, Points)) + + points.name = "renamed_pts" + assert set(items(dropdown)) == {"img", "renamed_pts"} + + # the already-watched image is not connected a second time + image.name = "renamed_img" + assert set(items(dropdown)) == {"renamed_img", "renamed_pts"} + assert len(dropdown._rename_callbacks) == 2 + + +def test_removed_layer_is_dropped_and_disconnected(viewer): + """Removing a layer removes its entry and its rename callback.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + layer = add_image(viewer, "img") + other = add_image(viewer, "other") + assert set(items(dropdown)) == {"img", "other"} + + viewer.layers.remove(layer) + + assert items(dropdown) == ["other"] + assert id(layer) not in dropdown._rename_callbacks + assert id(other) in dropdown._rename_callbacks + + # the disconnected layer no longer drives updates + layer.name = "renamed_after_removal" + assert items(dropdown) == ["other"] + + +def test_removing_the_selected_layer_moves_selection(viewer, collect): + """When the selected layer disappears, the dropdown falls back and re-emits.""" + + layer = add_image(viewer, "img") + add_image(viewer, "other") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + dropdown.setCurrentText("img") + emissions = collect(dropdown) + + viewer.layers.remove(layer) + + assert dropdown.currentText() == "other" + assert dropdown.selected_layer is viewer.layers["other"] + assert emissions == ["other"] + + +def test_follow_active_tracks_viewer_selection(viewer, collect): + """With follow_active, selecting a layer in the viewer moves the dropdown.""" + + first = add_image(viewer, "img") + second = add_image(viewer, "other") + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + viewer.layers.selection.active = first + emissions = collect(dropdown) + + viewer.layers.selection.active = second + assert dropdown.currentText() == "other" + assert dropdown.selected_layer is second + assert emissions[-1] == "other" + + viewer.layers.selection.active = first + assert dropdown.currentText() == "img" + assert dropdown.selected_layer is first + assert emissions[-1] == "img" + + +def test_follow_active_ignores_unlisted_and_multi_selection(viewer): + """Layers of another type, and multi-selections, leave the dropdown alone.""" + + image = add_image(viewer, "img") + other = add_image(viewer, "other") + points = viewer.add_points(np.zeros((2, 3)), name="pts") + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + viewer.layers.selection.active = image + assert dropdown.currentText() == "img" + + # a layer that is not listed does not steal the selection + viewer.layers.selection.active = points + assert dropdown.currentText() == "img" + assert dropdown.selected_layer is image + + # neither does selecting several layers at once + viewer.layers.selection.clear() + viewer.layers.selection.update({image, other}) + assert dropdown.currentText() == "img" + assert dropdown.selected_layer is image + + +def test_follow_active_false_ignores_viewer_selection(viewer, collect): + """Without follow_active the dropdown only changes when the user picks a layer.""" + + first = add_image(viewer, "img") + second = add_image(viewer, "other") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + dropdown.setCurrentText("img") + viewer.layers.selection.active = first + emissions = collect(dropdown) + + viewer.layers.selection.active = second + + assert dropdown.currentText() == "img" + assert dropdown.selected_layer is first + assert emissions == [] + + +def test_set_layer_types_refilters(viewer, collect): + """set_layer_types swaps both the included and the excluded types.""" + + add_image(viewer, "img") + viewer.add_points(np.zeros((2, 3)), name="pts") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + emissions = collect(dropdown) + assert items(dropdown) == ["img"] + + dropdown.set_layer_types((Points,)) + assert items(dropdown) == ["pts"] + assert dropdown.selected_layer is viewer.layers["pts"] + assert emissions == ["pts"] + + dropdown.set_layer_types((Image, Points), exclude_types=(Points,)) + assert items(dropdown) == ["img"] + assert dropdown.layer_types == (Image, Points) + assert dropdown.exclude_types == (Points,) + + +def test_destroy_disconnects_from_the_viewer(viewer, collect): + """After teardown the dropdown stops responding to viewer events.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + layer = add_image(viewer, "img") + emissions = collect(dropdown) + assert dropdown._rename_callbacks # a live rename hook to be cleaned up + + dropdown._on_destroyed() + + assert dropdown._deleted + assert dropdown._rename_callbacks == {} + + # none of the viewer events reach the dropdown anymore + second = add_image(viewer, "other") + viewer.layers.selection.active = second + layer.name = "renamed" + viewer.layers.remove(second) + + assert items(dropdown) == ["img"] + assert emissions == [] + + +def test_destroy_is_idempotent(viewer): + """Tearing down twice (e.g. explicit cleanup plus Qt destroyed) does not raise.""" + + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + add_image(viewer, "img") + + dropdown._on_destroyed() + dropdown._on_destroyed() + + assert dropdown._deleted + + +def test_handlers_are_inert_after_teardown(viewer, collect): + """Every handler bails out on a deleted widget, even if called directly. + + Qt can still deliver a queued event after ``destroyed``, so the ``_deleted`` + guards are the second line of defence behind disconnecting. + """ + + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + layer = add_image(viewer, "img") + emissions = collect(dropdown) + dropdown._on_destroyed() + + class _Event: + value = layer + + dropdown._on_insert(_Event()) + dropdown._on_removed(_Event()) + dropdown._on_selection_changed() + dropdown._update_dropdown() + dropdown._emit_layer_changed() + + assert items(dropdown) == ["img"] + assert dropdown._rename_callbacks == {} + assert emissions == [] + + +def test_rename_callback_is_a_noop_once_the_widget_is_gone(viewer): + """The rename callback holds only a weak reference and stays silent afterwards.""" + + add_image(viewer, "img") + dropdown = LayerDropdown(viewer, (Image,), follow_active=False) + callback = dropdown._make_weak_rename_cb() + + dropdown._deleted = True + callback() # must not raise, and must not touch the deleted widget + + assert items(dropdown) == ["img"] + + +def test_handlers_survive_a_broken_viewer(viewer): + """A viewer that is being torn down must not turn into an exception.""" + + add_image(viewer, "img") + dropdown = LayerDropdown(viewer, (Image,), follow_active=True) + + class BrokenViewer: + @property + def layers(self): + raise RuntimeError("C++ object deleted") + + dropdown.viewer = BrokenViewer() + + # all handlers swallow the teardown errors instead of propagating them + dropdown._update_dropdown() + dropdown._emit_layer_changed() + dropdown._on_selection_changed() + dropdown._on_destroyed() diff --git a/tests/application_menus/test_track_list_widget.py b/tests/application_menus/test_track_list_widget.py index fdad344c..26f8eeb9 100644 --- a/tests/application_menus/test_track_list_widget.py +++ b/tests/application_menus/test_track_list_widget.py @@ -9,4 +9,4 @@ def test_init_contains_tracks_list(self, make_napari_viewer): viewer = make_napari_viewer() widget = TrackListWidget(viewer) tracks_viewer = TracksViewer.get_instance(viewer) - assert widget.layout().itemAt(0).widget() is tracks_viewer.tracks_list + assert widget.layout().itemAt(1).widget() is tracks_viewer.tracks_list diff --git a/tests/application_menus/test_tracking_from_scratch_widget.py b/tests/application_menus/test_tracking_from_scratch_widget.py new file mode 100644 index 00000000..f72c589c --- /dev/null +++ b/tests/application_menus/test_tracking_from_scratch_widget.py @@ -0,0 +1,109 @@ +"""Tests for TrackingFromScratch - creating an empty tracking tree. + +The 'track from scratch' flow is the only place where a Tracks object is displayed +with *zero* nodes, so it exercises code paths (layer construction, colormaps, tree +view, table) that every other flow only ever sees with data in them. These tests +guard that empty-graph path. +""" + +import numpy as np +import pytest + +from motile_tracker.application_menus.track_list_widget import TrackListWidget +from motile_tracker.application_menus.tracking_from_scratch_widget import ( + TrackingFromScratch, +) +from motile_tracker.data_views.views.layers.track_labels import TrackLabels +from motile_tracker.data_views.views.layers.track_points import TrackPoints +from motile_tracker.data_views.views.table.custom_table_widget import ( + ColoredTableWidget, +) +from motile_tracker.data_views.views.tree_view.tree_widget import TreeWidget + + +@pytest.fixture +def scratch_app(make_napari_viewer): + """A viewer with a size layer, the from-scratch widget, and the two data views + that also have to cope with an empty graph (tree view and table).""" + + viewer = make_napari_viewer() + viewer.add_image(np.zeros((5, 10, 10), dtype=np.uint16), name="img") + widget = TrackingFromScratch(viewer) + table = ColoredTableWidget(viewer) + tree = TreeWidget(viewer) + return viewer, widget, table, tree + + +@pytest.mark.parametrize( + ("mode", "layer_type"), [("points", TrackPoints), ("labels", TrackLabels)] +) +def test_start_tracking_creates_empty_tracks(scratch_app, mode, layer_type): + """Creating empty tracks must build the track layers without raising. + + Regression guard: TrackPoints used to hand napari a (0, 4) face-color array for + an empty graph, which crashes in `transform_color` ('zero-size array to reduction + operation minimum'). + """ + + _viewer, widget, table, _tree = scratch_app + widget.size_layer_dropdown.setCurrentText("img") + widget._start_tracking(mode) + + tracks_viewer = widget.tracks_viewer + assert tracks_viewer.tracks is not None + assert tracks_viewer.tracks.graph.num_nodes() == 0 + + # the track layers exist and are empty + points_layer = tracks_viewer.tracking_layers.points_layer + assert isinstance(points_layer, TrackPoints) + assert len(points_layer.data) == 0 + if mode == "labels": + assert isinstance(tracks_viewer.tracking_layers.seg_layer, layer_type) + else: + assert tracks_viewer.tracking_layers.seg_layer is None + + # the table view survives an empty graph + assert table._model.rowCount() == 0 + assert table._id_to_row == {} + + # an empty tree still gets a valid (non-zero) tracklet id to annotate with + assert tracks_viewer.selected_track is not None + assert tracks_viewer.selected_track != 0 + + +def test_start_buttons_require_a_size_layer(make_napari_viewer): + """The start buttons are only enabled once an Image/Labels layer is selected.""" + + viewer = make_napari_viewer() + widget = TrackingFromScratch(viewer) + assert not widget.start_points_btn.isEnabled() + assert not widget.start_labels_btn.isEnabled() + + viewer.add_image(np.zeros((5, 10, 10), dtype=np.uint16), name="img") + widget.size_layer_dropdown.setCurrentText("img") + assert widget.start_points_btn.isEnabled() + assert widget.start_labels_btn.isEnabled() + + +def test_creating_a_second_tree_replaces_the_first(scratch_app): + """Creating another empty tree switches the tracks viewer over to it.""" + + _viewer, widget, _table, _tree = scratch_app + widget.size_layer_dropdown.setCurrentText("img") + + widget._start_tracking("labels") + first = widget.tracks_viewer.tracks + assert first.segmentation is not None + + widget._start_tracking("points") + second = widget.tracks_viewer.tracks + assert second is not first + assert second.segmentation is None + + +def test_track_list_widget_contains_from_scratch_widget(make_napari_viewer): + """The from-scratch controls live above the tracks list in the Tracks List tab.""" + + viewer = make_napari_viewer() + widget = TrackListWidget(viewer) + assert isinstance(widget.layout().itemAt(0).widget(), TrackingFromScratch)