From 5b6feb6246d80f75cad64efc7e03e0dd7b22d9eb Mon Sep 17 00:00:00 2001 From: David Stansby Date: Thu, 5 May 2022 17:55:14 +0100 Subject: [PATCH 1/6] Working layout for slice widget --- examples/slice.py | 15 ++++++++++++ src/napari_matplotlib/__init__.py | 1 + src/napari_matplotlib/napari.yaml | 7 ++++++ src/napari_matplotlib/slice.py | 39 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 examples/slice.py create mode 100644 src/napari_matplotlib/slice.py diff --git a/examples/slice.py b/examples/slice.py new file mode 100644 index 00000000..3e43443e --- /dev/null +++ b/examples/slice.py @@ -0,0 +1,15 @@ +""" +1D slices +========= +""" +import napari + +viewer = napari.Viewer() +viewer.open_sample("napari", "kidney") + +viewer.window.add_plugin_dock_widget( + plugin_name="napari-matplotlib", widget_name="1D slice" +) + +if __name__ == "__main__": + napari.run() diff --git a/src/napari_matplotlib/__init__.py b/src/napari_matplotlib/__init__.py index 7e8ccf69..2d0e7e71 100644 --- a/src/napari_matplotlib/__init__.py +++ b/src/napari_matplotlib/__init__.py @@ -6,3 +6,4 @@ from .histogram import * # NoQA from .scatter import * # NoQA +from .slice import * # NoQA diff --git a/src/napari_matplotlib/napari.yaml b/src/napari_matplotlib/napari.yaml index 3ff66090..cd585879 100644 --- a/src/napari_matplotlib/napari.yaml +++ b/src/napari_matplotlib/napari.yaml @@ -10,9 +10,16 @@ contributions: python_name: napari_matplotlib:ScatterWidget title: Make a scatter plot + - id: napari-matplotlib.slice + python_name: napari_matplotlib:SliceWidget + title: Plot a 1D slice + widgets: - command: napari-matplotlib.histogram display_name: Histogram - command: napari-matplotlib.scatter display_name: Scatter + + - command: napari-matplotlib.slice + display_name: 1D slice diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py new file mode 100644 index 00000000..6e3a49fd --- /dev/null +++ b/src/napari_matplotlib/slice.py @@ -0,0 +1,39 @@ +import napari +from qtpy.QtWidgets import QComboBox, QHBoxLayout, QSpinBox + +from napari_matplotlib.base import NapariMPLWidget + +__all__ = ["SliceWidget"] + +_dims = ["x", "y", "z"] + + +class SliceWidget(NapariMPLWidget): + def __init__(self, napari_viewer: napari.viewer.Viewer): + super().__init__(napari_viewer) + + self.layer = self.viewer.layers[-1] + + button_layout = QHBoxLayout() + self.layout().addLayout(button_layout) + + self.dim_selector = QComboBox() + button_layout.addWidget(self.dim_selector) + + self.selectors = {} + for d in _dims: + self.selectors[d] = QSpinBox() + button_layout.addWidget(self.selectors[d]) + + self.update_dim_selector() + self.viewer.layers.selection.events.changed.connect( + self.update_dim_selector + ) + + def update_dim_selector(self) -> None: + """ + Update options in the dimension selector from currently selected layer. + """ + dims = ["x", "y", "z"] + self.dim_selector.clear() + self.dim_selector.addItems(dims[0 : self.layer.data.ndim]) From 760b84123a7ad935a3a6dfab0c2686a04f5f3def Mon Sep 17 00:00:00 2001 From: David Stansby Date: Thu, 5 May 2022 18:48:44 +0100 Subject: [PATCH 2/6] Working slicer --- src/napari_matplotlib/slice.py | 81 +++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py index 6e3a49fd..76532ee5 100644 --- a/src/napari_matplotlib/slice.py +++ b/src/napari_matplotlib/slice.py @@ -1,7 +1,10 @@ +from typing import Dict + import napari +import numpy as np from qtpy.QtWidgets import QComboBox, QHBoxLayout, QSpinBox -from napari_matplotlib.base import NapariMPLWidget +from napari_matplotlib.base import SingleLayerWidget __all__ = ["SliceWidget"] @@ -9,31 +12,79 @@ class SliceWidget(NapariMPLWidget): + """ + Plot a 1D slice along a given dimension. + """ + n_layers_input = 1 + def __init__(self, napari_viewer: napari.viewer.Viewer): super().__init__(napari_viewer) - - self.layer = self.viewer.layers[-1] + self.axes = self.canvas.figure.subplots() button_layout = QHBoxLayout() self.layout().addLayout(button_layout) self.dim_selector = QComboBox() button_layout.addWidget(self.dim_selector) + self.dim_selector.addItems(_dims) - self.selectors = {} + self.slice_selectors = {} for d in _dims: - self.selectors[d] = QSpinBox() - button_layout.addWidget(self.selectors[d]) + self.slice_selectors[d] = QSpinBox() + button_layout.addWidget(self.slice_selectors[d]) + + self.update_slice_selectors() + self.draw() + + @property + def current_dim(self) -> str: + """ + Currently selected slice dimension. + """ + return self.dim_selector.currentText() + + @property + def current_dim_index(self) -> int: + """ + Currently selected slice dimension index. + """ + # Note the reversed list because in napari the z-axis is the first + # numpy axis + return _dims[::-1].index(self.current_dim) - self.update_dim_selector() - self.viewer.layers.selection.events.changed.connect( - self.update_dim_selector - ) + @property + def selector_values(self) -> Dict[str, int]: + return {d: self.slice_selectors[d].value() for d in _dims} - def update_dim_selector(self) -> None: + def update_slice_selectors(self) -> None: """ - Update options in the dimension selector from currently selected layer. + Update range and enabled status of the slice selectors, and the value + of the z slice selector. """ - dims = ["x", "y", "z"] - self.dim_selector.clear() - self.dim_selector.addItems(dims[0 : self.layer.data.ndim]) + # Update min/max + for i, dim in enumerate(_dims): + self.slice_selectors[dim].setRange(0, self.layer.data.shape[i]) + + # The z dimension is always set by current z in the viewer + self.slice_selectors["z"].setValue(self.current_z) + self.slice_selectors[self.current_dim].setEnabled(False) + + def draw(self) -> None: + x = np.arange(self.layer.data.shape[self.current_dim_index]) + + slices = [] + for d in _dims: + if d == self.current_dim: + # Select all data along this axis + slices.append(slice(None)) + else: + # Select specific index + val = self.selector_values[d] + slices.append(slice(val, val + 1)) + + slices = slices[::-1] + y = self.layer.data[tuple(slices)].ravel() + + self.axes.plot(x, y) + self.axes.set_xlabel(self.current_dim) + self.axes.set_title(self.layer.name) From 342f72710842775fc35f57ca4e7df6eb72582e09 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Thu, 5 May 2022 18:51:23 +0100 Subject: [PATCH 3/6] Factor out code for getting plot data --- src/napari_matplotlib/slice.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py index 76532ee5..93f176e6 100644 --- a/src/napari_matplotlib/slice.py +++ b/src/napari_matplotlib/slice.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Tuple import napari import numpy as np @@ -69,7 +69,7 @@ def update_slice_selectors(self) -> None: self.slice_selectors["z"].setValue(self.current_z) self.slice_selectors[self.current_dim].setEnabled(False) - def draw(self) -> None: + def get_xy(self) -> Tuple[np.ndarray, np.ndarray]: x = np.arange(self.layer.data.shape[self.current_dim_index]) slices = [] @@ -82,9 +82,18 @@ def draw(self) -> None: val = self.selector_values[d] slices.append(slice(val, val + 1)) + # Reverse since z is the first axis in napari slices = slices[::-1] y = self.layer.data[tuple(slices)].ravel() + return x, y + + def draw(self) -> None: + """ + Clear axes and draw a 1D plot. + """ + x, y = self.get_xy() + self.axes.plot(x, y) self.axes.set_xlabel(self.current_dim) self.axes.set_title(self.layer.name) From 23d7531e5debf9eed9d7b3d172186c3d5c8deb9c Mon Sep 17 00:00:00 2001 From: David Stansby Date: Thu, 5 May 2022 21:35:30 +0100 Subject: [PATCH 4/6] Fix compatability --- src/napari_matplotlib/base.py | 2 +- src/napari_matplotlib/slice.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index 6bfbd093..8ef507d1 100644 --- a/src/napari_matplotlib/base.py +++ b/src/napari_matplotlib/base.py @@ -83,7 +83,7 @@ def setup_callbacks(self) -> None: def update_layers(self, event: napari.utils.events.Event) -> None: """ - Update the currently selected layers and re-draw. + Update the layers attribute with currently selected layers and re-draw. """ self.layers = list(self.viewer.layers.selection) self._draw() diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py index 93f176e6..e8717988 100644 --- a/src/napari_matplotlib/slice.py +++ b/src/napari_matplotlib/slice.py @@ -4,7 +4,7 @@ import numpy as np from qtpy.QtWidgets import QComboBox, QHBoxLayout, QSpinBox -from napari_matplotlib.base import SingleLayerWidget +from napari_matplotlib.base import NapariMPLWidget __all__ = ["SliceWidget"] @@ -15,6 +15,7 @@ class SliceWidget(NapariMPLWidget): """ Plot a 1D slice along a given dimension. """ + n_layers_input = 1 def __init__(self, napari_viewer: napari.viewer.Viewer): @@ -33,8 +34,11 @@ def __init__(self, napari_viewer: napari.viewer.Viewer): self.slice_selectors[d] = QSpinBox() button_layout.addWidget(self.slice_selectors[d]) - self.update_slice_selectors() - self.draw() + self.update_layers(None) + + @property + def layer(self): + return self.layers[0] @property def current_dim(self) -> str: @@ -88,10 +92,14 @@ def get_xy(self) -> Tuple[np.ndarray, np.ndarray]: return x, y + def clear(self) -> None: + self.axes.cla() + def draw(self) -> None: """ Clear axes and draw a 1D plot. """ + self.update_slice_selectors() x, y = self.get_xy() self.axes.plot(x, y) From 73ee3a2c4d0919f8013902fe6dd156f6d76679c5 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 7 May 2022 09:19:16 +0100 Subject: [PATCH 5/6] Remove z logic --- src/napari_matplotlib/slice.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py index e8717988..1d6407f5 100644 --- a/src/napari_matplotlib/slice.py +++ b/src/napari_matplotlib/slice.py @@ -2,12 +2,13 @@ import napari import numpy as np -from qtpy.QtWidgets import QComboBox, QHBoxLayout, QSpinBox +from qtpy.QtWidgets import QComboBox, QHBoxLayout, QLabel, QSpinBox from napari_matplotlib.base import NapariMPLWidget __all__ = ["SliceWidget"] +_dims_sel = ["x", "y"] _dims = ["x", "y", "z"] @@ -19,6 +20,7 @@ class SliceWidget(NapariMPLWidget): n_layers_input = 1 def __init__(self, napari_viewer: napari.viewer.Viewer): + # Setup figure/axes super().__init__(napari_viewer) self.axes = self.canvas.figure.subplots() @@ -26,14 +28,22 @@ def __init__(self, napari_viewer: napari.viewer.Viewer): self.layout().addLayout(button_layout) self.dim_selector = QComboBox() + button_layout.addWidget(QLabel("Slice axis:")) button_layout.addWidget(self.dim_selector) self.dim_selector.addItems(_dims) self.slice_selectors = {} - for d in _dims: + for d in _dims_sel: self.slice_selectors[d] = QSpinBox() + button_layout.addWidget(QLabel(f"{d}:")) button_layout.addWidget(self.slice_selectors[d]) + # Setup callbacks + # Re-draw when any of the combon/spin boxes are updated + self.dim_selector.currentTextChanged.connect(self._draw) + for d in _dims_sel: + self.slice_selectors[d].textChanged.connect(self._draw) + self.update_layers(None) @property @@ -58,7 +68,7 @@ def current_dim_index(self) -> int: @property def selector_values(self) -> Dict[str, int]: - return {d: self.slice_selectors[d].value() for d in _dims} + return {d: self.slice_selectors[d].value() for d in _dims_sel} def update_slice_selectors(self) -> None: """ @@ -66,16 +76,18 @@ def update_slice_selectors(self) -> None: of the z slice selector. """ # Update min/max - for i, dim in enumerate(_dims): + for i, dim in enumerate(_dims_sel): self.slice_selectors[dim].setRange(0, self.layer.data.shape[i]) - # The z dimension is always set by current z in the viewer - self.slice_selectors["z"].setValue(self.current_z) - self.slice_selectors[self.current_dim].setEnabled(False) - def get_xy(self) -> Tuple[np.ndarray, np.ndarray]: + """ + Get data for plotting. + """ x = np.arange(self.layer.data.shape[self.current_dim_index]) + vals = self.selector_values + vals.update({"z": self.current_z}) + slices = [] for d in _dims: if d == self.current_dim: @@ -83,7 +95,7 @@ def get_xy(self) -> Tuple[np.ndarray, np.ndarray]: slices.append(slice(None)) else: # Select specific index - val = self.selector_values[d] + val = vals[d] slices.append(slice(val, val + 1)) # Reverse since z is the first axis in napari @@ -99,7 +111,6 @@ def draw(self) -> None: """ Clear axes and draw a 1D plot. """ - self.update_slice_selectors() x, y = self.get_xy() self.axes.plot(x, y) From c946a6295412b600d554a63d0817e31a91a19975 Mon Sep 17 00:00:00 2001 From: David Stansby Date: Sat, 7 May 2022 09:20:34 +0100 Subject: [PATCH 6/6] Add slice test --- src/napari_matplotlib/tests/test_slice.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/napari_matplotlib/tests/test_slice.py diff --git a/src/napari_matplotlib/tests/test_slice.py b/src/napari_matplotlib/tests/test_slice.py new file mode 100644 index 00000000..d0be3cc1 --- /dev/null +++ b/src/napari_matplotlib/tests/test_slice.py @@ -0,0 +1,10 @@ +import numpy as np + +from napari_matplotlib import SliceWidget + + +def test_scatter(make_napari_viewer): + # Smoke test adding a histogram widget + viewer = make_napari_viewer() + viewer.add_image(np.random.random((100, 100, 100))) + SliceWidget(viewer)