Skip to content

Commit 6f2a63c

Browse files
Refactor project
1 parent 7e9e5a5 commit 6f2a63c

7 files changed

Lines changed: 605 additions & 320 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,58 @@
33

44
[demo.webm](https://github.com/user-attachments/assets/d44aa86c-7976-4240-91bd-ca194762d038)
55

6-
This is an extended version of Napari's built-in [features table widget](https://napari.org/0.6.3/gallery/features_table_widget.html), with extra options to:
6+
This plugin is similar to Napari's built-in [features table widget](https://napari.org/0.6.3/gallery/features_table_widget.html), but optimized for usage with **2D and 3D `Labels` layers**. It offers extra options to:
77

8-
- Sort table values
9-
- Display a subset of table columns
10-
- Colorize `Labels` based on feature values
8+
- Sort table values.
9+
- Hide or show table columns.
10+
- Colorize `Labels` based on feature values, with different color maps.
11+
- Connect your own callback functions to customize what happens when users click on a table row.
12+
- Connect your own featurizer functions that run automatically when the data in the `Labels` changes and update the displayed table.
1113

12-
The table is essentially a graphical view of the `features` attribute of a layer.
13-
14-
The table is optimized for usage with **2D and 3D `Labels` layers**. It can probably be used to display features from other layer types as well, but this hasn't been tested.
14+
The content of the table is a graphical view of the `features` attribute of a `Labels` layer.
1515

1616
## Displaying the table
1717

1818
Open the table from `Plugins > Features Table` in Napari.
1919

2020
The table displays features from the **currently selected layer** in the layers list. It will automatically update when the layer selection changes. If multiple layers are selected, only features from the first selected layer will be displayed.
2121

22-
## Computing custom features
22+
## Connecting featurizer functions
2323

2424
By default, the table displays a `label` column for the selected `Labels` layer, along with any pre-existing features that can be matched with the `label` column (they should be in Pandas DataFrame format with at least a 'label' column).
2525

26-
It is possible to **compute features automatically** when a new Labels layer is selected, based on a provided featurizer function. Featurizer functions will receive as input the labels layer data as a Numpy array, and should return a Pandas DataFrame with at least a `label` column, along with any other feature columns.
26+
You can customize the behaviour of the plugin to **update features automatically** when a new Labels layer is selected, or when its data changes, based on a featurizer function that you provide. Featurizer functions will receive as input the labels layer data as a Numpy array, and should return a Pandas DataFrame with at least a `label` column, along with any other feature columns.
2727

28-
For example, the following code extends the behaviour of the table to compute and display the `area` (or volume) of a labels layer:
28+
For example, the following code extends the behaviour of the table to display the `area` (or volume) of objects:
2929

3030
```python
31+
import napari
3132
import pandas as pd
3233
from skimage.measure import regionprops_table
34+
from napari_label_focus import ConfigurableFeaturesTableWidget
3335

36+
# Write an "area" featurizer
3437
def area_featurizer(labels: np.ndarray) -> pd.DataFrame:
3538
return pd.DataFrame(regionprops_table(labels, properties=["label", "area"]))
3639

3740
if __name__ == "__main__":
38-
import napari
39-
from napari_label_focus import ConfigurableFeaturesTableWidget
4041
viewer = napari.Viewer()
41-
widget = ConfigurableFeaturesTableWidget(viewer, featurizers=[area_featurizer])
42+
widget = ConfigurableFeaturesTableWidget(viewer, featurizer_functs=[area_featurizer])
4243
viewer.window.add_dock_widget(widget)
4344
napari.run()
4445
```
4546

46-
If more than one featurizer is provided, featurizers will be run one by one and the results from each will be merged into a single features DataFrame.
47+
If more than one featurizer function is provided, these functions will be run one by one and the results will be merged into a single features DataFrame.
4748

4849
## Controlling what clicking on a table row does
4950

5051
By default, clicking on a table row selects the corresponding label in the `Labels` layer. This behaviour can be extended by adding callback functions to the *table_click_callbacks* parameter of the table widget. The callback functions receive a selection context object with references to the viewer, selected layer, selected table row, and the table itself.
5152

52-
The following example illustrates how this works:
53+
For example:
5354

5455
```python
55-
from napari_label_focus._context import SelectionContext
56+
import napari
57+
from napari_label_focus import ConfigurableFeaturesTableWidget, SelectionContext
5658

5759
def print_selection_context(ctx: SelectionContext):
5860
print(f"Napari viewer: {ctx.viewer}")
@@ -61,8 +63,6 @@ def print_selection_context(ctx: SelectionContext):
6163
print(f"Features table: {ctx.features_table}")
6264

6365
if __name__ == "__main__":
64-
import napari
65-
from napari_label_focus import ConfigurableFeaturesTableWidget
6666
viewer = napari.Viewer()
6767
widget = ConfigurableFeaturesTableWidget(viewer, table_click_callbacks=[print_selection_context])
6868
viewer.window.add_dock_widget(widget)

‎src/napari_label_focus/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from ._widget import ConfigurableFeaturesTableWidget
2+
from ._context import SelectionContext
23

3-
__all__ = ("ConfigurableFeaturesTableWidget",)
4+
__all__ = ("ConfigurableFeaturesTableWidget", "SelectionContext")
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from typing import Callable, List, Optional
2+
3+
import napari.layers
4+
import numpy as np
5+
import pandas as pd
6+
import skimage.measure
7+
from napari.utils.notifications import show_warning
8+
9+
from napari_label_focus._utils import sanitize_layer_features
10+
11+
12+
def default_featurizer(labels: np.ndarray) -> pd.DataFrame:
13+
return pd.DataFrame(skimage.measure.regionprops_table(labels, properties=["label"]))
14+
15+
16+
class Featurizer:
17+
def __init__(
18+
self,
19+
featurizer_functs: Optional[List[Callable[[np.ndarray], pd.DataFrame]]] = None,
20+
) -> None:
21+
self.featurizer_functs: List = [default_featurizer]
22+
if isinstance(featurizer_functs, List):
23+
self.featurizer_functs.extend(featurizer_functs)
24+
25+
def recompute_features(
26+
self, labels_layer: napari.layers.Layer
27+
) -> Optional[pd.DataFrame]:
28+
"""
29+
Performs checks to sanitize a labels layer and its data, then runs featurizer functions and updates layer features.
30+
"""
31+
if not isinstance(labels_layer, napari.layers.Labels):
32+
return
33+
34+
# Sanitize labels layer data
35+
labels = labels_layer.data
36+
37+
if not isinstance(labels, np.ndarray):
38+
show_warning("Labels data should be a Numpy array")
39+
return
40+
41+
# Sanitize labels layer features
42+
features_df = sanitize_layer_features(labels_layer)
43+
44+
# Compute the "default featurizer"
45+
merged_df = default_featurizer(labels)
46+
47+
# Merge it with existing features
48+
if "label" in features_df.columns:
49+
merged_df = self._merge_incoming_df(features_df, merged_df)
50+
51+
# Compute extra feature dataframes and merge them into the layer features
52+
for featurizer_func in self.featurizer_functs:
53+
df_incoming = featurizer_func(labels)
54+
55+
if "label" not in df_incoming.columns:
56+
show_warning(
57+
"Featurizer should output a dataframe with a 'label' column"
58+
)
59+
continue
60+
61+
merged_df = self._merge_incoming_df(df_incoming, merged_df)
62+
63+
# Update the layer features
64+
labels_layer.features = merged_df
65+
66+
def _merge_incoming_df(
67+
self, df_incoming: pd.DataFrame, df_existing: pd.DataFrame
68+
) -> pd.DataFrame:
69+
# Shared columns that are not "label"
70+
shared_cols = df_incoming.columns.intersection(df_existing.columns).difference(
71+
["label"]
72+
)
73+
74+
# Left merge to keep only label rows present in the incoming features
75+
df_merged = df_incoming.merge(
76+
df_existing, on="label", how="left", suffixes=("_incoming", "_existing")
77+
)
78+
79+
# Overwrite existing values with incoming values
80+
for col in shared_cols:
81+
df_merged[col] = df_merged[f"{col}_incoming"]
82+
83+
# Drop all suffixed columns
84+
cols_to_drop = [f"{col}_incoming" for col in shared_cols] + [
85+
f"{col}_existing" for col in shared_cols
86+
]
87+
df_merged = df_merged.drop(columns=cols_to_drop)
88+
89+
return df_merged
90+
91+
92+
class FeaturizerWidget:
93+
def __init__(
94+
self,
95+
napari_viewer: napari.Viewer,
96+
featurizer_functs: Optional[List[Callable[[np.ndarray], pd.DataFrame]]] = None,
97+
):
98+
self.viewer = napari_viewer
99+
100+
self.featurizer_functs = Featurizer(featurizer_functs=featurizer_functs)
101+
102+
# Keep track of the selected layer
103+
self.selected_layer = None
104+
105+
# Keep track of layers selected at least once to avoid recomputing their features on layer change
106+
self.seen_layers = []
107+
108+
# Layer events
109+
self.viewer.layers.selection.events.changed.connect(self._selection_changed)
110+
self.viewer.layers.events.inserted.connect(
111+
lambda e: self._selection_changed(None)
112+
)
113+
self._selection_changed(None)
114+
115+
def _selection_changed(self, event=None):
116+
if event is None:
117+
layer = self.viewer.layers.selection.active
118+
else:
119+
layer = event.source.active
120+
121+
if not isinstance(layer, napari.layers.Layer):
122+
return
123+
124+
# Connect the "paint" and "data change" events to recomputing features
125+
if isinstance(self.selected_layer, napari.layers.Labels):
126+
self.selected_layer.events.paint.disconnect(self._recompute)
127+
self.selected_layer.events.data.disconnect(self._recompute)
128+
129+
if isinstance(layer, napari.layers.Labels):
130+
layer.events.data.connect(self._recompute)
131+
layer.events.paint.connect(self._recompute)
132+
133+
self.selected_layer = layer
134+
135+
# Only compute features on selection change when the layer is seen for the first time
136+
if layer not in self.seen_layers:
137+
self.featurizer_functs.recompute_features(layer)
138+
139+
def _recompute(self, event):
140+
layer = event.sources[0]
141+
142+
self.featurizer_functs.recompute_features(layer)

‎src/napari_label_focus/_featurizers.py‎

Lines changed: 0 additions & 7 deletions
This file was deleted.

‎src/napari_label_focus/_utils.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from collections import defaultdict
2+
import napari.layers
3+
import numpy as np
4+
import pandas as pd
5+
import matplotlib.pyplot as plt
6+
from napari.utils import DirectLabelColormap
7+
from napari.utils.notifications import show_warning
8+
9+
10+
def sanitize_layer_features(layer: napari.layers.Layer) -> pd.DataFrame:
11+
"""Converts existing layer features to a Pandas DataFrame (empty if features are non-existent, or incompatible with the plugin)."""
12+
if not hasattr(layer, "features"):
13+
return pd.DataFrame()
14+
else:
15+
features = getattr(layer, "features")
16+
if isinstance(features, pd.DataFrame):
17+
return features
18+
else:
19+
show_warning(
20+
f"Layer `{layer.name}` contains features that aren't a pandas DataFrame (not displayable in the features table widget)."
21+
)
22+
return pd.DataFrame()
23+
24+
25+
def color_labels_layer_by_values(
26+
layer: napari.layers.Labels,
27+
features_df: pd.DataFrame,
28+
color_by: str,
29+
colormap: str = "inferno",
30+
):
31+
"""Colorize a napari Labels layer based on the values of a column in a features dataframe."""
32+
# Drop the NaNs
33+
features_df_filtered = features_df.dropna(axis=0)
34+
if len(features_df_filtered) == 0:
35+
return
36+
37+
# Check that the `label` column is present
38+
if "label" not in features_df_filtered.columns:
39+
raise ValueError("Features dataframe does not have a `label` column.")
40+
label_values = features_df_filtered["label"].values
41+
42+
# Check the values to plot from the dataframe
43+
numeric_cols = features_df_filtered.select_dtypes(include=[np.number]).columns
44+
if color_by not in numeric_cols:
45+
raise ValueError(
46+
f"Column {color_by} is either not present or non-numeric (cannot be plotted)."
47+
)
48+
49+
plot_vals = features_df_filtered[color_by].values
50+
if not isinstance(plot_vals, np.ndarray):
51+
raise ValueError(
52+
f"Values in column {color_by} are not a NumPy array (cannot be plotted)"
53+
)
54+
55+
# Rescale values to [0-1] for colormapping
56+
min_vals = np.min(plot_vals)
57+
max_vals = np.max(plot_vals)
58+
val_range = max_vals - min_vals
59+
if val_range > 0:
60+
relative_vals = (plot_vals - min_vals) / val_range
61+
else:
62+
relative_vals = (plot_vals - min_vals) + 1
63+
64+
# Plot in `inferno` colour
65+
cmap = plt.get_cmap(colormap)
66+
rgba = cmap(relative_vals)
67+
68+
color_dict = defaultdict(lambda: np.zeros(4))
69+
for lab, color in zip(label_values, rgba):
70+
color_dict[lab] = color
71+
72+
layer.events.selected_label.block()
73+
layer.colormap = DirectLabelColormap(color_dict=color_dict)
74+
layer.events.selected_label.unblock()
75+
layer.refresh()

0 commit comments

Comments
 (0)