Skip to content

Commit 251d333

Browse files
authored
Merge pull request #242 from p-j-smith/feat/hist-bin-params
Add widgets for setting histogram bins
2 parents 0501fab + 8fe7c7f commit 251d333

File tree

4 files changed

+96
-11
lines changed

4 files changed

+96
-11
lines changed

docs/changelog.rst

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Changelog
22
=========
33

4+
2.1.0
5+
-----
6+
New features
7+
~~~~~~~~~~~~
8+
- Added a GUI element to manually set the number of bins in the histogram widgets.
9+
410
2.0.3
511
-----
612
Bug fixes

src/napari_matplotlib/histogram.py

+76-11
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
from napari.layers._multiscale_data import MultiScaleData
99
from qtpy.QtWidgets import (
1010
QComboBox,
11+
QFormLayout,
12+
QGroupBox,
1113
QLabel,
14+
QSpinBox,
1215
QVBoxLayout,
1316
QWidget,
1417
)
@@ -22,15 +25,32 @@
2225
_COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}
2326

2427

25-
def _get_bins(data: npt.NDArray[Any]) -> npt.NDArray[Any]:
28+
def _get_bins(
29+
data: npt.NDArray[Any],
30+
num_bins: int = 100,
31+
) -> npt.NDArray[Any]:
32+
"""Create evenly spaced bins with a given interval.
33+
34+
Parameters
35+
----------
36+
data : napari.layers.Layer.data
37+
Napari layer data.
38+
num_bins : integer, optional
39+
Number of evenly-spaced bins to create. Defaults to 100.
40+
41+
Returns
42+
-------
43+
bin_edges : numpy.ndarray
44+
Array of evenly spaced bin edges.
45+
"""
2646
if data.dtype.kind in {"i", "u"}:
2747
# Make sure integer data types have integer sized bins
28-
step = np.ceil(np.ptp(data) / 100)
48+
step = np.ceil(np.ptp(data) / num_bins)
2949
return np.arange(np.min(data), np.max(data) + step, step)
3050
else:
31-
# For other data types, just have 100 evenly spaced bins
32-
# (and 101 bin edges)
33-
return np.linspace(np.min(data), np.max(data), 101)
51+
# For other data types we can use exactly `num_bins` bins
52+
# (and `num_bins` + 1 bin edges)
53+
return np.linspace(np.min(data), np.max(data), num_bins + 1)
3454

3555

3656
class HistogramWidget(SingleAxesWidget):
@@ -47,6 +67,30 @@ def __init__(
4767
parent: QWidget | None = None,
4868
):
4969
super().__init__(napari_viewer, parent=parent)
70+
71+
num_bins_widget = QSpinBox()
72+
num_bins_widget.setRange(1, 100_000)
73+
num_bins_widget.setValue(101)
74+
num_bins_widget.setWrapping(False)
75+
num_bins_widget.setKeyboardTracking(False)
76+
77+
# Set bins widget layout
78+
bins_selection_layout = QFormLayout()
79+
bins_selection_layout.addRow("num bins", num_bins_widget)
80+
81+
# Group the widgets and add to main layout
82+
params_widget_group = QGroupBox("Params")
83+
params_widget_group_layout = QVBoxLayout()
84+
params_widget_group_layout.addLayout(bins_selection_layout)
85+
params_widget_group.setLayout(params_widget_group_layout)
86+
self.layout().addWidget(params_widget_group)
87+
88+
# Add callbacks
89+
num_bins_widget.valueChanged.connect(self._draw)
90+
91+
# Store widgets for later usage
92+
self.num_bins_widget = num_bins_widget
93+
5094
self._update_layers(None)
5195
self.viewer.events.theme.connect(self._on_napari_theme_changed)
5296

@@ -60,6 +104,13 @@ def on_update_layers(self) -> None:
60104
self._update_contrast_lims
61105
)
62106

107+
if not self.layers:
108+
return
109+
110+
# Reset the num bins based on new layer data
111+
layer_data = self._get_layer_data(self.layers[0])
112+
self._set_widget_nums_bins(data=layer_data)
113+
63114
def _update_contrast_lims(self) -> None:
64115
for lim, line in zip(
65116
self.layers[0].contrast_limits, self._contrast_lines, strict=False
@@ -68,11 +119,13 @@ def _update_contrast_lims(self) -> None:
68119

69120
self.figure.canvas.draw()
70121

71-
def draw(self) -> None:
72-
"""
73-
Clear the axes and histogram the currently selected layer/slice.
74-
"""
75-
layer: Image = self.layers[0]
122+
def _set_widget_nums_bins(self, data: npt.NDArray[Any]) -> None:
123+
"""Update num_bins widget with bins determined from the image data"""
124+
bins = _get_bins(data)
125+
self.num_bins_widget.setValue(bins.size - 1)
126+
127+
def _get_layer_data(self, layer: napari.layers.Layer) -> npt.NDArray[Any]:
128+
"""Get the data associated with a given layer"""
76129
data = layer.data
77130

78131
if isinstance(layer.data, MultiScaleData):
@@ -87,9 +140,21 @@ def draw(self) -> None:
87140
# Read data into memory if it's a dask array
88141
data = np.asarray(data)
89142

143+
return data
144+
145+
def draw(self) -> None:
146+
"""
147+
Clear the axes and histogram the currently selected layer/slice.
148+
"""
149+
layer: Image = self.layers[0]
150+
data = self._get_layer_data(layer)
151+
90152
# Important to calculate bins after slicing 3D data, to avoid reading
91153
# whole cube into memory.
92-
bins = _get_bins(data)
154+
bins = _get_bins(
155+
data,
156+
num_bins=self.num_bins_widget.value(),
157+
)
93158

94159
if layer.rgb:
95160
# Histogram RGB channels independently
Loading

src/napari_matplotlib/tests/test_histogram.py

+14
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
)
1111

1212

13+
@pytest.mark.mpl_image_compare
14+
def test_histogram_2D_bins(make_napari_viewer, astronaut_data):
15+
viewer = make_napari_viewer()
16+
viewer.theme = "light"
17+
viewer.add_image(astronaut_data[0], **astronaut_data[1])
18+
widget = HistogramWidget(viewer)
19+
viewer.window.add_dock_widget(widget)
20+
widget.num_bins_widget.setValue(25)
21+
fig = widget.figure
22+
# Need to return a copy, as original figure is too eagerley garbage
23+
# collected by the widget
24+
return deepcopy(fig)
25+
26+
1327
@pytest.mark.mpl_image_compare
1428
def test_histogram_2D(make_napari_viewer, astronaut_data):
1529
viewer = make_napari_viewer()

0 commit comments

Comments
 (0)