Skip to content

Commit dc1f3bb

Browse files
authored
Merge pull request #148 from jo-mueller/add-feature-histogram
added a Feature Histogram Widget
2 parents 3a8261a + e1ccfb1 commit dc1f3bb

10 files changed

+231
-12
lines changed

baseline/test_feature_histogram2.png

12.6 KB
Loading

docs/changelog.rst

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
Changelog
22
=========
3-
1.0.3
3+
1.1.0
44
-----
5+
Additions
6+
~~~~~~~~~
7+
- Added a widget to draw a histogram of features.
8+
59
Changes
610
~~~~~~~
711
- The slice widget is now limited to slicing along the x/y dimensions. Support

docs/user_guide.rst

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ These widgets plot the data stored in the ``.features`` attribute of individual
3030
Currently available are:
3131

3232
- 2D scatter plots of two features against each other.
33+
- Histograms of individual features.
3334

3435
To use these:
3536

src/napari_matplotlib/features.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from napari.layers import Labels, Points, Shapes, Tracks, Vectors
2+
3+
FEATURES_LAYER_TYPES = (
4+
Labels,
5+
Points,
6+
Shapes,
7+
Tracks,
8+
Vectors,
9+
)

src/napari_matplotlib/histogram.py

+114-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
from typing import Optional
1+
from typing import Any, List, Optional, Tuple
22

33
import napari
44
import numpy as np
5-
from qtpy.QtWidgets import QWidget
5+
import numpy.typing as npt
6+
from qtpy.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget
67

78
from .base import SingleAxesWidget
9+
from .features import FEATURES_LAYER_TYPES
810
from .util import Interval
911

10-
__all__ = ["HistogramWidget"]
12+
__all__ = ["HistogramWidget", "FeaturesHistogramWidget"]
1113

1214
_COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}
1315

@@ -61,3 +63,112 @@ def draw(self) -> None:
6163
self.axes.hist(data.ravel(), bins=bins, label=layer.name)
6264

6365
self.axes.legend()
66+
67+
68+
class FeaturesHistogramWidget(SingleAxesWidget):
69+
"""
70+
Display a histogram of selected feature attached to selected layer.
71+
"""
72+
73+
n_layers_input = Interval(1, 1)
74+
# All layers that have a .features attributes
75+
input_layer_types = FEATURES_LAYER_TYPES
76+
77+
def __init__(
78+
self,
79+
napari_viewer: napari.viewer.Viewer,
80+
parent: Optional[QWidget] = None,
81+
):
82+
super().__init__(napari_viewer, parent=parent)
83+
84+
self.layout().addLayout(QVBoxLayout())
85+
self._key_selection_widget = QComboBox()
86+
self.layout().addWidget(QLabel("Key:"))
87+
self.layout().addWidget(self._key_selection_widget)
88+
89+
self._key_selection_widget.currentTextChanged.connect(
90+
self._set_axis_keys
91+
)
92+
93+
self._update_layers(None)
94+
95+
@property
96+
def x_axis_key(self) -> Optional[str]:
97+
"""Key to access x axis data from the FeaturesTable"""
98+
return self._x_axis_key
99+
100+
@x_axis_key.setter
101+
def x_axis_key(self, key: Optional[str]) -> None:
102+
self._x_axis_key = key
103+
self._draw()
104+
105+
def _set_axis_keys(self, x_axis_key: str) -> None:
106+
"""Set both axis keys and then redraw the plot"""
107+
self._x_axis_key = x_axis_key
108+
self._draw()
109+
110+
def _get_valid_axis_keys(self) -> List[str]:
111+
"""
112+
Get the valid axis keys from the layer FeatureTable.
113+
114+
Returns
115+
-------
116+
axis_keys : List[str]
117+
The valid axis keys in the FeatureTable. If the table is empty
118+
or there isn't a table, returns an empty list.
119+
"""
120+
if len(self.layers) == 0 or not (hasattr(self.layers[0], "features")):
121+
return []
122+
else:
123+
return self.layers[0].features.keys()
124+
125+
def _get_data(self) -> Tuple[Optional[npt.NDArray[Any]], str]:
126+
"""Get the plot data.
127+
128+
Returns
129+
-------
130+
data : List[np.ndarray]
131+
List contains X and Y columns from the FeatureTable. Returns
132+
an empty array if nothing to plot.
133+
x_axis_name : str
134+
The title to display on the x axis. Returns
135+
an empty string if nothing to plot.
136+
"""
137+
if not hasattr(self.layers[0], "features"):
138+
# if the selected layer doesn't have a featuretable,
139+
# skip draw
140+
return None, ""
141+
142+
feature_table = self.layers[0].features
143+
144+
if (len(feature_table) == 0) or (self.x_axis_key is None):
145+
return None, ""
146+
147+
data = feature_table[self.x_axis_key]
148+
x_axis_name = self.x_axis_key.replace("_", " ")
149+
150+
return data, x_axis_name
151+
152+
def on_update_layers(self) -> None:
153+
"""
154+
Called when the layer selection changes by ``self.update_layers()``.
155+
"""
156+
# reset the axis keys
157+
self._x_axis_key = None
158+
159+
# Clear combobox
160+
self._key_selection_widget.clear()
161+
self._key_selection_widget.addItems(self._get_valid_axis_keys())
162+
163+
def draw(self) -> None:
164+
"""Clear the axes and histogram the currently selected layer/slice."""
165+
data, x_axis_name = self._get_data()
166+
167+
if data is None:
168+
return
169+
170+
self.axes.hist(data, bins=50, edgecolor="white", linewidth=0.3)
171+
172+
# set ax labels
173+
self.axes.set_xlabel(x_axis_name)
174+
self.axes.set_ylabel("Counts [#]")

src/napari_matplotlib/napari.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ contributions:
1414
python_name: napari_matplotlib:FeaturesScatterWidget
1515
title: Make a scatter plot of layer features
1616

17+
- id: napari-matplotlib.features_histogram
18+
python_name: napari_matplotlib:FeaturesHistogramWidget
19+
title: Plot feature histograms
20+
1721
- id: napari-matplotlib.slice
1822
python_name: napari_matplotlib:SliceWidget
1923
title: Plot a 1D slice
@@ -28,5 +32,8 @@ contributions:
2832
- command: napari-matplotlib.features_scatter
2933
display_name: FeaturesScatter
3034

35+
- command: napari-matplotlib.features_histogram
36+
display_name: FeaturesHistogram
37+
3138
- command: napari-matplotlib.slice
3239
display_name: 1D slice

src/napari_matplotlib/scatter.py

+2-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from qtpy.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget
66

77
from .base import SingleAxesWidget
8+
from .features import FEATURES_LAYER_TYPES
89
from .util import Interval
910

1011
__all__ = ["ScatterBaseWidget", "ScatterWidget", "FeaturesScatterWidget"]
@@ -94,13 +95,7 @@ class FeaturesScatterWidget(ScatterBaseWidget):
9495

9596
n_layers_input = Interval(1, 1)
9697
# All layers that have a .features attributes
97-
input_layer_types = (
98-
napari.layers.Labels,
99-
napari.layers.Points,
100-
napari.layers.Shapes,
101-
napari.layers.Tracks,
102-
napari.layers.Vectors,
103-
)
98+
input_layer_types = FEATURES_LAYER_TYPES
10499

105100
def __init__(
106101
self,
Loading
Loading

src/napari_matplotlib/tests/test_histogram.py

+93-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from copy import deepcopy
22

3+
import numpy as np
34
import pytest
45

5-
from napari_matplotlib import HistogramWidget
6+
from napari_matplotlib import FeaturesHistogramWidget, HistogramWidget
7+
from napari_matplotlib.tests.helpers import (
8+
assert_figures_equal,
9+
assert_figures_not_equal,
10+
)
611

712

813
@pytest.mark.mpl_image_compare
@@ -28,3 +33,90 @@ def test_histogram_3D(make_napari_viewer, brain_data):
2833
# Need to return a copy, as original figure is too eagerley garbage
2934
# collected by the widget
3035
return deepcopy(fig)
36+
37+
38+
def test_feature_histogram(make_napari_viewer):
39+
n_points = 1000
40+
random_points = np.random.random((n_points, 3)) * 10
41+
feature1 = np.random.random(n_points)
42+
feature2 = np.random.normal(size=n_points)
43+
44+
viewer = make_napari_viewer()
45+
viewer.add_points(
46+
random_points,
47+
properties={"feature1": feature1, "feature2": feature2},
48+
name="points1",
49+
)
50+
viewer.add_points(
51+
random_points,
52+
properties={"feature1": feature1, "feature2": feature2},
53+
name="points2",
54+
)
55+
56+
widget = FeaturesHistogramWidget(viewer)
57+
viewer.window.add_dock_widget(widget)
58+
59+
# Check whether changing the selected key changes the plot
60+
widget._set_axis_keys("feature1")
61+
fig1 = deepcopy(widget.figure)
62+
63+
widget._set_axis_keys("feature2")
64+
assert_figures_not_equal(widget.figure, fig1)
65+
66+
# check whether selecting a different layer produces the same plot
67+
viewer.layers.selection.clear()
68+
viewer.layers.selection.add(viewer.layers[1])
69+
assert_figures_equal(widget.figure, fig1)
70+
71+
72+
@pytest.mark.mpl_image_compare
73+
def test_feature_histogram2(make_napari_viewer):
74+
import numpy as np
75+
76+
np.random.seed(0)
77+
n_points = 1000
78+
random_points = np.random.random((n_points, 3)) * 10
79+
feature1 = np.random.random(n_points)
80+
feature2 = np.random.normal(size=n_points)
81+
82+
viewer = make_napari_viewer()
83+
viewer.add_points(
84+
random_points,
85+
properties={"feature1": feature1, "feature2": feature2},
86+
name="points1",
87+
)
88+
viewer.add_points(
89+
random_points,
90+
properties={"feature1": feature1, "feature2": feature2},
91+
name="points2",
92+
)
93+
94+
widget = FeaturesHistogramWidget(viewer)
95+
viewer.window.add_dock_widget(widget)
96+
widget._set_axis_keys("feature1")
97+
98+
fig = FeaturesHistogramWidget(viewer).figure
99+
return deepcopy(fig)
100+
101+
102+
def test_change_layer(make_napari_viewer, brain_data, astronaut_data):
103+
viewer = make_napari_viewer()
104+
widget = HistogramWidget(viewer)
105+
106+
viewer.add_image(brain_data[0], **brain_data[1])
107+
viewer.add_image(astronaut_data[0], **astronaut_data[1])
108+
109+
# Select first layer
110+
viewer.layers.selection.clear()
111+
viewer.layers.selection.add(viewer.layers[0])
112+
fig1 = deepcopy(widget.figure)
113+
114+
# Re-selecting first layer should produce identical plot
115+
viewer.layers.selection.clear()
116+
viewer.layers.selection.add(viewer.layers[0])
117+
assert_figures_equal(widget.figure, fig1)
118+
119+
# Plotting the second layer should produce a different plot
120+
viewer.layers.selection.clear()
121+
viewer.layers.selection.add(viewer.layers[1])
122+
assert_figures_not_equal(widget.figure, fig1)

0 commit comments

Comments
 (0)