Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add widgets for setting histogram bins #242

Merged
merged 27 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a7b09d4
Add widgets to set bin parameters for histograms
p-j-smith Jan 11, 2024
96b2f0c
Add test for histogram widget when setting bin parameters
p-j-smith Jan 11, 2024
b8623ed
Update changelog
p-j-smith Jan 11, 2024
4e4fb84
Make linters happy
p-j-smith Jan 11, 2024
5ac1f71
Fix type hints
p-j-smith Jan 11, 2024
fab2906
Don't allow bins lower than 0 if dtype is unisgned
p-j-smith Jan 11, 2024
5553174
Update changelog
p-j-smith Jan 11, 2024
e86d4f6
Undo changes to example of HistogramWidget
p-j-smith Jan 11, 2024
c5e0886
Fix autosetting bins from data
p-j-smith Jan 15, 2024
127d325
remove duplicate on_update_layers method
p-j-smith Jan 15, 2024
b8ffdb7
Make linters happy
p-j-smith Jan 15, 2024
88760cf
Add HistogramWidget._bin_widgets attribute for storing bin widgets
p-j-smith Jan 15, 2024
d56942b
Fix calculation of bins from widget values
p-j-smith Jan 15, 2024
11590b2
Calculate step using n_bins-1
p-j-smith Jan 15, 2024
08a5086
cannot use negative start bin for uint data
p-j-smith Jan 15, 2024
65e84f8
Make HistogramWidget bins_num widget correspond to number of bins rat…
p-j-smith Jan 15, 2024
37d33ae
Merge branch 'main' into feat/hist-bin-params
p-j-smith Jan 15, 2024
8a7d9e4
fix typo in comment about using 128 bins for float data
p-j-smith Jan 15, 2024
fbd929e
Merge branch 'main' into feat/hist-bin-params
p-j-smith Jan 15, 2024
7c4cdc8
Update changelog
p-j-smith Jan 15, 2024
c6b5d8e
Merge branch 'main' into feat/hist-bin-params
p-j-smith Feb 14, 2024
6261f4c
Add 'num_bins, 'start', and 'stop' parameters to '_get_bins'
p-j-smith Feb 14, 2024
457bc1b
Merge branch 'main' into feat/hist-bin-params
p-j-smith May 25, 2024
426a0f5
use '| None' rather than Optional[Union[...]] for type hints
p-j-smith May 25, 2024
67a8641
remove widgest to set start and stop values for histogram bins
p-j-smith May 25, 2024
208af97
Merge branch 'main' into feat/hist-bin-params
dstansby Jul 12, 2024
8fe7c7f
Update changelog
dstansby Jul 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Other changes
- The ``HistogramWidget`` now has two vertical lines showing the contrast limits used
to render the selected layer in the main napari window.
- Added an example gallery for the ``FeaturesHistogramWidget``.
- Add widgets for setting bin parameters for ``HistogramWidget``.

1.2.0
-----
Expand Down
154 changes: 146 additions & 8 deletions src/napari_matplotlib/histogram.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from typing import Any, Optional, cast
from typing import Any, Optional, Union, cast

import napari
import numpy as np
import numpy.typing as npt
from matplotlib.container import BarContainer
from qtpy.QtWidgets import (
QAbstractSpinBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QLabel,
QSpinBox,
QVBoxLayout,
QWidget,
)
Expand All @@ -26,7 +31,7 @@ def _get_bins(data: npt.NDArray[Any]) -> npt.NDArray[Any]:
step = np.ceil(np.ptp(data) / 100)
return np.arange(np.min(data), np.max(data) + step, step)
else:
# For other data types, just have 128 evenly spaced bins
# For other data types, just have 99 evenly spaced bins
return np.linspace(np.min(data), np.max(data), 100)


Expand All @@ -44,6 +49,55 @@ def __init__(
parent: Optional[QWidget] = None,
):
super().__init__(napari_viewer, parent=parent)

# Create widgets for setting bin parameters
bins_start = QDoubleSpinBox()
bins_start.setStepType(QAbstractSpinBox.AdaptiveDecimalStepType)
bins_start.setRange(-1e10, 1e10)
bins_start.setValue(0)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure what a reasonable range would be (rather than the default of 0 to 100)?

bins_start.setWrapping(False)
bins_start.setKeyboardTracking(False)
bins_start.setDecimals(2)

bins_stop = QDoubleSpinBox()
bins_stop.setStepType(QAbstractSpinBox.AdaptiveDecimalStepType)
bins_stop.setRange(-1e10, 1e10)
bins_stop.setValue(100)
bins_start.setWrapping(False)
bins_stop.setKeyboardTracking(False)
bins_stop.setDecimals(2)

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

