Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4584036
implement a new widget to support tracking from scratch or from detec…
AnniekStok Jul 14, 2026
d1f26bc
Merge branch 'main' into track_from_scratch
AnniekStok Jul 27, 2026
c78c530
wip implementing manual tracking from detections by adding labels
AnniekStok Jul 29, 2026
18eb10b
wip connecting buttons and orthoviews to manual tracking
AnniekStok Jul 29, 2026
fbec472
put new track widget in its own widget class and also show when manua…
AnniekStok Jul 29, 2026
4e3e6a8
wip refactor to always allow copying of detections onto current Track…
AnniekStok Jul 29, 2026
45e2dfc
wip: always paint with the current tracklet ID when possible, use pre…
AnniekStok Jul 29, 2026
97916f1
suppress centering the view when copying objects from the source layer
AnniekStok Jul 29, 2026
3129872
make sure that tracks layer is never empty (e.g. when undoing the fir…
AnniekStok Jul 29, 2026
a208684
let user choose between two modes: copy either means start new trackl…
AnniekStok Jul 29, 2026
0486cf1
remove duplicate new tracklet widget, widget layout tbd
AnniekStok Jul 29, 2026
77f949f
block excessive dropdown menu signals when adding a new layer
AnniekStok Jul 29, 2026
5b78558
merge main and fix track_points layer to return a single white color …
AnniekStok Aug 4, 2026
daa252f
add claude tests
AnniekStok Aug 4, 2026
c0f7870
update layout
AnniekStok Aug 4, 2026
4960fc0
Merge branch 'main' into track_from_scratch
AnniekStok Aug 10, 2026
e85859d
Reduce tracking-from-scratch to creating an empty tree in the Tracks …
AnniekStok Aug 10, 2026
759b250
Merge branch 'main' into track_from_scratch
cmalinmayor Aug 18, 2026
50ff5b2
Merge branch 'main' into track_from_scratch
cmalinmayor Aug 18, 2026
ca1be30
block centering to point when adding a new point
AnniekStok Aug 19, 2026
6f1b8bc
ensure to track all layer names in the dropdown
AnniekStok Aug 19, 2026
ff7750c
test layer dropdown widget
AnniekStok Aug 19, 2026
71551ee
update docs
AnniekStok Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/source/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand Down
239 changes: 239 additions & 0 deletions src/motile_tracker/application_menus/layer_dropdown.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 8 additions & 1 deletion src/motile_tracker/application_menus/track_list_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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)
113 changes: 113 additions & 0 deletions src/motile_tracker/application_menus/tracking_from_scratch_widget.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading