diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..af3b9f02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 13ce3b85..6a5d182f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,6 @@ name: Build docs +permissions: + contents: read on: @@ -10,19 +12,22 @@ on: - main tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + workflow_dispatch: jobs: build-docs: name: Build & Upload Artifact runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: "3.10" - - uses: tlambert03/setup-qt-libs@v1 + - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1 - name: Install Dependencies run: | @@ -31,13 +36,13 @@ jobs: sudo apt install graphviz --yes - name: Build Docs - uses: aganders3/headless-gui@v1 + uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2 with: run: make html working-directory: ./docs - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: docs path: docs/_build @@ -48,13 +53,15 @@ jobs: needs: build-docs if: contains(github.ref, 'tags') steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4.3.0 with: name: docs - name: Push to GitHub pages - uses: JamesIves/github-pages-deploy-action@v4 + uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4 with: folder: html ssh-key: ${{ secrets.DEPLOY_KEY }} diff --git a/.github/workflows/napari_hub_preview.yml b/.github/workflows/napari_hub_preview.yml deleted file mode 100644 index c204ac45..00000000 --- a/.github/workflows/napari_hub_preview.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: napari hub Preview Page # we use this name to find your preview page artifact, so don't change it! -# For more info on this action, see https://github.com/chanzuckerberg/napari-hub-preview-action/blob/main/action.yml - -on: - pull_request: - types: [ labeled ] - -jobs: - preview-page: - if: ${{ github.event.label.name == 'napari hub preview' }} - name: Preview Page Deploy - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - - name: napari hub Preview Page Builder - uses: chanzuckerberg/napari-hub-preview-action@v0.1 - with: - hub-ref: main diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index df170bdf..0521c9e6 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -1,4 +1,6 @@ name: tests +permissions: + contents: read on: push: @@ -12,7 +14,24 @@ on: workflow_dispatch: merge_group: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + pre-commit: + name: precommit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 + with: + python-version: "3.x" + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + with: + extra_args: --hook-stage manual --all-files test: name: ${{ matrix.platform }} py${{ matrix.python-version }} runs-on: ${{ matrix.platform }} @@ -20,18 +39,20 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} # these libraries enable testing on Qt on linux - - uses: tlambert03/setup-qt-libs@v1 + - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1 # strategy borrowed from vispy for installing opengl libs on windows - name: Install Windows OpenGL @@ -50,7 +71,7 @@ jobs: run: python -m tox - name: Upload pytest test results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pytest-results-${{ matrix.platform }} py${{ matrix.python-version }} path: reports/ @@ -58,13 +79,15 @@ jobs: if: ${{ always() }} - name: Coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 # Don't run coverage on merge queue CI to avoid duplicating reports # to codecov. See https://github.com/matplotlib/napari-matplotlib/issues/155 if: github.event_name != 'merge_group' with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false + + deploy: # this will run when you have tagged a commit, starting with "v*" @@ -77,9 +100,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install build @@ -93,4 +118,4 @@ jobs: python -m build . - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71aa4ae5..e592dea1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,13 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: check-docstring-first - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.4.0 - hooks: - - id: setup-cfg-fmt - - - repo: https://github.com/psf/black - rev: 23.3.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.1.0 hooks: - id: black @@ -22,14 +17,14 @@ repos: - id: napari-plugin-checks - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 + rev: v1.15.0 hooks: - id: mypy additional_dependencies: [numpy, matplotlib] - - repo: https://github.com/charliermarsh/ruff-pre-commit + - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: 'v0.0.276' + rev: 'v0.11.9' hooks: - id: ruff diff --git a/MANIFEST.in b/MANIFEST.in index d625d95e..7ce16f9b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,5 @@ include LICENSE include README.md -recursive-include * *.mplstyle recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/README.md b/README.md index 855c4991..fb7aa635 100644 --- a/README.md +++ b/README.md @@ -15,32 +15,7 @@ A plugin to create Matplotlib plots from napari layers ## Introduction `napari-matplotlib` is a bridge between `napari` and `matplotlib`, making it easy to create publication quality `Matplotlib` plots based on the data loaded in `napari` layers. -## Available widgets - -### `Slice` -Plots 1D slices of data along a specified axis. -![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/slice.png) - -### `Histogram` -Plots histograms of individual image layers, or RGB histograms of an RGB image -![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/hist.png) - -### `Scatter` -Scatters the values of two similarly sized images layers against each other. -![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/scatter.png) - -## Installation - -You can install `napari-matplotlib` via [pip]: - - pip install napari-matplotlib - - - -To install latest development version : - - pip install git+https://github.com/matplotlib/napari-matplotlib.git - +Documentation can be found at https://napari-matplotlib.github.io/ ## Contributing diff --git a/docs/changelog.rst b/docs/changelog.rst index a31a0d3a..60dd72ba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,99 @@ 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 +~~~~~~~~~ +- Fix an error that happened when the histogram widget was open, but a layer that doesn't support + histogramming (e.g., a labels layer) was selected. + +2.0.2 +----- +Dependencies +~~~~~~~~~~~~ +napari-matplotlib now adheres to `SPEC 0 `_, and has: + +- Dropped support for Python 3.9 +- Added support for Python 3.12 +- Added a minimum required numpy verison of 1.23 +- Pinned the maximum napari version to ``< 0.5``. + Version 3.0 of ``napari-matplotlib`` will introduce support for ``napari`` version 0.5. + +2.0.1 +----- +Bug fixes +~~~~~~~~~ +- Fixed using the ``HistogramWidget`` with layers containing multiscale data. +- Make sure ``HistogramWidget`` uses 100 bins (not 99) when floating point data is + selected. + +2.0.0 +----- +Changes to custom theming +~~~~~~~~~~~~~~~~~~~~~~~~~ +``napari-matplotlib`` now uses colours from the current napari theme to customise the +Matplotlib plots. See `the example on creating a new napari theme +`_ for a helpful guide on how to +create custom napari themes. + +This means support for custom Matplotlib styles sheets has been removed. + +If you spot any issues with the new theming, please report them at +https://github.com/matplotlib/napari-matplotlib/issues. + +Other changes +~~~~~~~~~~~~~ +- Histogram bin sizes for integer-type data are now force to be an integer. +- 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``. + +1.2.0 +----- +Changes +~~~~~~~ +- Dropped support for Python 3.8, and added support for Python 3.11. +- Histogram plots of points and vector layers are now coloured with their napari colourmap. +- Added support for Matplotlib 3.8 + +1.1.0 +----- +Additions +~~~~~~~~~ +- Added a widget to draw a histogram of features. + +Changes +~~~~~~~ +- The slice widget is now limited to slicing along the x/y dimensions. Support + for slicing along z has been removed for now to make the code simpler. +- The slice widget now uses a slider to select the slice value. + +Bug fixes +~~~~~~~~~ +- Fixed creating 1D slices of 2D images. +- Removed the limitation that only the first 99 indices could be sliced using + the slice widget. + +1.0.2 +----- +Bug fixes +~~~~~~~~~ +- A full dataset is no longer read into memory when using ``HistogramWidget``. + Only the current slice is loaded. +- Fixed compatibility with napari 0.4.18. + +Changes +~~~~~~~ +- Histogram bin limits are now caclualted from the slice being histogrammed, and + not the whole dataset. This is as a result of the above bug fix. + 1.0.1 ----- Bug fixes diff --git a/docs/conf.py b/docs/conf.py index 2517a59c..f1533830 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ # import os # import sys # sys.path.insert(0, os.path.abspath('.')) -import qtgallery +from sphinx_gallery import scrapers # -- Project information ----------------------------------------------------- @@ -35,18 +35,58 @@ "sphinx.ext.intersphinx", ] + +def reset_napari(gallery_conf, fname): # type: ignore[no-untyped-def] + from napari.settings import get_settings + from qtpy.QtWidgets import QApplication + + settings = get_settings() + settings.appearance.theme = "dark" + + # Disabling `QApplication.exec_` means example scripts can call `exec_` + # (scripts work when run normally) without blocking example execution by + # sphinx-gallery. (from qtgallery) + QApplication.exec_ = lambda _: None + + +def napari_scraper(block, block_vars, gallery_conf): # type: ignore[no-untyped-def] + """Basic napari window scraper. + + Looks for any QtMainWindow instances and takes a screenshot of them. + + `app.processEvents()` allows Qt events to propagateo and prevents hanging. + """ + import napari + + imgpath_iter = block_vars["image_path_iterator"] + + if app := napari.qt.get_app(): + app.processEvents() + else: + return "" + + img_paths = [] + for win, img_path in zip( + reversed(napari._qt.qt_main_window._QtMainWindow._instances), + imgpath_iter, + strict=False, + ): + img_paths.append(img_path) + win._window.screenshot(img_path, canvas_only=False) + + napari.Viewer.close_all() + app.processEvents() + + return scrapers.figure_rst(img_paths, gallery_conf["src_dir"]) + + sphinx_gallery_conf = { "filename_pattern": ".", - "image_scrapers": (qtgallery.qtscraper,), - "reset_modules": (qtgallery.reset_qapp,), + "image_scrapers": (napari_scraper,), + "reset_modules": (reset_napari,), } +suppress_warnings = ["config.cache"] -qtgallery_conf = { - "xvfb_size": (640, 480), - "xvfb_color_depth": 24, - "xfvb_use_xauth": False, - "xfvb_extra_args": [], -} numpydoc_show_class_members = False automodapi_inheritance_diagram = True diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 0872e540..253e3149 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -30,6 +30,7 @@ These widgets plot the data stored in the ``.features`` attribute of individual Currently available are: - 2D scatter plots of two features against each other. +- Histograms of individual features. To use these: @@ -39,11 +40,6 @@ To use these: Customising plots ----------------- -`Matplotlib style sheets `__ can be used to customise -the plots generated by ``napari-matplotlib``. -To use a custom style sheet: - -1. Save it as ``napari-matplotlib.mplstyle`` -2. Put it in the Matplotlib configuration directory. - The location of this directory varies on different computers, - and can be found by calling :func:`matplotlib.get_configdir()`. +``napari-matplotlib`` uses colours from the current napari theme to customise the +Matplotlib plots. See `the example on creating a new napari theme +`_ 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