Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom groups #112

Draft
wants to merge 31 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8f0b500
first implementation of custom, manually assigned groups of nodes, st…
AnniekStok Nov 7, 2024
2607082
implement an experimental sync button to adjust the tree widget conte…
AnniekStok Nov 8, 2024
8495740
clean up
AnniekStok Nov 8, 2024
5d7049b
fix sync update issue, and make sure the mode radio button is set to …
AnniekStok Nov 8, 2024
8d2244f
Merge branch 'main' of https://github.com/funkelab/motile_napari_plug…
AnniekStok Nov 8, 2024
ada38ed
implement a filter widget that highlights nodes fulfilling selection …
AnniekStok Nov 12, 2024
d28238c
clean up
AnniekStok Nov 12, 2024
610f87a
add node count to groups, fix error triggered by removing nodes from …
AnniekStok Nov 12, 2024
ff8cdd7
fix bug, can only set value widget after adding it to the layout
AnniekStok Nov 12, 2024
74f6d13
fix layout in filter widget
AnniekStok Nov 13, 2024
b6561a7
enforce updating the node count for all groups when loading back in
AnniekStok Nov 13, 2024
56633c0
Merge branch 'main' into custom_groups
AnniekStok Dec 2, 2024
a06210a
wip: use histograms for selecting a range of data when filtering. Use…
AnniekStok Dec 2, 2024
1370c63
only fire event when editing of the spinboxes is finished (not while …
AnniekStok Dec 3, 2024
75f3aee
display nodes that are not selected as contours, and nodes that are p…
AnniekStok Dec 9, 2024
18ecd56
connect the collection widget group update event to calling the label…
AnniekStok Dec 10, 2024
0452ebc
merge main and combine buttons in plot controls box
AnniekStok Dec 10, 2024
0e90a81
bug fix from merge;
AnniekStok Dec 10, 2024
c5b3187
make a separate label class that can show both contours and filled sh…
AnniekStok Dec 11, 2024
2f183a0
remove viewer from contour labels
AnniekStok Dec 11, 2024
1654155
bug fix: show_all_group -> show_group_radio
AnniekStok Dec 11, 2024
a3dbf50
use a set for the collection instead of a separate class, which has b…
AnniekStok Dec 11, 2024
5218db5
combine functions to add/remove nodes from a collection
AnniekStok Dec 11, 2024
b7c347a
get rid of for loops when setting tree view outlines
AnniekStok Dec 11, 2024
2c584c4
bugfix: remove _list from collection._list as it is a list already
AnniekStok Dec 12, 2024
dcb2067
ensure that deleted nodes are also deleted from the group in the coll…
AnniekStok Dec 18, 2024
9ec0304
add button to invert the selection
AnniekStok Dec 12, 2024
313788e
Merge branch 'main' into custom_groups
cmalinmayor Dec 18, 2024
47a3ab2
Update imports in collection and filter widgets to motile_tracker
cmalinmayor Dec 18, 2024
090abfc
Merge branch 'main' into custom_groups
cmalinmayor Dec 18, 2024
ad635cc
Merge branch 'main' into custom_groups
cmalinmayor Dec 18, 2024
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
2 changes: 2 additions & 0 deletions src/motile_tracker/application_menus/menu_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def __init__(self, viewer: napari.Viewer):
tabwidget.addTab(motile_widget, "Track with Motile")
tabwidget.addTab(editing_widget, "Edit Tracks")
tabwidget.addTab(tracks_viewer.tracks_list, "Results List")
tabwidget.addTab(tracks_viewer.collection_widget, "Collections")
tabwidget.addTab(tracks_viewer.filter_widget, "Filters")

layout = QVBoxLayout()
layout.addWidget(tabwidget)
Expand Down
121 changes: 121 additions & 0 deletions src/motile_tracker/data_views/views/layers/contour_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations

import functools
from typing import Optional

import napari
import numpy as np
from napari.layers.labels._labels_utils import (
expand_slice,
)
from napari.utils import DirectLabelColormap
from scipy import ndimage as ndi


def get_contours(
labels: np.ndarray,
thickness: int,
background_label: int,
group_labels: list[int] | None = None,
):
"""Computes the contours of a 2D label image.

Parameters
----------
labels : array of integers
An input labels image.
thickness : int
It controls the thickness of the inner boundaries. The outside thickness is always 1.
The final thickness of the contours will be `thickness + 1`.
background_label : int
That label is used to fill everything outside the boundaries.

Returns
-------
A new label image in which only the boundaries of the input image are kept.
"""
struct_elem = ndi.generate_binary_structure(labels.ndim, 1)

thick_struct_elem = ndi.iterate_structure(struct_elem, thickness).astype(bool)

dilated_labels = ndi.grey_dilation(labels, footprint=struct_elem)
eroded_labels = ndi.grey_erosion(labels, footprint=thick_struct_elem)
not_boundaries = dilated_labels == eroded_labels

contours = labels.copy()
contours[not_boundaries] = background_label

# instead of filling with background label, fill the group label with their normal color
if group_labels is not None and len(group_labels) > 0:
group_mask = functools.reduce(
np.logical_or, (labels == val for val in group_labels)
)
combined_mask = not_boundaries & group_mask
contours = np.where(combined_mask, labels, contours)

return contours


class ContourLabels(napari.layers.Labels):
"""Extended labels layer that allows to show contours and filled labels simultaneously"""

@property
def _type_string(self) -> str:
return "labels" # to make sure that the layer is treated as labels layer for saving

def __init__(
self,
data: np.array,
name: str,
opacity: float,
scale: tuple,
colormap: DirectLabelColormap,
):
super().__init__(
data=data,
name=name,
opacity=opacity,
scale=scale,
colormap=colormap,
)

self.group_labels = None

def _calculate_contour(
self, labels: np.ndarray, data_slice: tuple[slice, ...]
) -> Optional[np.ndarray]:
"""Calculate the contour of a given label array within the specified data slice.

Parameters
----------
labels : np.ndarray
The label array.
data_slice : Tuple[slice, ...]
The slice of the label array on which to calculate the contour.

Returns
-------
Optional[np.ndarray]
The calculated contour as a boolean mask array.
Returns None if the contour parameter is less than 1,
or if the label array has more than 2 dimensions.
"""
if self.contour < 1:
return None
if labels.ndim > 2:
return None

expanded_slice = expand_slice(data_slice, labels.shape, 1)
sliced_labels = get_contours(
labels[expanded_slice],
self.contour,
self.colormap.background_value,
self.group_labels,
)

# Remove the latest one-pixel border from the result
delta_slice = tuple(
slice(s1.start - s2.start, s1.stop - s2.start)
for s1, s2 in zip(data_slice, expanded_slice, strict=False)
)
return sliced_labels[delta_slice]
50 changes: 34 additions & 16 deletions src/motile_tracker/data_views/views/layers/track_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
if TYPE_CHECKING:
from motile_tracker.data_views.views_coordinator.tracks_viewer import TracksViewer

from .contour_labels import ContourLabels

class TrackLabels(napari.layers.Labels):

class TrackLabels(ContourLabels):
"""Extended labels layer that holds the track information and emits
and responds to dynamics visualization signals"""

Expand Down Expand Up @@ -52,6 +54,10 @@ def __init__(
)

self.viewer = viewer
self.viewer.dims.events.ndisplay.connect(
lambda: self.update_label_colormap(visible=None)
)
self.group_labels = None

# Key bindings (should be specified both on the viewer (in tracks_viewer)
# and on the layer to overwrite napari defaults)
Expand Down Expand Up @@ -223,10 +229,13 @@ def _refresh(self):

self.refresh()

def update_label_colormap(self, visible: list[int] | str) -> None:
def update_label_colormap(self, visible: list[int] | str | None = None) -> None:
"""Updates the opacity of the label colormap to highlight the selected label
and optionally hide cells not belonging to the current lineage"""

if visible is None:
visible = self.group_labels if self.group_labels is not None else "all"

highlighted = [
self.tracks_viewer.tracks.get_track_id(node)
for node in self.tracks_viewer.selected_nodes
Expand All @@ -240,30 +249,39 @@ def update_label_colormap(self, visible: list[int] | str) -> None:
] # set the first track_id to be the selected label color

# 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()
}