# Set bins widget layout
bins_selection_layout = QFormLayout()
bins_selection_layout.addRow("start", bins_start)
bins_selection_layout.addRow("stop", bins_stop)
bins_selection_layout.addRow("num", bins_num)

# Group the widgets and add to main layout
bins_widget_group = QGroupBox("Bins")
bins_widget_group_layout = QVBoxLayout()
bins_widget_group_layout.addLayout(bins_selection_layout)
bins_widget_group.setLayout(bins_widget_group_layout)
self.layout().addWidget(bins_widget_group)

# Add callbacks
bins_start.valueChanged.connect(self._draw)
bins_stop.valueChanged.connect(self._draw)
bins_num.valueChanged.connect(self._draw)

# Store widgets for later usage
self._bin_widgets = {
"start": bins_start,
"stop": bins_stop,
"num": bins_num,
}

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

Expand All @@ -55,6 +109,31 @@ def on_update_layers(self) -> None:
for layer in self.viewer.layers:
layer.events.contrast_limits.connect(self._update_contrast_lims)

if not self.layers:
return

# Reset to bin start, stop and step
layer_data = self._get_layer_data(self.layers[0])
self.autoset_widget_bins(data=layer_data)

# Only allow integer bins for integer data
# And only allow values greater than 0 for unsigned integers
n_decimals = 0 if np.issubdtype(layer_data.dtype, np.integer) else 2
is_unsigned = layer_data.dtype.kind == "u"
minimum_value = 0 if is_unsigned else -1e10

# Disable callbacks whilst widget values might change
for widget in self._bin_widgets.values():
widget.blockSignals(True)

self._bin_widgets["start"].setDecimals(n_decimals)
self._bin_widgets["stop"].setDecimals(n_decimals)
self._bin_widgets["start"].setMinimum(minimum_value)
self._bin_widgets["stop"].setMinimum(minimum_value)

for widget in self._bin_widgets.values():
widget.blockSignals(False)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temporarily disable the callbacks otherwise the plot is re-drawn each time the widget value changes

def _update_contrast_lims(self) -> None:
for lim, line in zip(
self.layers[0].contrast_limits, self._contrast_lines
Expand All @@ -63,24 +142,83 @@ 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 = self.layers[0]
def autoset_widget_bins(self, data: npt.NDArray[Any]) -> None:
"""Update widgets with bins determined from the image data"""
bins = _get_bins(data)

# Disable callbacks whilst setting widget values
for widget in self._bin_widgets.values():
widget.blockSignals(True)

self.bins_start = bins[0]
self.bins_stop = bins[-1]
self.bins_num = bins.size - 1

for widget in self._bin_widgets.values():
widget.blockSignals(False)

@property
def bins_start(self) -> float:
"""Minimum bin edge"""
return self._bin_widgets["start"].value()

@bins_start.setter
def bins_start(self, start: Union[int, float]) -> None:
"""Set the minimum bin edge"""
self._bin_widgets["start"].setValue(start)

@property
def bins_stop(self) -> float:
"""Maximum bin edge"""
return self._bin_widgets["stop"].value()

@bins_stop.setter
def bins_stop(self, stop: Union[int, float]) -> None:
"""Set the maximum bin edge"""
self._bin_widgets["stop"].setValue(stop)

@property
def bins_num(self) -> int:
"""Number of bins to use"""
return self._bin_widgets["num"].value()

@bins_num.setter
def bins_num(self, num: int) -> None:
"""Set the number of bins to use"""
self._bin_widgets["num"].setValue(num)

def _get_layer_data(self, layer: napari.layers.Layer) -> npt.NDArray[Any]:
"""Get the data associated with a given layer"""
if layer.data.ndim - layer.rgb == 3:
# 3D data, can be single channel or RGB
data = layer.data[self.current_z]
self.axes.set_title(f"z={self.current_z}")
else:
data = layer.data

# 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 = 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)
if data.dtype.kind in {"i", "u"}:
# Make sure integer data types have integer sized bins
step = abs(self.bins_stop - self.bins_start) // (self.bins_num)
step = max(1, step)
bins = np.arange(self.bins_start, self.bins_stop + step, step)
else:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to show a notification when the actual number of bins used differs from that requested in the widgets, but perhaps this could be a different PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the notification now, but happy to remove it and add separately if it's easier

bins = np.linspace(
self.bins_start, self.bins_stop, self.bins_num + 1
)

p-j-smith marked this conversation as resolved.
Show resolved Hide resolved
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.
16 changes: 16 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,22 @@
)


@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.bins_start = 0
widget.bins_stop = 350
widget.bins_num = 35
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
Loading