Skip to content

Commit

Permalink
Merge pull request #242 from p-j-smith/feat/hist-bin-params
Browse files Browse the repository at this point in the history
Add widgets for setting histogram bins
  • Loading branch information
dstansby authored Jul 12, 2024
2 parents 0501fab + 8fe7c7f commit 251d333
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 11 deletions.
6 changes: 6 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

2.1.0
-----
New features
~~~~~~~~~~~~
- Added a GUI element to manually set the number of bins in the histogram widgets.

2.0.3
-----
Bug fixes
Expand Down
87 changes: 76 additions & 11 deletions src/napari_matplotlib/histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from napari.layers._multiscale_data import MultiScaleData
from qtpy.QtWidgets import (
QComboBox,
QFormLayout,
QGroupBox,
QLabel,
QSpinBox,
QVBoxLayout,
QWidget,
)
Expand All @@ -22,15 +25,32 @@
_COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}


def _get_bins(data: npt.NDArray[Any]) -> npt.NDArray[Any]:
def _get_bins(
data: npt.NDArray[Any],
num_bins: int = 100,
) -> npt.NDArray[Any]:
"""Create evenly spaced bins with a given interval.
Parameters
----------
data : napari.layers.Layer.data
Napari layer data.
num_bins : integer, optional
Number of evenly-spaced bins to create. Defaults to 100.
Returns
-------
bin_edges : numpy.ndarray
Array of evenly spaced bin edges.
"""
if data.dtype.kind in {"i", "u"}:
# Make sure integer data types have integer sized bins
step = np.ceil(np.ptp(data) / 100)
step = np.ceil(np.ptp(data) / num_bins)
return np.arange(np.min(data), np.max(data) + step, step)
else:
# For other data types, just have 100 evenly spaced bins
# (and 101 bin edges)
return np.linspace(np.min(data), np.max(data), 101)
# For other data types we can use exactly `num_bins` bins
# (and `num_bins` + 1 bin edges)
return np.linspace(np.min(data), np.max(data), num_bins + 1)


class HistogramWidget(SingleAxesWidget):
Expand All @@ -47,6 +67,30 @@ def __init__(
parent: QWidget | None = None,
):
super().__init__(napari_viewer, parent=parent)

num_bins_widget = QSpinBox()
num_bins_widget.setRange(1, 100_000)
num_bins_widget.setValue(101)
num_bins_widget.setWrapping(False)
num_bins_widget.setKeyboardTracking(False)

# Set bins widget layout
bins_selection_layout = QFormLayout()
bins_selection_layout.addRow("num bins", num_bins_widget)

# Group the widgets and add to main layout
params_widget_group = QGroupBox("Params")
params_widget_group_layout = QVBoxLayout()
params_widget_group_layout.addLayout(bins_selection_layout)
params_widget_group.setLayout(params_widget_group_layout)
self.layout().addWidget(params_widget_group)

# Add callbacks
num_bins_widget.valueChanged.connect(self._draw)

# Store widgets for later usage
self.num_bins_widget = num_bins_widget

self._update_layers(None)
self.viewer.events.theme.connect(self._on_napari_theme_changed)

Expand All @@ -60,6 +104,13 @@ def on_update_layers(self) -> None:
self._update_contrast_lims
)

if not self.layers:
return

# Reset the num bins based on new layer data
layer_data = self._get_layer_data(self.layers[0])
self._set_widget_nums_bins(data=layer_data)

def _update_contrast_lims(self) -> None:
for lim, line in zip(
self.layers[0].contrast_limits, self._contrast_lines, strict=False
Expand All @@ -68,11 +119,13 @@ def _update_contrast_lims(self) -> None:

self.figure.canvas.draw()

def draw(self) -> None:
"""
Clear the axes and histogram the currently selected layer/slice.
"""
layer: Image = self.layers[0]
def _set_widget_nums_bins(self, data: npt.NDArray[Any]) -> None:
"""Update num_bins widget with bins determined from the image data"""
bins = _get_bins(data)
self.num_bins_widget.setValue(bins.size - 1)

def _get_layer_data(self, layer: napari.layers.Layer) -> npt.NDArray[Any]:
"""Get the data associated with a given layer"""
data = layer.data

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

return data

def draw(self) -> None:
"""
Clear the axes and histogram the currently selected layer/slice.
"""
layer: Image = self.layers[0]
data = self._get_layer_data(layer)

# Important to calculate bins after slicing 3D data, to avoid reading
# whole cube into memory.
bins = _get_bins(data)
bins = _get_bins(
data,
num_bins=self.num_bins_widget.value(),
)

if layer.rgb:
# Histogram RGB channels independently
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions src/napari_matplotlib/tests/test_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
)


@pytest.mark.mpl_image_compare
def test_histogram_2D_bins(make_napari_viewer, astronaut_data):
viewer = make_napari_viewer()
viewer.theme = "light"
viewer.add_image(astronaut_data[0], **astronaut_data[1])
widget = HistogramWidget(viewer)
viewer.window.add_dock_widget(widget)
widget.num_bins_widget.setValue(25)
fig = widget.figure
# Need to return a copy, as original figure is too eagerley garbage
# collected by the widget
return deepcopy(fig)


@pytest.mark.mpl_image_compare
def test_histogram_2D(make_napari_viewer, astronaut_data):
viewer = make_napari_viewer()
Expand Down

0 comments on commit 251d333

Please sign in to comment.