Source code for movement.napari.layer_wiring

"""Layer callbacks that must outlive the widget connecting them.

Put a callback here if it should be active as long as the viewer
or a layer exists (rather than sharing lifetime with a widget that
can be closed).

- Typical candidates are callbacks that mutate layer state (data, properties,
  symbols, ``editable``): that state is part of the user's work and may be
  saved to a file later, so it must survive the widget being closed.
- A callback that only refreshes a widget's own UI (a dropdown, a table, a
  button) can stay a method on that widget: once the widget is gone there is
  nothing left to update, which is fine.

"""

import warnings
from functools import partial
from weakref import WeakSet

import numpy as np
from napari.components.dims import RangeTuple
from napari.layers import Points
from napari.layers.base import ActionType

from movement.napari.layer_styles import EDITED_POINT_SYMBOL

# Metadata keys stored on the movement Points layer.
# - POINTS_LAYER_KEY marks the layer as movement-created.
# - POINTS_PROPERTIES_KEY holds the full properties df, incl. the NaN rows
#   dropped from the live layer, needed to reconstruct the dataset.
# - DATASET_ATTRS_KEY holds the source dataset's attrs (source_software, fps…).
# - TRACKS_LAYER_KEY holds a reference to the companion Tracks layer.
# - MAX_FRAME_IDX_KEY holds the last frame index of the source data,
#   including leading/trailing all-NaN frames (which are dropped from the
#   napari layer data array).
POINTS_LAYER_KEY: str = "movement_points_layer"
POINTS_PROPERTIES_KEY: str = "movement_points_properties"
DATASET_ATTRS_KEY: str = "movement_dataset_attrs"
TRACKS_LAYER_KEY: str = "movement_tracks_layer"
MAX_FRAME_IDX_KEY: str = "movement_max_frame_idx"

# Keep a set of viewers already wired by connect_viewer_callbacks,
# so we don't wire them twice. We use a WeakSet so tracking a viewer here
# does not prevent it from being garbage-collected when it is no longer used.
_WIRED_VIEWERS: WeakSet = WeakSet()