if visible == "all":
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()
}
self.contour = 0
self.group_labels = None

else:
self.colormap.color_dict = {
key: np.array([*value[:-1], 0], dtype=np.float32)
for key, value in self.colormap.color_dict.items()
}
for label in visible:
# find the index in the cyclic label colormap
self.colormap.color_dict[label][-1] = 0.6
if self.viewer.dims.ndisplay == 2:
self.contour = 1
self.group_labels = visible # for now we can not distinguish between individual nodes and tracks, but if we switch to unique labels this will be possible.

else:
self.colormap.color_dict = {
key: np.array([*value[:-1], 0], dtype=np.float32)
for key, value in self.colormap.color_dict.items()
}
for label in visible:
# find the index in the cyclic label colormap
self.colormap.color_dict[label][-1] = 0.6

for label in highlighted:
self.colormap.color_dict[label][-1] = 1 # full opacity

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):
"""Extended version of existing function, to emit refresh signal to also update colors in other layers/widgets"""
Expand Down
17 changes: 15 additions & 2 deletions src/motile_tracker/data_views/views/layers/track_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,11 @@ def get_symbols(self, tracks: Tracks, symbolmap: dict[NodeType, str]) -> list[st
symbols = [symbolmap[statemap[degree]] for _, degree in tracks.graph.out_degree]
return symbols

def update_point_outline(self, visible: list[int] | str) -> None:
def update_point_visibility(self, visible: list[int] | str) -> None:
"""Update the outline color of the selected points and visibility according to display mode

Args:
visible (list[int] | str): A list of track ids, or "all"
visible (list[int] | str | None): A list of track ids, "all"
"""
# filter out the non-selected tracks if in lineage mode
if visible == "all":
Expand All @@ -217,8 +217,21 @@ def update_point_outline(self, visible: list[int] | str) -> None:
self.shown[:] = False
self.shown[indices] = True

self.update_point_outline()

def update_point_outline(self) -> None:
# set border color for selected item
self.border_color = [1, 1, 1, 1]

for node in self.tracks_viewer.filtered_nodes:
index = self.node_index_dict[node]
self.border_color[index] = (
self.tracks_viewer.filter_color[0],
self.tracks_viewer.filter_color[1],
self.tracks_viewer.filter_color[2],
1,
)

self.size = self.default_size
for node in self.tracks_viewer.selected_nodes:
index = self.node_index_dict[node]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def update_visible(self, visible: list[int]):
if self.seg_layer is not None:
self.seg_layer.update_label_colormap(visible)
if self.points_layer is not None:
self.points_layer.update_point_outline(visible)
self.points_layer.update_point_visibility(visible)
if self.tracks_layer is not None:
self.tracks_layer.update_track_visibility(visible)

Expand Down
14 changes: 4 additions & 10 deletions src/motile_tracker/data_views/views/tree_view/flip_axes_widget.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from psygnal import Signal
from qtpy.QtWidgets import QGroupBox, QPushButton, QVBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget


class FlipTreeWidget(QWidget):
Expand All @@ -10,18 +10,12 @@ class FlipTreeWidget(QWidget):
def __init__(self):
super().__init__()

flip_layout = QVBoxLayout()
display_box = QGroupBox("Plot axes [F]")
flip_button = QPushButton("Flip")
flip_layout = QHBoxLayout()
flip_button = QPushButton("Flip axes [F]")
flip_button.clicked.connect(self.flip)
flip_layout.addWidget(flip_button)
display_box.setLayout(flip_layout)

layout = QVBoxLayout()
layout.addWidget(display_box)
self.setLayout(layout)
display_box.setMaximumWidth(90)
display_box.setMaximumHeight(82)
self.setLayout(flip_layout)

def flip(self):
"""Send a signal to flip the axes of the plot"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ def __init__(self):
display_box = QGroupBox("Display [Q]")
display_layout = QHBoxLayout()
button_group = QButtonGroup()
self.show_all_radio = QRadioButton("All cells")
self.show_all_radio = QRadioButton("All")
self.show_all_radio.setChecked(True)
self.show_all_radio.clicked.connect(lambda: self._set_mode("all"))
self.show_lineage_radio = QRadioButton("Current lineage(s)")
self.show_lineage_radio = QRadioButton("Selected lineage(s)")
self.show_lineage_radio.clicked.connect(lambda: self._set_mode("lineage"))
self.show_group_radio = QRadioButton("Group")
self.show_group_radio.clicked.connect(lambda: self._set_mode("group"))
button_group.addButton(self.show_all_radio)
button_group.addButton(self.show_lineage_radio)
display_layout.addWidget(self.show_all_radio)
display_layout.addWidget(self.show_lineage_radio)
display_layout.addWidget(self.show_group_radio)
display_box.setLayout(display_layout)
display_box.setMaximumWidth(250)
display_box.setMaximumWidth(300)
display_box.setMaximumHeight(60)

layout = QVBoxLayout()
Expand All @@ -44,6 +47,9 @@ def _toggle_display_mode(self, event=None) -> None:
"""Toggle display mode"""

if self.mode == "lineage":
self._set_mode("group")
self.show_group_radio.setChecked(True)
elif self.mode == "group":
self._set_mode("all")
self.show_all_radio.setChecked(True)
else:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from qtpy.QtWidgets import (
QHBoxLayout,
QPushButton,
QWidget,
)


class SyncWidget(QWidget):
"""Widget to switch between viewing all nodes versus nodes of one or more lineages in the tree widget"""

def __init__(self):
super().__init__()

sync_layout = QHBoxLayout()
self.sync_button = SyncButton()
sync_layout.addWidget(self.sync_button)
self.setLayout(sync_layout)


class SyncButton(QPushButton):
def __init__(self):
super().__init__()

self.setCheckable(True)
self.setText("Sync Views 🔗") # Initial icon as Unicode and text
self.clicked.connect(self.toggle_state)
self.setFixedHeight(25)
self.setFixedWidth(100)

def toggle_state(self):
"""Set text and icon depending on toggle state"""

if self.isChecked():
self.setText("Stop sync ❌") # Replace with your chosen broken link symbol
else:
self.setText("Sync views 🔗 ")
Loading
Loading