diff --git a/docs/changelog.rst b/docs/changelog.rst index 54e6bba..60dd72b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/src/napari_matplotlib/histogram.py b/src/napari_matplotlib/histogram.py index 2881cf7..acdd840 100644 --- a/src/napari_matplotlib/histogram.py +++ b/src/napari_matplotlib/histogram.py @@ -8,7 +8,10 @@ from napari.layers._multiscale_data import MultiScaleData from qtpy.QtWidgets import ( QComboBox, + QFormLayout, + QGroupBox, QLabel, + QSpinBox, QVBoxLayout, QWidget, ) @@ -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): @@ -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) @@ -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 @@ -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): @@ -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 diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png new file mode 100644 index 0000000..98e3cde Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png differ diff --git a/src/napari_matplotlib/tests/test_histogram.py b/src/napari_matplotlib/tests/test_histogram.py index 1ceca51..435973b 100644 --- a/src/napari_matplotlib/tests/test_histogram.py +++ b/src/napari_matplotlib/tests/test_histogram.py @@ -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()