# ---- Layer helpers -------------------------------------
[docs] def is_movement_points_layer(layer) -> bool: """Return ``True`` for a movement-loaded napari Points layer.""" layer = getattr(layer, "__wrapped__", layer) return isinstance(layer, Points) and bool( layer.metadata.get(POINTS_LAYER_KEY) )
[docs] def active_movement_points_layer(viewer): """Return the active movement Points layer, else the last one, else None. Prefers napari's active selection, but falls back to scanning the layer list so a Points layer is still found when the active selection is something else or unset. """ active = viewer.layers.selection.active if is_movement_points_layer(active): return getattr(active, "__wrapped__", active) for layer in reversed(viewer.layers): if is_movement_points_layer(layer): return getattr(layer, "__wrapped__", layer) return None
# ---- Callbacks with viewer lifetime --------------------
[docs] def connect_viewer_callbacks(viewer) -> None: """Wire the layer callbacks to a viewer, skipping if already wired. These wirings last as long as the viewer, no matter which widget requested them. """ # Check if viewer has been wired by this function viewer = getattr(viewer, "__wrapped__", viewer) if viewer in _WIRED_VIEWERS: return # Connect relevant layer callbacks to the viewer. # Connect frame slider range update to layer events for action in ( "inserted", "removed", ): getattr(viewer.layers.events, action).connect( partial(update_frame_slider_range, viewer) ) # Point drags are only guaranteed to stay within their own frame # when frame is the sliced (non-displayed) axis in a 2D view. If # axes are rolled or a 3D view is used in the viewer, disable editing # rather than risk a drag moving a point onto a different frame. for event in ( viewer.dims.events.order, viewer.dims.events.ndisplay, ): event.connect(partial(update_points_layers_editable, viewer)) # Update set _WIRED_VIEWERS.add(viewer)
[docs] def update_frame_slider_range(viewer, event=None): """Widen the frame slider range to cover NaN-trimmed frames. napari derives ``viewer.dims.range`` from the world-coordinate union of all layer extents, and it does so before this callback runs. Movement's layers have their leading/trailing all-NaN rows dropped, so a dataset that starts or ends with NaNs yields an extent — and therefore a slider range — narrower than the real frame span. Extend napari's range to cover the true span of every movement layer, but never replace it: layers movement did not create (a video the user opened, another plugin's layer) keep the range napari computed for them, including any scale or translate they carry. """ max_frame_indices = [ ly.metadata[MAX_FRAME_IDX_KEY] for ly in viewer.layers if MAX_FRAME_IDX_KEY in getattr(ly, "metadata", {}) ] if not max_frame_indices: return current_range = viewer.dims.range[0] start = min(current_range.start, 0.0) stop = max(current_range.stop, *max_frame_indices) if (start, stop) != (current_range.start, current_range.stop): viewer.dims.range = ( RangeTuple(start=start, stop=stop, step=current_range.step), ) + viewer.dims.range[1:]
[docs] def frame_axis_is_sliced(viewer) -> bool: """Determine whether the frame axis is the sliced axis in a 2D view.""" return viewer.dims.ndisplay == 2 and viewer.dims.order[0] == 0
[docs] def update_points_layers_editable(viewer, event=None): """Disable point editing while the frame axis isn't sliced. Connected to ``viewer.dims.events.order``/``ndisplay``. It disables editing on every movement Points layer if the viewer axes are rolled or switched to 3D; napari greys out the select/add/delete controls. """ is_editable = frame_axis_is_sliced(viewer) for layer in viewer.layers: if is_movement_points_layer(layer): layer.editable = is_editable
# ---- Callbacks with layer lifetime --------------------
[docs] def set_point_symbol_by_edited(layer: Points) -> None: """Show points flagged as edited with a distinct marker symbol.""" edited = layer.properties.get("edited") if edited is None or not edited.any(): return symbols = np.asarray(layer.symbol).copy() symbols[edited] = EDITED_POINT_SYMBOL layer.symbol = symbols
[docs] def on_points_data_changed(event): """Keep the corresponding Tracks layer in sync with the Points layer. Connected to ``points_layer.events.data``. Handles two actions: - ``ActionType.CHANGED`` (a point was dragged): sets the confidence score of moved points to NaN, marks them as edited, and changes their marker symbol to ``EDITED_POINT_SYMBOL`` so edited points are visually distinguishable. The Tracks layer row is updated in place via `sync_tracks_layer`. - ``ActionType.REMOVED`` (one or more points were deleted): removes the corresponding rows from the Tracks layer via `remove_from_tracks_layer`. """ layer = event.source if not isinstance(layer, Points): return if event.action == ActionType.CHANGED: moved_indices = list(event.data_indices) props = layer.properties props["confidence"] = props["confidence"].copy() props["confidence"][moved_indices] = float("nan") if "edited" in props: props["edited"] = props["edited"].copy() else: props["edited"] = np.full(len(props["confidence"]), False) props["edited"][moved_indices] = True layer.properties = props set_point_symbol_by_edited(layer) sync_tracks_layer(layer, moved_indices) elif event.action == ActionType.REMOVED: removed_indices = list(event.data_indices) remove_from_tracks_layer(layer, removed_indices)
[docs] def sync_tracks_layer(points_layer, moved_indices): """Update the corresponding Tracks layer to match an edited point. A moved point's new (frame, y, x) is written to the same row in the Tracks layer, so the track segment connecting the previous frame to this one terminates at the dragged position. """ tracks_layer = points_layer.metadata[TRACKS_LAYER_KEY] # Points and Tracks layers are built from the same NaN-filtered # array in the same row order (see _add_points_layer/ # _add_tracks_layer). The Tracks layer only has an extra # leading track_id column. tracks_data = tracks_layer.data tracks_data[moved_indices, 1:] = points_layer.data[moved_indices] set_tracks_layer_data(tracks_layer, tracks_data, tracks_layer.properties)
[docs] def remove_from_tracks_layer(points_layer, removed_indices): """Remove the rows corresponding to deleted points. Users edit the Points layer directly, either dragging points or removing inaccurate predictions. The Tracks layer has no interactive editing of its own, so it must be kept in sync with the Points layer instead. ``removed_indices`` are indices in the Points layer which line up with rows in the Tracks layer, the same way :func:`sync_tracks_layer` relies on for edits. """ tracks_layer = points_layer.metadata[TRACKS_LAYER_KEY] tracks_data = np.delete(tracks_layer.data, removed_indices, axis=0) tracks_properties = { key: np.delete(np.asarray(values), removed_indices, axis=0) for key, values in tracks_layer.properties.items() } set_tracks_layer_data(tracks_layer, tracks_data, tracks_properties)
[docs] def set_tracks_layer_data(tracks_layer, data, properties): """Set a Tracks layer's data and properties, preserving color_by. Setting ``.data`` on a napari Tracks layer resets its internal features to empty, which transiently invalidates ``color_by`` (napari warns and falls back to "track_id") even though we restore the same properties and colour-by property right after. Suppress that spurious warning around the sequence. """ color_by = tracks_layer.color_by with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=".*Previous color_by key.*", category=UserWarning, ) tracks_layer.data = data tracks_layer.properties = properties tracks_layer.color_by = color_by