`_ for a helpful guide.
diff --git a/examples/features_hist.py b/examples/features_hist.py
new file mode 100644
index 00000000..899ddef3
--- /dev/null
+++ b/examples/features_hist.py
@@ -0,0 +1,42 @@
+"""
+Hisogram of features
+====================
+"""
+
+import napari
+import numpy as np
+import numpy.typing as npt
+from skimage.measure import regionprops_table
+
+# make a test label image
+label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
+
+label_image[10:20, 10:20] = 1
+label_image[50:70, 50:70] = 2
+
+feature_table_1 = regionprops_table(
+ label_image, properties=("label", "area", "perimeter")
+)
+feature_table_1["index"] = feature_table_1["label"]
+
+# make the points data
+n_points = 100
+points_data = 100 * np.random.random((100, 2))
+points_features = {
+ "feature_0": np.random.random((n_points,)),
+ "feature_1": np.random.random((n_points,)),
+ "feature_2": np.random.random((n_points,)),
+}
+
+# create the viewer
+viewer = napari.Viewer()
+viewer.add_labels(label_image, features=feature_table_1)
+viewer.add_points(points_data, features=points_features)
+
+# make the widget
+viewer.window.add_plugin_dock_widget(
+ plugin_name="napari-matplotlib", widget_name="FeaturesHistogram"
+)
+
+if __name__ == "__main__":
+ napari.run()
diff --git a/examples/histogram.py b/examples/histogram.py
index ccda491a..b9ceb377 100644
--- a/examples/histogram.py
+++ b/examples/histogram.py
@@ -2,6 +2,7 @@
Histograms
==========
"""
+
import napari
viewer = napari.Viewer()
diff --git a/examples/scatter.py b/examples/scatter.py
index cd812401..00e01ec9 100644
--- a/examples/scatter.py
+++ b/examples/scatter.py
@@ -2,6 +2,7 @@
Scatter plots
=============
"""
+
import napari
viewer = napari.Viewer()
diff --git a/examples/slice.py b/examples/slice.py
index 3e43443e..242a16cc 100644
--- a/examples/slice.py
+++ b/examples/slice.py
@@ -2,6 +2,7 @@
1D slices
=========
"""
+
import napari
viewer = napari.Viewer()
diff --git a/pyproject.toml b/pyproject.toml
index 7c7dbbdd..f76831a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,34 @@
[build-system]
-requires = ["setuptools", "wheel", "setuptools_scm"]
+requires = ["setuptools", "setuptools_scm"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/napari_matplotlib/_version.py"
[tool.pytest.ini_options]
-qt_api = "pyqt6"
-addopts = "--mpl"
filterwarnings = [
"error",
+ "ignore:(?s).*Pyarrow will become a required dependency of pandas",
# Coming from vispy
"ignore:distutils Version classes are deprecated:DeprecationWarning",
"ignore:`np.bool8` is a deprecated alias for `np.bool_`:DeprecationWarning",
+ # Coming from pydantic via napari
+ "ignore:Pickle, copy, and deepcopy support will be removed from itertools in Python 3.14.:DeprecationWarning",
+ # Until we stop supporting older numpy versions (<2.1)
+ "ignore:(?s).*`newshape` keyword argument is deprecated.*$:DeprecationWarning",
+]
+qt_api = "pyqt6"
+addopts = [
+ "--mpl",
+ "--mpl-baseline-relative",
+ "--strict-config",
+ "--strict-markers",
+ "-ra",
]
+minversion = "7"
+testpaths = ["src/napari_matplotlib/tests"]
+log_cli_level = "INFO"
+xfail_strict = true
[tool.black]
line-length = 79
@@ -23,8 +38,11 @@ profile = "black"
line_length = 79
[tool.ruff]
-target-version = "py38"
-select = ["I", "UP", "F", "E", "W", "D"]
+target-version = "py310"
+fix = true
+
+[tool.ruff.lint]
+select = ["B", "I", "UP", "F", "E", "W", "D"]
ignore = [
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
@@ -34,37 +52,25 @@ ignore = [
"D401", # First line of docstring should be in imperative mood
]
-fix = true
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
"docs/*" = ["D"]
"examples/*" = ["D"]
"src/napari_matplotlib/tests/*" = ["D"]
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.mypy]
-python_version = "3.8"
+python_version = "3.12"
# Block below are checks that form part of mypy 'strict' mode
-warn_unused_configs = true
-warn_redundant_casts = true
-warn_unused_ignores = true
-strict_equality = true
-strict_concatenate = true
-check_untyped_defs = true
+strict = true
disallow_subclassing_any = false # TODO: fix
-disallow_untyped_decorators = true
-disallow_any_generics = true
-disallow_untyped_calls = true
-disallow_incomplete_defs = true
-disallow_untyped_defs = true
-no_implicit_reexport = true
-warn_return_any = false # TODO: fix
+warn_return_any = false # TODO: fix
ignore_missing_imports = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+
[[tool.mypy.overrides]]
-module = [
- "napari_matplotlib/tests/*",
-]
+module = ["napari_matplotlib/tests/*"]
disallow_untyped_defs = false
diff --git a/setup.cfg b/setup.cfg
index dfd52347..a3709e66 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
[metadata]
-name = napari_matplotlib
+name = napari-matplotlib
description = A plugin to use Matplotlib with napari
long_description = file: README.md
long_description_content_type = text/markdown
@@ -9,7 +9,7 @@ author_email = d.stansby@ucl.ac.uk
license = BSD-3-Clause
license_files = LICENSE
classifiers =
- Development Status :: 3 - Alpha
+ Development Status :: 5 - Production/Stable
Framework :: napari
Intended Audience :: Developers
License :: OSI Approved :: BSD License
@@ -28,10 +28,10 @@ project_urls =
packages = find:
install_requires =
matplotlib
- napari<0.4.18
- numpy
+ napari>=0.5
+ numpy>=1.23
tinycss2
-python_requires = >=3.8
+python_requires = >=3.10
include_package_data = True
package_dir =
=src
@@ -49,13 +49,13 @@ napari.manifest =
docs =
napari[all]
numpydoc
+ pydantic<2
pydata-sphinx-theme
- qtgallery
sphinx
sphinx-automodapi
sphinx-gallery
testing =
- napari[pyqt6-experimental]
+ napari[pyqt6_experimental]>=0.4.18
pooch
pyqt6
pytest
diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py
index 792b5aff..ca69a548 100644
--- a/src/napari_matplotlib/base.py
+++ b/src/napari_matplotlib/base.py
@@ -1,26 +1,22 @@
import os
from pathlib import Path
-from typing import List, Optional, Tuple
-import matplotlib
import matplotlib.style as mplstyle
import napari
-from matplotlib.backends.backend_qtagg import (
- FigureCanvas,
+from matplotlib.backends.backend_qtagg import ( # type: ignore[attr-defined]
+ FigureCanvasQTAgg,
NavigationToolbar2QT,
)
from matplotlib.figure import Figure
+from napari.utils.events import Event
+from napari.utils.theme import get_theme
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
-from .util import Interval, from_napari_css_get_size_of
+from .util import Interval, from_napari_css_get_size_of, style_sheet_from_theme
__all__ = ["BaseNapariMPLWidget", "NapariMPLWidget", "SingleAxesWidget"]
-_CUSTOM_STYLE_PATH = (
- Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
-)
-
class BaseNapariMPLWidget(QWidget):
"""
@@ -41,24 +37,21 @@ class BaseNapariMPLWidget(QWidget):
def __init__(
self,
napari_viewer: napari.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(parent=parent)
self.viewer = napari_viewer
- self._mpl_style_sheet_path: Optional[Path] = None
+ self.napari_theme_style_sheet = style_sheet_from_theme(
+ get_theme(napari_viewer.theme)
+ )
# Sets figure.* style
- with mplstyle.context(self.mpl_style_sheet_path):
- self.canvas = FigureCanvas()
+ with mplstyle.context(self.napari_theme_style_sheet):
+ self.canvas = FigureCanvasQTAgg() # type: ignore[no-untyped-call]
self.canvas.figure.set_layout_engine("constrained")
- self.toolbar = NapariNavigationToolbar(
- self.canvas, parent=self
- ) # type: ignore[no-untyped-call]
+ self.toolbar = NapariNavigationToolbar(self.canvas, parent=self)
self._replace_toolbar_icons()
- # callback to update when napari theme changed
- # TODO: this isn't working completely (see issue #140)
- # most of our styling respects the theme change but not all
self.viewer.events.theme.connect(self._on_napari_theme_changed)
self.setLayout(QVBoxLayout())
@@ -70,24 +63,6 @@ def figure(self) -> Figure:
"""Matplotlib figure."""
return self.canvas.figure
- @property
- def mpl_style_sheet_path(self) -> Path:
- """
- Path to the set Matplotlib style sheet.
- """
- if self._mpl_style_sheet_path is not None:
- return self._mpl_style_sheet_path
- elif (_CUSTOM_STYLE_PATH).exists():
- return _CUSTOM_STYLE_PATH
- elif self._napari_theme_has_light_bg():
- return Path(__file__).parent / "styles" / "light.mplstyle"
- else:
- return Path(__file__).parent / "styles" / "dark.mplstyle"
-
- @mpl_style_sheet_path.setter
- def mpl_style_sheet_path(self, path: Path) -> None:
- self._mpl_style_sheet_path = Path(path)
-
def add_single_axes(self) -> None:
"""
Add a single Axes to the figure.
@@ -96,13 +71,21 @@ def add_single_axes(self) -> None:
"""
# Sets axes.* style.
# Does not set any text styling set by axes.* keys
- with mplstyle.context(self.mpl_style_sheet_path):
- self.axes = self.figure.subplots()
+ with mplstyle.context(self.napari_theme_style_sheet):
+ self.axes = self.figure.add_subplot()
- def _on_napari_theme_changed(self) -> None:
+ def _on_napari_theme_changed(self, event: Event) -> None:
"""
Called when the napari theme is changed.
+
+ Parameters
+ ----------
+ event : napari.utils.events.Event
+ Event that triggered the callback.
"""
+ self.napari_theme_style_sheet = style_sheet_from_theme(
+ get_theme(event.value)
+ )
self._replace_toolbar_icons()
def _napari_theme_has_light_bg(self) -> bool:
@@ -114,7 +97,7 @@ def _napari_theme_has_light_bg(self) -> bool:
bool
True if theme's background colour has hsl lighter than 50%, False if darker.
"""
- theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False)
+ theme = napari.utils.theme.get_theme(self.viewer.theme)
_, _, bg_lightness = theme.background.as_hsl_tuple()
return bg_lightness > 0.5
@@ -184,16 +167,16 @@ class NapariMPLWidget(BaseNapariMPLWidget):
#: Number of layers taken as input
n_layers_input = Interval(None, None)
#: Type of layer taken as input
- input_layer_types: Tuple[napari.layers.Layer, ...] = (napari.layers.Layer,)
+ input_layer_types: tuple[napari.layers.Layer, ...] = (napari.layers.Layer,)
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer=napari_viewer, parent=parent)
self._setup_callbacks()
- self.layers: List[napari.layers.Layer] = []
+ self.layers: list[napari.layers.Layer] = []
helper_text = self.n_layers_input._helper_text
if helper_text is not None:
@@ -213,15 +196,18 @@ def current_z(self) -> int:
"""
return self.viewer.dims.current_step[0]
- def _on_napari_theme_changed(self) -> None:
+ def _on_napari_theme_changed(self, event: Event) -> None:
"""Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed.
- Note:
- At the moment we only handle the default 'light' and 'dark' napari themes.
+ Parameters
+ ----------
+ event : napari.utils.events.Event
+ Event that triggered the callback.
"""
- super()._on_napari_theme_changed()
- self.clear()
- self.draw()
+ super()._on_napari_theme_changed(event)
+ # use self._draw instead of self.draw to cope with redraw while there are no
+ # layers, this makes the self.clear() obsolete
+ self._draw()
def _setup_callbacks(self) -> None:
"""
@@ -238,6 +224,15 @@ def _setup_callbacks(self) -> None:
self._update_layers
)
+ @property
+ def _valid_layer_selection(self) -> bool:
+ """
+ Return `True` if layer selection is valid.
+ """
+ return self.n_selected_layers in self.n_layers_input and all(
+ isinstance(layer, self.input_layer_types) for layer in self.layers
+ )
+
def _update_layers(self, event: napari.utils.events.Event) -> None:
"""
Update the ``layers`` attribute with currently selected layers and re-draw.
@@ -245,7 +240,8 @@ def _update_layers(self, event: napari.utils.events.Event) -> None:
self.layers = list(self.viewer.layers.selection)
self.layers = sorted(self.layers, key=lambda layer: layer.name)
self.on_update_layers()
- self._draw()
+ if self._valid_layer_selection:
+ self._draw()
def _draw(self) -> None:
"""
@@ -254,13 +250,12 @@ def _draw(self) -> None:
"""
# Clearing axes sets new defaults, so need to make sure style is applied when
# this happens
- with mplstyle.context(self.mpl_style_sheet_path):
+ with mplstyle.context(self.napari_theme_style_sheet):
+ # everything should be done in the style context
self.clear()
- if self.n_selected_layers in self.n_layers_input and all(
- isinstance(layer, self.input_layer_types) for layer in self.layers
- ):
- self.draw()
- self.canvas.draw()
+ if self._valid_layer_selection:
+ self.draw()
+ self.canvas.draw() # type: ignore[no-untyped-call]
def clear(self) -> None:
"""
@@ -293,7 +288,7 @@ class SingleAxesWidget(NapariMPLWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer=napari_viewer, parent=parent)
self.add_single_axes()
@@ -302,15 +297,15 @@ def clear(self) -> None:
"""
Clear the axes.
"""
- with mplstyle.context(self.mpl_style_sheet_path):
+ with mplstyle.context(self.napari_theme_style_sheet):
self.axes.clear()
class NapariNavigationToolbar(NavigationToolbar2QT):
"""Custom Toolbar style for Napari."""
- def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
- super().__init__(*args, **kwargs)
+ def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
+ super().__init__(*args, **kwargs) # type: ignore[no-untyped-call]
self.setIconSize(
from_napari_css_get_size_of(
"QtViewerPushButton", fallback=(28, 28)
@@ -319,7 +314,7 @@ def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
def _update_buttons_checked(self) -> None:
"""Update toggle tool icons when selected/unselected."""
- super()._update_buttons_checked()
+ super()._update_buttons_checked() # type: ignore[no-untyped-call]
icon_dir = self.parentWidget()._get_path_to_icon()
# changes pan/zoom icons depending on state (checked or not)
diff --git a/src/napari_matplotlib/features.py b/src/napari_matplotlib/features.py
new file mode 100644
index 00000000..34abf104
--- /dev/null
+++ b/src/napari_matplotlib/features.py
@@ -0,0 +1,9 @@
+from napari.layers import Labels, Points, Shapes, Tracks, Vectors
+
+FEATURES_LAYER_TYPES = (
+ Labels,
+ Points,
+ Shapes,
+ Tracks,
+ Vectors,
+)
diff --git a/src/napari_matplotlib/histogram.py b/src/napari_matplotlib/histogram.py
index 39ad41a3..85bba9d2 100644
--- a/src/napari_matplotlib/histogram.py
+++ b/src/napari_matplotlib/histogram.py
@@ -1,17 +1,58 @@
-from typing import Optional
+from typing import Any, cast
import napari
import numpy as np
-from qtpy.QtWidgets import QWidget
+import numpy.typing as npt
+from matplotlib.container import BarContainer
+from napari.layers import Image
+from napari.layers._multiscale_data import MultiScaleData
+from qtpy.QtWidgets import (
+ QComboBox,
+ QFormLayout,
+ QGroupBox,
+ QLabel,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
from .base import SingleAxesWidget
+from .features import FEATURES_LAYER_TYPES
from .util import Interval
-__all__ = ["HistogramWidget"]
+__all__ = ["HistogramWidget", "FeaturesHistogramWidget"]
_COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}
+def _get_bins(
+ data: npt.NDArray[Any],
+ num_bins: int = 100,
+) -> npt.NDArray[np.floating]:
+ """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) / num_bins)
+ return np.arange(np.min(data), np.max(data) + step, step)
+ else:
+ # 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):
"""
Display a histogram of the currently selected layer.
@@ -23,36 +64,251 @@ class HistogramWidget(SingleAxesWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ 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)
- def draw(self) -> None:
+ def on_update_layers(self) -> None:
"""
- Clear the axes and histogram the currently selected layer/slice.
+ Called when the selected layers are updated.
"""
- layer = self.layers[0]
- bins = np.linspace(np.min(layer.data), np.max(layer.data), 100)
+ super().on_update_layers()
+ if self._valid_layer_selection:
+ self.layers[0].events.contrast_limits.connect(
+ 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
+ ):
+ line.set_xdata(lim)
+
+ self.figure.canvas.draw()
+
+ 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 layer.data.ndim - layer.rgb == 3:
+ if isinstance(layer.data, MultiScaleData):
+ data = data[layer.data_level]
+
+ if layer.ndim - layer.rgb == 3:
# 3D data, can be single channel or RGB
- data = layer.data[self.current_z]
+ # Slice in z dimension
+ data = 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: 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,
+ num_bins=self.num_bins_widget.value(),
+ )
if layer.rgb:
# Histogram RGB channels independently
for i, c in enumerate("rgb"):
self.axes.hist(
data[..., i].ravel(),
- bins=bins,
+ bins=bins.tolist(),
label=c,
histtype="step",
color=_COLORS[c],
)
else:
- self.axes.hist(data.ravel(), bins=bins, label=layer.name)
+ self.axes.hist(data.ravel(), bins=bins.tolist(), label=layer.name)
+ self._contrast_lines = [
+ self.axes.axvline(lim, color="white")
+ for lim in layer.contrast_limits
+ ]
self.axes.legend()
+
+
+class FeaturesHistogramWidget(SingleAxesWidget):
+ """
+ Display a histogram of selected feature attached to selected layer.
+ """
+
+ n_layers_input = Interval(1, 1)
+ # All layers that have a .features attributes
+ input_layer_types = FEATURES_LAYER_TYPES
+
+ def __init__(
+ self,
+ napari_viewer: napari.viewer.Viewer,
+ parent: QWidget | None = None,
+ ):
+ super().__init__(napari_viewer, parent=parent)
+
+ self.layout().addLayout(QVBoxLayout())
+ self._key_selection_widget = QComboBox()
+ self.layout().addWidget(QLabel("Key:"))
+ self.layout().addWidget(self._key_selection_widget)
+
+ self._key_selection_widget.currentTextChanged.connect(
+ self._set_axis_keys
+ )
+
+ self._update_layers(None)
+
+ @property
+ def x_axis_key(self) -> str | None:
+ """Key to access x axis data from the FeaturesTable"""
+ return self._x_axis_key
+
+ @x_axis_key.setter
+ def x_axis_key(self, key: str | None) -> None:
+ self._x_axis_key = key
+ self._draw()
+
+ def _set_axis_keys(self, x_axis_key: str) -> None:
+ """Set both axis keys and then redraw the plot"""
+ self._x_axis_key = x_axis_key
+ self._draw()
+
+ def _get_valid_axis_keys(self) -> list[str]:
+ """
+ Get the valid axis keys from the layer FeatureTable.
+
+ Returns
+ -------
+ axis_keys : List[str]
+ The valid axis keys in the FeatureTable. If the table is empty
+ or there isn't a table, returns an empty list.
+ """
+ if len(self.layers) == 0 or not (hasattr(self.layers[0], "features")):
+ return []
+ else:
+ return self.layers[0].features.keys()
+
+ def _get_data(self) -> tuple[npt.NDArray[Any] | None, str]:
+ """Get the plot data.
+
+ Returns
+ -------
+ data : List[np.ndarray]
+ List contains X and Y columns from the FeatureTable. Returns
+ an empty array if nothing to plot.
+ x_axis_name : str
+ The title to display on the x axis. Returns
+ an empty string if nothing to plot.
+ """
+ if not hasattr(self.layers[0], "features"):
+ # if the selected layer doesn't have a featuretable,
+ # skip draw
+ return None, ""
+
+ feature_table = self.layers[0].features
+
+ if (len(feature_table) == 0) or (self.x_axis_key is None):
+ return None, ""
+
+ data = feature_table[self.x_axis_key]
+ x_axis_name = self.x_axis_key.replace("_", " ")
+
+ return data, x_axis_name
+
+ def on_update_layers(self) -> None:
+ """
+ Called when the layer selection changes by ``self.update_layers()``.
+ """
+ # reset the axis keys
+ self._x_axis_key = None
+
+ # Clear combobox
+ self._key_selection_widget.clear()
+ self._key_selection_widget.addItems(self._get_valid_axis_keys())
+
+ def draw(self) -> None:
+ """Clear the axes and histogram the currently selected layer/slice."""
+ # get the colormap from the layer depending on its type
+ if isinstance(self.layers[0], napari.layers.Points):
+ colormap = self.layers[0].face_colormap
+ if self.x_axis_key:
+ self.layers[0].face_color = self.x_axis_key
+ elif isinstance(self.layers[0], napari.layers.Vectors):
+ colormap = self.layers[0].edge_colormap
+ if self.x_axis_key:
+ self.layers[0].edge_color = self.x_axis_key
+ else:
+ colormap = None
+
+ # apply new colors to the layer
+ self.viewer.layers[self.layers[0].name].refresh_colors(True)
+ self.viewer.layers[self.layers[0].name].refresh()
+
+ # Draw the histogram
+ data, x_axis_name = self._get_data()
+
+ if data is None:
+ return
+
+ bins = _get_bins(data)
+
+ _, bins, patches = self.axes.hist(data, bins=bins.tolist())
+ patches = cast(BarContainer, patches)
+
+ # recolor the histogram plot
+ if colormap is not None:
+ self.bins_norm = (bins - bins.min()) / (bins.max() - bins.min())
+ colors = colormap.map(self.bins_norm)
+
+ # Set histogram style:
+ for idx, patch in enumerate(patches):
+ patch.set_facecolor(colors[idx])
+
+ # set ax labels
+ self.axes.set_xlabel(x_axis_name)
+ self.axes.set_ylabel("Counts [#]")
diff --git a/src/napari_matplotlib/napari.yaml b/src/napari_matplotlib/napari.yaml
index b736592b..71af0ca6 100644
--- a/src/napari_matplotlib/napari.yaml
+++ b/src/napari_matplotlib/napari.yaml
@@ -14,6 +14,10 @@ contributions:
python_name: napari_matplotlib:FeaturesScatterWidget
title: Make a scatter plot of layer features
+ - id: napari-matplotlib.features_histogram
+ python_name: napari_matplotlib:FeaturesHistogramWidget
+ title: Plot feature histograms
+
- id: napari-matplotlib.slice
python_name: napari_matplotlib:SliceWidget
title: Plot a 1D slice
@@ -28,5 +32,8 @@ contributions:
- command: napari-matplotlib.features_scatter
display_name: FeaturesScatter
+ - command: napari-matplotlib.features_histogram
+ display_name: FeaturesHistogram
+
- command: napari-matplotlib.slice
display_name: 1D slice
diff --git a/src/napari_matplotlib/scatter.py b/src/napari_matplotlib/scatter.py
index db86c7f3..98ebe928 100644
--- a/src/napari_matplotlib/scatter.py
+++ b/src/napari_matplotlib/scatter.py
@@ -1,10 +1,11 @@
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import Any
import napari
import numpy.typing as npt
from qtpy.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget
from .base import SingleAxesWidget
+from .features import FEATURES_LAYER_TYPES
from .util import Interval
__all__ = ["ScatterBaseWidget", "ScatterWidget", "FeaturesScatterWidget"]
@@ -39,7 +40,7 @@ def draw(self) -> None:
self.axes.set_xlabel(x_axis_name)
self.axes.set_ylabel(y_axis_name)
- def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+ def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
"""
Get the plot data.
@@ -66,7 +67,7 @@ class ScatterWidget(ScatterBaseWidget):
n_layers_input = Interval(2, 2)
input_layer_types = (napari.layers.Image,)
- def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+ def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
"""
Get the plot data.
@@ -94,24 +95,18 @@ class FeaturesScatterWidget(ScatterBaseWidget):
n_layers_input = Interval(1, 1)
# All layers that have a .features attributes
- input_layer_types = (
- napari.layers.Labels,
- napari.layers.Points,
- napari.layers.Shapes,
- napari.layers.Tracks,
- napari.layers.Vectors,
- )
+ input_layer_types = FEATURES_LAYER_TYPES
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer, parent=parent)
self.layout().addLayout(QVBoxLayout())
- self._selectors: Dict[str, QComboBox] = {}
+ self._selectors: dict[str, QComboBox] = {}
for dim in ["x", "y"]:
self._selectors[dim] = QComboBox()
# Re-draw when combo boxes are updated
@@ -123,7 +118,7 @@ def __init__(
self._update_layers(None)
@property
- def x_axis_key(self) -> Union[str, None]:
+ def x_axis_key(self) -> str | None:
"""
Key for the x-axis data.
"""
@@ -138,7 +133,7 @@ def x_axis_key(self, key: str) -> None:
self._draw()
@property
- def y_axis_key(self) -> Union[str, None]:
+ def y_axis_key(self) -> str | None:
"""
Key for the y-axis data.
"""
@@ -152,7 +147,7 @@ def y_axis_key(self, key: str) -> None:
self._selectors["y"].setCurrentText(key)
self._draw()
- def _get_valid_axis_keys(self) -> List[str]:
+ def _get_valid_axis_keys(self) -> list[str]:
"""
Get the valid axis keys from the layer FeatureTable.
@@ -191,7 +186,7 @@ def draw(self) -> None:
if self._ready_to_scatter():
super().draw()
- def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+ def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
"""
Get the plot data from the ``features`` attribute of the first
selected layer.
diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py
index e3aa80b2..1924bf2b 100644
--- a/src/napari_matplotlib/slice.py
+++ b/src/napari_matplotlib/slice.py
@@ -1,19 +1,23 @@
-from typing import Any, Dict, Optional, Tuple
+from typing import Any
import matplotlib.ticker as mticker
import napari
import numpy as np
import numpy.typing as npt
-from qtpy.QtWidgets import QComboBox, QHBoxLayout, QLabel, QSpinBox, QWidget
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import (
+ QComboBox,
+ QLabel,
+ QSlider,
+ QVBoxLayout,
+ QWidget,
+)
from .base import SingleAxesWidget
from .util import Interval
__all__ = ["SliceWidget"]
-_dims_sel = ["x", "y"]
-_dims = ["x", "y", "z"]
-
class SliceWidget(SingleAxesWidget):
"""
@@ -26,33 +30,51 @@ class SliceWidget(SingleAxesWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
# Setup figure/axes
super().__init__(napari_viewer, parent=parent)
- button_layout = QHBoxLayout()
- self.layout().addLayout(button_layout)
-
self.dim_selector = QComboBox()
+ self.dim_selector.addItems(["x", "y"])
+
+ self.slice_selector = QSlider(orientation=Qt.Orientation.Horizontal)
+
+ # Create widget layout
+ button_layout = QVBoxLayout()
button_layout.addWidget(QLabel("Slice axis:"))
button_layout.addWidget(self.dim_selector)
- self.dim_selector.addItems(_dims)
-
- self.slice_selectors = {}
- for d in _dims_sel:
- self.slice_selectors[d] = QSpinBox()
- button_layout.addWidget(QLabel(f"{d}:"))
- button_layout.addWidget(self.slice_selectors[d])
+ button_layout.addWidget(self.slice_selector)
+ self.layout().addLayout(button_layout)
# Setup callbacks
- # Re-draw when any of the combon/spin boxes are updated
+ # Re-draw when any of the combo/slider is updated
self.dim_selector.currentTextChanged.connect(self._draw)
- for d in _dims_sel:
- self.slice_selectors[d].textChanged.connect(self._draw)
+ self.slice_selector.valueChanged.connect(self._draw)
self._update_layers(None)
+ def on_update_layers(self) -> None:
+ """
+ Called when layer selection is updated.
+ """
+ if not len(self.layers):
+ return
+ if self.current_dim_name == "x":
+ max = self._layer.data.shape[-2]
+ elif self.current_dim_name == "y":
+ max = self._layer.data.shape[-1]
+ else:
+ raise RuntimeError("dim name must be x or y")
+ self.slice_selector.setRange(0, max - 1)
+
+ @property
+ def _slice_width(self) -> int:
+ """
+ Width of the slice being plotted.
+ """
+ return self._layer.data.shape[self.current_dim_index]
+
@property
def _layer(self) -> napari.layers.Layer:
"""
@@ -61,7 +83,7 @@ def _layer(self) -> napari.layers.Layer:
return self.layers[0]
@property
- def current_dim(self) -> str:
+ def current_dim_name(self) -> str:
"""
Currently selected slice dimension.
"""
@@ -74,36 +96,40 @@ def current_dim_index(self) -> int:
"""
# Note the reversed list because in napari the z-axis is the first
# numpy axis
- return _dims[::-1].index(self.current_dim)
+ return self._dim_names.index(self.current_dim_name)
@property
- def _selector_values(self) -> Dict[str, int]:
+ def _dim_names(self) -> list[str]:
"""
- Values of the slice selectors.
+ List of dimension names. This is a property as it varies depending on the
+ dimensionality of the currently selected data.
"""
- return {d: self.slice_selectors[d].value() for d in _dims_sel}
-
- def _get_xy(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any]]:
+ if self._layer.data.ndim == 2:
+ return ["y", "x"]
+ elif self._layer.data.ndim == 3:
+ return ["z", "y", "x"]
+ else:
+ raise RuntimeError("Don't know how to handle ndim != 2 or 3")
+
+ def _get_xy(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any]]:
"""
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})
+ val = self.slice_selector.value()
slices = []
- for d in _dims:
- if d == self.current_dim:
+ for dim_name in self._dim_names:
+ if dim_name == self.current_dim_name:
# Select all data along this axis
slices.append(slice(None))
+ elif dim_name == "z":
+ # Only select the currently viewed z-index
+ slices.append(slice(self.current_z, self.current_z + 1))
else:
# Select specific index
- val = vals[d]
slices.append(slice(val, val + 1))
- # Reverse since z is the first axis in napari
- slices = slices[::-1]
+ x = np.arange(self._slice_width)
y = self._layer.data[tuple(slices)].ravel()
return x, y
@@ -115,7 +141,7 @@ def draw(self) -> None:
x, y = self._get_xy()
self.axes.plot(x, y)
- self.axes.set_xlabel(self.current_dim)
+ self.axes.set_xlabel(self.current_dim_name)
self.axes.set_title(self._layer.name)
# Make sure all ticks lie on integer values
self.axes.xaxis.set_major_locator(
diff --git a/src/napari_matplotlib/styles/README.md b/src/napari_matplotlib/styles/README.md
deleted file mode 100644
index 79d3c417..00000000
--- a/src/napari_matplotlib/styles/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-This folder contains default built-in Matplotlib style sheets.
-See https://matplotlib.org/stable/tutorials/introductory/customizing.html#defining-your-own-style
-for more info on Matplotlib style sheets.
diff --git a/src/napari_matplotlib/styles/dark.mplstyle b/src/napari_matplotlib/styles/dark.mplstyle
deleted file mode 100644
index 1658f9b4..00000000
--- a/src/napari_matplotlib/styles/dark.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# Dark-theme napari colour scheme for matplotlib plots
-
-# text (very light grey - almost white): #f0f1f2
-# foreground (mid grey): #414851
-# background (dark blue-gray): #262930
-
-figure.facecolor : none
-axes.labelcolor : f0f1f2
-axes.facecolor : none
-axes.edgecolor : 414851
-xtick.color : f0f1f2
-ytick.color : f0f1f2
diff --git a/src/napari_matplotlib/styles/light.mplstyle b/src/napari_matplotlib/styles/light.mplstyle
deleted file mode 100644
index 3b8d7d1d..00000000
--- a/src/napari_matplotlib/styles/light.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# Light-theme napari colour scheme for matplotlib plots
-
-# text (very dark grey - almost black): #3b3a39
-# foreground (mid grey): #d6d0ce
-# background (brownish beige): #efebe9
-
-figure.facecolor : none
-axes.labelcolor : 3b3a39
-axes.facecolor : none
-axes.edgecolor : d6d0ce
-xtick.color : 3b3a39
-ytick.color : 3b3a39
diff --git a/src/napari_matplotlib/tests/baseline/test_custom_theme.png b/src/napari_matplotlib/tests/baseline/test_custom_theme.png
index 65c43a49..ffa4635b 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_custom_theme.png and b/src/napari_matplotlib/tests/baseline/test_custom_theme.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram.png
new file mode 100644
index 00000000..1892af44
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png
new file mode 100644
index 00000000..88a28f79
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png
new file mode 100644
index 00000000..857d9344
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png
index b76d1e10..b9096e4d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png differ
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 00000000..98e3cde1
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png
index 2dffdcb2..ec4ad96d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_2D.png b/src/napari_matplotlib/tests/baseline/test_slice_2D.png
index 5b73091c..c1e67637 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_2D.png and b/src/napari_matplotlib/tests/baseline/test_slice_2D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_3D.png b/src/napari_matplotlib/tests/baseline/test_slice_3D.png
index 43c8c3b6..046293f3 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_3D.png and b/src/napari_matplotlib/tests/baseline/test_slice_3D.png differ
diff --git a/src/napari_matplotlib/tests/data/test_theme.mplstyle b/src/napari_matplotlib/tests/data/test_theme.mplstyle
deleted file mode 100644
index 2f94b31f..00000000
--- a/src/napari_matplotlib/tests/data/test_theme.mplstyle
+++ /dev/null
@@ -1,15 +0,0 @@
-# Dark-theme napari colour scheme for matplotlib plots
-
-#f4b8b2 # light red
-#b2e4f4 # light blue
-#0aa3fc # dark blue
-#008939 # dark green
-
-figure.facecolor : f4b8b2 # light red
-axes.facecolor : b2e4f4 # light blue
-axes.edgecolor : 0aa3fc # dark blue
-
-xtick.color : 008939 # dark green
-xtick.labelcolor : 008939 # dark green
-ytick.color : 008939 # dark green
-ytick.labelcolor : 008939 # dark green
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png
index 269ebd01..9237dbdc 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png
index 3b550666..a11bda5f 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png
index 27e7d673..cd42a8a2 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/test_scatter.py b/src/napari_matplotlib/tests/scatter/test_scatter.py
index a225863d..0c60660c 100644
--- a/src/napari_matplotlib/tests/scatter/test_scatter.py
+++ b/src/napari_matplotlib/tests/scatter/test_scatter.py
@@ -15,7 +15,9 @@ def test_scatter_2D(make_napari_viewer, astronaut_data):
viewer.add_image(astronaut_data[0], **astronaut_data[1], name="astronaut")
viewer.add_image(
- astronaut_data[0] * -1, **astronaut_data[1], name="astronaut_reversed"
+ astronaut_data[0] * -1.0,
+ **astronaut_data[1],
+ name="astronaut_reversed",
)
# De-select existing selection
viewer.layers.selection.clear()
@@ -36,7 +38,7 @@ def test_scatter_3D(make_napari_viewer, brain_data):
viewer.add_image(brain_data[0], **brain_data[1], name="brain")
viewer.add_image(
- brain_data[0] * -1, **brain_data[1], name="brain_reversed"
+ brain_data[0] * -1.0, **brain_data[1], name="brain_reversed"
)
# De-select existing selection
viewer.layers.selection.clear()
diff --git a/src/napari_matplotlib/tests/scatter/test_scatter_features.py b/src/napari_matplotlib/tests/scatter/test_scatter_features.py
index b5a396fd..3ede1e28 100644
--- a/src/napari_matplotlib/tests/scatter/test_scatter_features.py
+++ b/src/napari_matplotlib/tests/scatter/test_scatter_features.py
@@ -1,5 +1,5 @@
from copy import deepcopy
-from typing import Any, Dict, Tuple
+from typing import Any
import numpy as np
import numpy.typing as npt
@@ -34,7 +34,7 @@ def test_features_scatter_widget_2D(
def make_labels_layer_with_features() -> (
- Tuple[npt.NDArray[np.uint16], Dict[str, Any]]
+ tuple[npt.NDArray[np.uint16], dict[str, Any]]
):
label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
for label_value, start_index in enumerate([10, 30, 50], start=1):
diff --git a/src/napari_matplotlib/tests/test_histogram.py b/src/napari_matplotlib/tests/test_histogram.py
index 4d170014..435973ba 100644
--- a/src/napari_matplotlib/tests/test_histogram.py
+++ b/src/napari_matplotlib/tests/test_histogram.py
@@ -1,8 +1,27 @@
from copy import deepcopy
+import numpy as np
import pytest
-from napari_matplotlib import HistogramWidget
+from napari_matplotlib import FeaturesHistogramWidget, HistogramWidget
+from napari_matplotlib.tests.helpers import (
+ assert_figures_equal,
+ assert_figures_not_equal,
+)
+
+
+@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
@@ -28,3 +47,108 @@ def test_histogram_3D(make_napari_viewer, brain_data):
# Need to return a copy, as original figure is too eagerley garbage
# collected by the widget
return deepcopy(fig)
+
+
+def test_feature_histogram(make_napari_viewer):
+ n_points = 1000
+ random_points = np.random.random((n_points, 3)) * 10
+ random_directions = np.random.random((n_points, 3)) * 10
+ random_vectors = np.stack([random_points, random_directions], axis=1)
+ feature1 = np.random.random(n_points)
+ feature2 = np.random.normal(size=n_points)
+
+ viewer = make_napari_viewer()
+ viewer.add_points(
+ random_points,
+ properties={"feature1": feature1, "feature2": feature2},
+ name="points1",
+ )
+ viewer.add_vectors(
+ random_vectors,
+ properties={"feature1": feature1, "feature2": feature2},
+ name="vectors1",
+ )
+
+ widget = FeaturesHistogramWidget(viewer)
+ viewer.window.add_dock_widget(widget)
+
+ # Check whether changing the selected key changes the plot
+ widget._set_axis_keys("feature1")
+ fig1 = deepcopy(widget.figure)
+
+ widget._set_axis_keys("feature2")
+ assert_figures_not_equal(widget.figure, fig1)
+
+ # check whether selecting a different layer produces the same plot
+ viewer.layers.selection.clear()
+ viewer.layers.selection.add(viewer.layers[1])
+ assert_figures_equal(widget.figure, fig1)
+
+
+@pytest.mark.mpl_image_compare
+def test_feature_histogram_vectors(make_napari_viewer):
+ n_points = 1000
+ np.random.seed(42)
+ random_points = np.random.random((n_points, 3)) * 10
+ random_directions = np.random.random((n_points, 3)) * 10
+ random_vectors = np.stack([random_points, random_directions], axis=1)
+ feature1 = np.random.random(n_points)
+
+ viewer = make_napari_viewer()
+ viewer.add_vectors(
+ random_vectors,
+ properties={"feature1": feature1},
+ name="vectors1",
+ )
+
+ widget = FeaturesHistogramWidget(viewer)
+ viewer.window.add_dock_widget(widget)
+ widget._set_axis_keys("feature1")
+
+ fig = FeaturesHistogramWidget(viewer).figure
+ return deepcopy(fig)
+
+
+@pytest.mark.mpl_image_compare
+def test_feature_histogram_points(make_napari_viewer):
+ np.random.seed(0)
+ n_points = 1000
+ random_points = np.random.random((n_points, 3)) * 10
+ feature1 = np.random.random(n_points)
+
+ viewer = make_napari_viewer()
+ viewer.add_points(
+ random_points,
+ properties={"feature1": feature1},
+ name="points1",
+ )
+
+ widget = FeaturesHistogramWidget(viewer)
+ viewer.window.add_dock_widget(widget)
+ widget._set_axis_keys("feature1")
+
+ fig = FeaturesHistogramWidget(viewer).figure
+ return deepcopy(fig)
+
+
+def test_change_layer(make_napari_viewer, brain_data, astronaut_data):
+ viewer = make_napari_viewer()
+ widget = HistogramWidget(viewer)
+
+ viewer.add_image(brain_data[0], **brain_data[1])
+ viewer.add_image(astronaut_data[0], **astronaut_data[1])
+
+ # Select first layer
+ viewer.layers.selection.clear()
+ viewer.layers.selection.add(viewer.layers[0])
+ fig1 = deepcopy(widget.figure)
+
+ # Re-selecting first layer should produce identical plot
+ viewer.layers.selection.clear()
+ viewer.layers.selection.add(viewer.layers[0])
+ assert_figures_equal(widget.figure, fig1)
+
+ # Plotting the second layer should produce a different plot
+ viewer.layers.selection.clear()
+ viewer.layers.selection.add(viewer.layers[1])
+ assert_figures_not_equal(widget.figure, fig1)
diff --git a/src/napari_matplotlib/tests/test_layer_changes.py b/src/napari_matplotlib/tests/test_layer_changes.py
index bdd6c600..15958c07 100644
--- a/src/napari_matplotlib/tests/test_layer_changes.py
+++ b/src/napari_matplotlib/tests/test_layer_changes.py
@@ -1,5 +1,5 @@
from copy import deepcopy
-from typing import Any, Dict, Tuple, Type
+from typing import Any
import numpy as np
import numpy.typing as npt
@@ -61,8 +61,8 @@ def test_change_features_layer(
def assert_features_plot_changes(
viewer: Viewer,
- widget_cls: Type[NapariMPLWidget],
- data: Tuple[npt.NDArray[np.generic], Dict[str, Any]],
+ widget_cls: type[NapariMPLWidget],
+ data: tuple[npt.NDArray[np.generic], dict[str, Any]],
) -> None:
"""
When the selected layer is changed, make sure the plot generated
diff --git a/src/napari_matplotlib/tests/test_slice.py b/src/napari_matplotlib/tests/test_slice.py
index 412e71c3..368a7ded 100644
--- a/src/napari_matplotlib/tests/test_slice.py
+++ b/src/napari_matplotlib/tests/test_slice.py
@@ -9,9 +9,13 @@
def test_slice_3D(make_napari_viewer, brain_data):
viewer = make_napari_viewer()
viewer.theme = "light"
- viewer.add_image(brain_data[0], **brain_data[1])
+
+ data = brain_data[0]
+ assert data.ndim == 3, data.shape
+ viewer.add_image(data, **brain_data[1])
+
axis = viewer.dims.last_used
- slice_no = brain_data[0].shape[0] - 1
+ slice_no = data.shape[0] - 1
viewer.dims.set_current_step(axis, slice_no)
fig = SliceWidget(viewer).figure
# Need to return a copy, as original figure is too eagerley garbage
@@ -23,8 +27,37 @@ def test_slice_3D(make_napari_viewer, brain_data):
def test_slice_2D(make_napari_viewer, astronaut_data):
viewer = make_napari_viewer()
viewer.theme = "light"
- viewer.add_image(astronaut_data[0], **astronaut_data[1])
+
+ # Take first RGB channel
+ data = astronaut_data[0][:, :, 0]
+ assert data.ndim == 2, data.shape
+ viewer.add_image(data)
+
fig = SliceWidget(viewer).figure
# Need to return a copy, as original figure is too eagerley garbage
# collected by the widget
return deepcopy(fig)
+
+
+def test_slice_axes(make_napari_viewer, astronaut_data):
+ viewer = make_napari_viewer()
+ viewer.theme = "light"
+
+ # Take first RGB channel
+ data = astronaut_data[0][:256, :, 0]
+ # Shape:
+ # x: 0 > 512
+ # y: 0 > 256
+ assert data.ndim == 2, data.shape
+ # Make sure data isn't square for later tests
+ assert data.shape[0] != data.shape[1]
+ viewer.add_image(data)
+
+ widget = SliceWidget(viewer)
+ assert widget._dim_names == ["y", "x"]
+ assert widget.current_dim_name == "x"
+ assert widget.slice_selector.value() == 0
+ assert widget.slice_selector.minimum() == 0
+ assert widget.slice_selector.maximum() == data.shape[0] - 1
+ # x/y are flipped in napari
+ assert widget._slice_width == data.shape[1]
diff --git a/src/napari_matplotlib/tests/test_theme.py b/src/napari_matplotlib/tests/test_theme.py
index a3642f8f..5fedc43d 100644
--- a/src/napari_matplotlib/tests/test_theme.py
+++ b/src/napari_matplotlib/tests/test_theme.py
@@ -1,15 +1,8 @@
-import os
-import shutil
-from copy import deepcopy
-from pathlib import Path
-
-import matplotlib
import napari
import numpy as np
import pytest
-from matplotlib.colors import to_rgba
-from napari_matplotlib import HistogramWidget, ScatterWidget
+from napari_matplotlib import ScatterWidget
from napari_matplotlib.base import NapariMPLWidget
@@ -36,10 +29,12 @@ def _mock_up_theme() -> None:
Based on:
https://napari.org/stable/gallery/new_theme.html
"""
- blue_theme = napari.utils.theme.get_theme("dark", False)
- blue_theme.name = "blue"
+ blue_theme = napari.utils.theme.get_theme("dark")
+ blue_theme.label = "blue"
blue_theme.background = "#4169e1" # my favourite shade of blue
- napari.utils.theme.register_theme("blue", blue_theme)
+ napari.utils.theme.register_theme(
+ "blue", blue_theme, source="napari-mpl-tests"
+ )
def test_theme_background_check(make_napari_viewer):
@@ -125,66 +120,3 @@ def test_no_theme_side_effects(make_napari_viewer):
unrelated_figure.tight_layout()
return unrelated_figure
-
-
-@pytest.mark.mpl_image_compare
-def test_custom_theme(make_napari_viewer, theme_path, brain_data):
- viewer = make_napari_viewer()
- viewer.theme = "dark"
-
- widget = ScatterWidget(viewer)
- widget.mpl_style_sheet_path = theme_path
-
- viewer.add_image(brain_data[0], **brain_data[1], name="brain")
- viewer.add_image(
- brain_data[0] * -1, **brain_data[1], name="brain_reversed"
- )
-
- viewer.layers.selection.clear()
- viewer.layers.selection.add(viewer.layers[0])
- viewer.layers.selection.add(viewer.layers[1])
-
- return deepcopy(widget.figure)
-
-
-def find_mpl_stylesheet(name: str) -> Path:
- """Find the built-in matplotlib stylesheet."""
- return Path(matplotlib.__path__[0]) / f"mpl-data/stylelib/{name}.mplstyle"
-
-
-def test_custom_stylesheet(make_napari_viewer, image_data):
- """
- Test that a stylesheet in the current directory is given precidence.
-
- Do this by copying over a stylesheet from matplotlib's built in styles,
- naming it correctly, and checking the colours are as expected.
- """
- # Copy Solarize_Light2 as if it was a user-overriden stylesheet.
- style_sheet_path = (
- Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
- )
- if style_sheet_path.exists():
- pytest.skip("Won't ovewrite existing custom style sheet.")
- shutil.copy(
- find_mpl_stylesheet("Solarize_Light2"),
- style_sheet_path,
- )
-
- try:
- viewer = make_napari_viewer()
- viewer.add_image(image_data[0], **image_data[1])
- widget = HistogramWidget(viewer)
- assert widget.mpl_style_sheet_path == style_sheet_path
- ax = widget.figure.gca()
-
- # The axes should have a light brownish grey background:
- assert ax.get_facecolor() == to_rgba("#eee8d5")
- assert ax.patch.get_facecolor() == to_rgba("#eee8d5")
-
- # The figure background and axis gridlines are light yellow:
- assert widget.figure.patch.get_facecolor() == to_rgba("#fdf6e3")
- for gridline in ax.get_xgridlines() + ax.get_ygridlines():
- assert gridline.get_visible() is True
- assert gridline.get_color() == "#fdf6e3"
- finally:
- os.remove(style_sheet_path)
diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py
index a8792d41..e966cc26 100644
--- a/src/napari_matplotlib/tests/test_util.py
+++ b/src/napari_matplotlib/tests/test_util.py
@@ -26,7 +26,7 @@ def test_interval():
assert 10 not in interval
with pytest.raises(ValueError, match="must be an integer"):
- "string" in interval # type: ignore
+ assert "string" in interval # type: ignore[operator]
with pytest.raises(ValueError, match="must be <= upper_bound"):
Interval(5, 3)
@@ -69,7 +69,10 @@ def test_fallback_if_missing_dimensions(mocker):
test_css = " Flobble { background-color: rgb(0, 97, 163); } "
mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css
with pytest.warns(RuntimeWarning, match="Unable to find DimensionToken"):
- assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(1, 2)
+ with pytest.warns(RuntimeWarning, match="Unable to find Flobble"):
+ assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(
+ 1, 2
+ )
def test_fallback_if_prelude_not_in_css():
diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py
index 2aa15ddd..8d4150c3 100644
--- a/src/napari_matplotlib/util.py
+++ b/src/napari_matplotlib/util.py
@@ -1,8 +1,8 @@
-from typing import List, Optional, Tuple, Union
from warnings import warn
import napari.qt
import tinycss2
+from napari.utils.theme import Theme
from qtpy.QtCore import QSize
@@ -11,7 +11,7 @@ class Interval:
An integer interval.
"""
- def __init__(self, lower_bound: Optional[int], upper_bound: Optional[int]):
+ def __init__(self, lower_bound: int | None, upper_bound: int | None):
"""
Parameters
----------
@@ -47,7 +47,7 @@ def __contains__(self, val: int) -> bool:
return True
@property
- def _helper_text(self) -> Optional[str]:
+ def _helper_text(self) -> str | None:
"""
Helper text for widgets.
"""
@@ -76,7 +76,7 @@ def _helper_text(self) -> Optional[str]:
return helper_text
-def _has_id(nodes: List[tinycss2.ast.Node], id_name: str) -> bool:
+def _has_id(nodes: list[tinycss2.ast.Node], id_name: str) -> bool:
"""
Is `id_name` in IdentTokens in the list of CSS `nodes`?
"""
@@ -85,9 +85,7 @@ def _has_id(nodes: List[tinycss2.ast.Node], id_name: str) -> bool:
)
-def _get_dimension(
- nodes: List[tinycss2.ast.Node], id_name: str
-) -> Union[int, None]:
+def _get_dimension(nodes: list[tinycss2.ast.Node], id_name: str) -> int | None:
"""
Get the value of the DimensionToken for the IdentToken `id_name`.
@@ -96,19 +94,23 @@ def _get_dimension(
None if no IdentToken is found.
"""
cleaned_nodes = [node for node in nodes if node.type != "whitespace"]
- for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4):
+ for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4, strict=False):
if (
name.type == "ident"
and value.type == "dimension"
and name.value == id_name
):
return value.int_value
- warn(f"Unable to find DimensionToken for {id_name}", RuntimeWarning)
+ warn(
+ f"Unable to find DimensionToken for {id_name}",
+ RuntimeWarning,
+ stacklevel=1,
+ )
return None
def from_napari_css_get_size_of(
- qt_element_name: str, fallback: Tuple[int, int]
+ qt_element_name: str, fallback: tuple[int, int]
) -> QSize:
"""
Get the size of `qt_element_name` from napari's current stylesheet.
@@ -136,5 +138,52 @@ def from_napari_css_get_size_of(
f"Unable to find {qt_element_name} or unable to find its size in "
f"the current Napari stylesheet, falling back to {fallback}",
RuntimeWarning,
+ stacklevel=1,
)
return QSize(*fallback)
+
+
+def style_sheet_from_theme(theme: Theme) -> dict[str, str]:
+ """Translate napari theme to a matplotlib style dictionary.
+
+ Parameters
+ ----------
+ theme : napari.utils.theme.Theme
+ Napari theme object representing the theme of the current viewer.
+
+ Returns
+ -------
+ Dict[str, str]
+ Matplotlib compatible style dictionary.
+ """
+ return {
+ "axes.edgecolor": theme.secondary.as_hex(),
+ # BUG: could be the same as napari canvas, but facecolors do not get
+ # updated upon redraw for what ever reason
+ #'axes.facecolor':theme.canvas.as_hex(),
+ "axes.facecolor": "none",
+ "axes.labelcolor": theme.text.as_hex(),
+ "boxplot.boxprops.color": theme.text.as_hex(),
+ "boxplot.capprops.color": theme.text.as_hex(),
+ "boxplot.flierprops.markeredgecolor": theme.text.as_hex(),
+ "boxplot.whiskerprops.color": theme.text.as_hex(),
+ "figure.edgecolor": theme.secondary.as_hex(),
+ # BUG: should be the same as napari background, but facecolors do not get
+ # updated upon redraw for what ever reason
+ #'figure.facecolor':theme.background.as_hex(),
+ "figure.facecolor": "none",
+ "grid.color": theme.foreground.as_hex(),
+ # COMMENT: the hard coded colors are to match the previous behaviour
+ # alternativly we could use the theme to style the legend as well
+ #'legend.edgecolor':theme.secondary.as_hex(),
+ "legend.edgecolor": "black",
+ #'legend.facecolor':theme.background.as_hex(),
+ "legend.facecolor": "white",
+ #'legend.labelcolor':theme.text.as_hex()
+ "legend.labelcolor": "black",
+ "text.color": theme.text.as_hex(),
+ "xtick.color": theme.secondary.as_hex(),
+ "xtick.labelcolor": theme.text.as_hex(),
+ "ytick.color": theme.secondary.as_hex(),
+ "ytick.labelcolor": theme.text.as_hex(),
+ }
diff --git a/tox.ini b/tox.ini
index 298887e1..f4aed6a8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,19 +1,14 @@
[tox]
-envlist = py{38,39,310}
+envlist = py{310,311,312}
isolated_build = true
[gh-actions]
python =
- 3.8: py38
- 3.9: py39
3.10: py310
+ 3.11: py311
+ 3.12: py312
[testenv]
extras = testing
-allowlist_externals =
- cp
- ls
commands =
- cp -R {toxinidir}/src/napari_matplotlib/tests/baseline {envdir}/baseline
- ls {toxinidir}/src/napari_matplotlib/tests/baseline
python -m pytest --mpl --mpl-generate-summary=html --mpl-results-path={toxinidir}/reports -v --color=yes --cov=napari_matplotlib --cov-report=xml
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
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:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy