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()
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: