├── .github ├── ISSUE_TEMPLATE.md ├── TEST_FAIL_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── deploy_docs.yml ├── .github_changelog_generator ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── _overrides │ └── main.html ├── cookbook │ ├── embedding.md │ └── streaming.md ├── css │ ├── install-table.css │ ├── material.css │ └── ndv.css ├── env_var.md ├── hooks.py ├── index.md ├── install.md ├── js │ └── install-table.js └── motivation.md ├── examples ├── README.md ├── backend_test │ ├── pygfx_mwe.py │ └── vispy_mwe.py ├── cookbook │ ├── microscope_dashboard.py │ ├── multi_ndv.py │ └── ndv_embedded.py ├── custom_store.py ├── dask_arr.py ├── jax_arr.py ├── notebook.ipynb ├── numpy_arr.py ├── pyopencl_arr.py ├── rgb.py ├── sparse_arr.py ├── streaming.py ├── streaming_with_history.py ├── tensorstore_arr.py ├── torch_arr.py ├── xarray_arr.py └── zarr_arr.py ├── mkdocs.yml ├── pyproject.toml ├── src └── ndv │ ├── __init__.py │ ├── _types.py │ ├── controllers │ ├── __init__.py │ ├── _array_viewer.py │ └── _channel_controller.py │ ├── data.py │ ├── models │ ├── __init__.py │ ├── _array_display_model.py │ ├── _base_model.py │ ├── _data_display_model.py │ ├── _data_wrapper.py │ ├── _lut_model.py │ ├── _mapping.py │ ├── _reducer.py │ ├── _ring_buffer.py │ ├── _roi_model.py │ └── _viewer_model.py │ ├── py.typed │ ├── util.py │ └── views │ ├── __init__.py │ ├── _app.py │ ├── _jupyter │ ├── __init__.py │ ├── _app.py │ └── _array_view.py │ ├── _pygfx │ ├── __init__.py │ ├── _array_canvas.py │ ├── _histogram.py │ └── _util.py │ ├── _qt │ ├── __init__.py │ ├── _app.py │ ├── _array_view.py │ ├── _main_thread.py │ └── _save_button.py │ ├── _resources │ └── spin.gif │ ├── _vispy │ ├── __init__.py │ ├── _array_canvas.py │ ├── _histogram.py │ └── _plot_widget.py │ ├── _wx │ ├── __init__.py │ ├── _app.py │ ├── _array_view.py │ ├── _labeled_slider.py │ ├── _main_thread.py │ └── range_slider.py │ └── bases │ ├── __init__.py │ ├── _app.py │ ├── _array_view.py │ ├── _graphics │ ├── __init__.py │ ├── _canvas.py │ ├── _canvas_elements.py │ └── _mouseable.py │ ├── _lut_view.py │ └── _view_base.py ├── tests ├── conftest.py ├── test_app.py ├── test_controller.py ├── test_examples.py ├── test_models.py ├── test_ring_buffer.py └── views │ ├── _jupyter │ ├── __init__.py │ ├── test_array_view.py │ └── test_lut_view.py │ ├── _pygfx │ ├── __init__.py │ └── test_histogram.py │ ├── _qt │ ├── __init__.py │ ├── conftest.py │ ├── test_array_view.py │ └── test_lut_view.py │ ├── _vispy │ ├── __init__.py │ └── test_histogram.py │ └── _wx │ ├── __init__.py │ ├── test_array_view.py │ └── test_lut_view.py └── uv.lock /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * ndv version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/TEST_FAIL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ env.TITLE }}" 3 | labels: [bug] 4 | --- 5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 6 | 7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} 8 | with commit: {{ sha }} 9 | 10 | Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} 11 | 12 | (This post will be updated if another test fails, as long as this issue remains open.) 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish release documentation 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: [v*] 7 | 8 | permissions: 9 | contents: write 10 | pages: write 11 | 12 | jobs: 13 | deploy: 14 | runs-on: macos-latest # nicer screenshots 15 | if: github.repository == 'pyapp-kit/ndv' 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: astral-sh/setup-uv@v6 21 | with: 22 | enable-cache: true 23 | 24 | - name: Config git 25 | run: | 26 | git config user.name github-actions[bot] 27 | git config user.email github-actions[bot]@users.noreply.github.com 28 | git fetch origin gh-pages --depth=1 29 | 30 | - name: Deploy release docs 31 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 32 | run: | 33 | VERSION=$(git describe --abbrev=0 --tags) 34 | # check if rc or beta release 35 | if [[ $VERSION == *"rc"* ]] || [[ $VERSION == *"beta"* ]]; then 36 | export DOCS_PRERELEASE=true 37 | echo "Deploying pre-release docs" 38 | uv run --group docs mike deploy --push --update-aliases $VERSION rc 39 | else 40 | echo "Deploying release docs" 41 | uv run --group docs mike deploy --push --update-aliases $VERSION latest 42 | fi 43 | env: 44 | DOCS_DEV: false 45 | 46 | - name: Deploy dev docs 47 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 48 | run: uv run --group docs mike deploy --push --update-aliases dev 49 | env: 50 | DOCS_DEV: true 51 | 52 | # - name: Update default release docs 53 | # run: mike set-default --push latest 54 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | user=pyapp-kit 2 | project=ndv 3 | issues=false 4 | exclude-labels=duplicate,question,invalid,wontfix,hide 5 | add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} 6 | exclude-tags-regex=.*rc -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | .DS_Store 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # ruff 107 | .ruff_cache/ 108 | 109 | # IDE settings 110 | .vscode/ 111 | .idea/ 112 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" 4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/abravalheri/validate-pyproject 8 | rev: v0.24.1 9 | hooks: 10 | - id: validate-pyproject 11 | 12 | - repo: https://github.com/crate-ci/typos 13 | rev: v1.32.0 14 | hooks: 15 | - id: typos 16 | args: [--force-exclude] # omitting --write-changes 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.11.8 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --unsafe-fixes] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.15.0 27 | hooks: 28 | - id: mypy 29 | files: "^src/" 30 | additional_dependencies: 31 | - numpy 32 | - pydantic 33 | - psygnal 34 | - IPython 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ndv 2 | 3 | Contributions are welcome. Please don't hesitate to open an issue if you have 4 | any questions about the structure/design of the codebase. 5 | 6 | ## Setup 7 | 8 | We recommend using `uv` when developing ndv. `uv` is an awesome tool that 9 | manages virtual environments, python interpreters, and package dependencies. 10 | Because `ndv` aims to support so many combinations of GUI and graphics 11 | frameworks, it's not unusual to have multiple virtual environments for 12 | different combinations of dependencies. `uv` makes this easy. 13 | 14 | [Install `uv`](https://docs.astral.sh/uv/getting-started/installation/), 15 | then clone the repository: 16 | 17 | ```bash 18 | git clone https://github.com/pyapp-kit/ndv 19 | cd ndv 20 | uv sync 21 | ``` 22 | 23 | This will create a virtual environment in `.venv` and install the "standard" 24 | set of dev dependencies (pretty much everything except for PySide6). 25 | You can then activate the environment with: 26 | 27 | For macOS/Linux: 28 | 29 | ```bash 30 | source .venv/bin/activate 31 | ``` 32 | 33 | For Windows: 34 | 35 | ```cmd 36 | .\.venv\Scripts\activate 37 | ``` 38 | 39 | ## Testing 40 | 41 | As usual, you can run the tests with `pytest`: 42 | 43 | ```bash 44 | uv run pytest 45 | ``` 46 | 47 | (Or just `pytest` if you've already activate your environment). 48 | 49 | The makefile also has a few targets for running tests (these all 50 | depend on having `uv` installed): 51 | 52 | ```bash 53 | # just test with something standard (currently pyqt6/pygfx) 54 | make test 55 | ``` 56 | 57 | ### Testing with different dependencies 58 | 59 | To test different variants of `pygfx`, `vispy`, `pyqt`, `pyside`, `wx`: 60 | use extras or groups to add specific members of 61 | `project.optional-dependencies` or `project.dependency-groups` 62 | declared in `pyproject.toml`. 63 | 64 | ```bash 65 | # run all 66 | make test extras=pyqt,vispy groups=array-libs 67 | ``` 68 | 69 | > [!TIP] 70 | > that above command actually has an alias: 71 | > 72 | > ```bash 73 | > make test-arrays 74 | > ``` 75 | 76 | **Note:** These commands *will* recreate your current .venv folder, 77 | since they include the `--exact` flag. If you don't want your current 78 | env modified, add `isolated=1` to the command. 79 | 80 | ```bash 81 | make test extras=jupyter,vispy isolated=1 82 | ``` 83 | 84 | (Alternatively, just run `uv sync` again afterwards and it will bring 85 | back the full env) 86 | 87 | ### Testing different Python versions 88 | 89 | Use `py=` to specify a different python version. 90 | 91 | ```bash 92 | make test py=3.10 93 | ``` 94 | 95 | ### Testing with minimum dependency versions 96 | 97 | To test against the minimum stated versions of dependencies, use `min=1` 98 | 99 | ```bash 100 | make test min=1 101 | ``` 102 | 103 | ## Linting and Formatting 104 | 105 | To lint and format the code, use pre-commit (make sure you've run `uv sync` first): 106 | 107 | ```bash 108 | uv run pre-commit run --all-files 109 | ``` 110 | 111 | or 112 | 113 | ```bash 114 | make lint 115 | ``` 116 | 117 | ## Building Documentation 118 | 119 | To serve the documentation locally, use: 120 | 121 | ```bash 122 | make docs-serve 123 | ``` 124 | 125 | or to build into a `site` directory: 126 | 127 | ```bash 128 | make docs 129 | ``` 130 | 131 | If the screenshot generation is annoying, you can 132 | disable it with the `GEN_SCREENSHOTS` environment variable: 133 | 134 | ```bash 135 | GEN_SCREENSHOTS=0 make docs 136 | ``` 137 | 138 | ## Releasing 139 | 140 | To release a new version, generate the changelog: 141 | 142 | ```sh 143 | github_changelog_generator --future-release vX.Y.Z 144 | ``` 145 | 146 | then review it and commit it: 147 | 148 | ```sh 149 | git commit -am "chore: changelog for vX.Y.Z" 150 | ``` 151 | 152 | then tag the commit and push: 153 | 154 | ```sh 155 | git tag -a vX.Y.Z -m "vX.Y.Z" 156 | git push upstream --follow-tags 157 | ``` 158 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Talley Lambert 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs docs-serve 2 | 3 | # Define a comma-separated list of extras. 4 | comma := , 5 | extras ?= pyqt,pygfx 6 | EXTRA_FLAGS := $(foreach extra, $(subst $(comma), ,$(extras)),--extra=$(extra)) 7 | GROUP_FLAGS := $(foreach extra, $(subst $(comma), ,$(groups)),--group=$(extra)) 8 | 9 | ifeq ($(filter pyqt pyside, $(subst $(comma), ,$(extras))),) 10 | GROUP_FLAGS += '--group=test' 11 | else 12 | GROUP_FLAGS += '--group=testqt' 13 | endif 14 | 15 | VERBOSE := $(if $(v),--verbose,) 16 | ISOLATED := $(if $(isolated),--isolated) 17 | PYTHON_FLAG := $(if $(py),-p=$(py)) 18 | RESOLUTION := $(if $(min),--resolution=lowest-direct) 19 | COV := $(if $(cov),coverage run -p -m) 20 | 21 | test: 22 | uv run $(VERBOSE) $(ISOLATED) $(PYTHON_FLAG) $(RESOLUTION) --exact --no-dev $(EXTRA_FLAGS) $(GROUP_FLAGS) $(COV) pytest $(VERBOSE) --color=yes 23 | 24 | test-arrays: 25 | $(MAKE) test extras=pyqt,pygfx groups=array-libs 26 | 27 | test-all: 28 | $(MAKE) test extras=pyqt,pygfx 29 | $(MAKE) test extras=pyqt,vispy 30 | $(MAKE) test extras=pyside,pygfx 31 | $(MAKE) test extras=jupyter,pygfx 32 | $(MAKE) test extras=wx,pygfx 33 | $(MAKE) test extras=wx,vispy 34 | # $(MAKE) test extras=pyside,vispy 35 | # $(MAKE) test extras=jupyter,vispy 36 | 37 | docs: 38 | uv run --group docs mkdocs build --strict 39 | 40 | docs-serve: 41 | uv run --group docs mkdocs serve --no-strict 42 | 43 | lint: 44 | uv run pre-commit run --all-files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ndv 2 | 3 | [![License](https://img.shields.io/pypi/l/ndv.svg?color=green)](https://github.com/pyapp-kit/ndv/raw/main/LICENSE) 4 | [![PyPI](https://img.shields.io/pypi/v/ndv.svg?color=green)](https://pypi.org/project/ndv) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/ndv.svg?color=green)](https://python.org) 6 | [![CI](https://github.com/pyapp-kit/ndv/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/ndv/actions/workflows/ci.yml) 7 | [![codecov](https://codecov.io/gh/pyapp-kit/ndv/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/ndv) 8 | 9 | Simple, fast-loading, asynchronous, n-dimensional array viewer, with minimal 10 | dependencies. 11 | 12 | Works in Qt, Jupyter, or wxPython. 13 | 14 | ```python 15 | import ndv 16 | 17 | data = ndv.data.cells3d() # or any arraylike object 18 | ndv.imshow(data) 19 | ``` 20 | 21 | ![Montage](https://github.com/pyapp-kit/ndv/assets/1609449/712861f7-ddcb-4ecd-9a4c-ba5f0cc1ee2c) 22 | 23 | [`ndv.imshow()`](https://pyapp-kit.github.io/ndv/latest/reference/ndv/#ndv.imshow) 24 | creates an instance of 25 | [`ndv.ArrayViewer`](https://pyapp-kit.github.io/ndv/latest/reference/ndv/controllers/#ndv.controllers.ArrayViewer), 26 | which you can also use directly: 27 | 28 | ```python 29 | import ndv 30 | 31 | viewer = ndv.ArrayViewer(data) 32 | viewer.show() 33 | ndv.run_app() 34 | ``` 35 | 36 | > [!TIP] 37 | > To embed the viewer in a broader Qt or wxPython application, you can 38 | > access the viewer's 39 | > [`widget`](https://pyapp-kit.github.io/ndv/latest/reference/ndv/controllers/#ndv.controllers.ArrayViewer.widget) 40 | > attribute and add it to your layout. 41 | 42 | ## Features 43 | 44 | - ⚡️ fast to import, fast to show 45 | - 🪶 minimal dependencies 46 | - 📦 supports arbitrary number of dimensions 47 | - 🥉 2D/3D view canvas 48 | - 🌠 supports [VisPy](https://github.com/vispy/vispy) or 49 | [pygfx](https://github.com/pygfx/pygfx) backends 50 | - 🛠️ support [Qt](https://doc.qt.io), [wx](https://www.wxpython.org), or 51 | [Jupyter](https://jupyter.org) GUI frontends 52 | - 🎨 colormaps provided by [cmap](https://cmap-docs.readthedocs.io/) 53 | - 🏷️ supports named dimensions and categorical coordinate values (WIP) 54 | - 🦆 supports most array types, including: 55 | - `numpy.ndarray` 56 | - `cupy.ndarray` 57 | - `dask.array.Array` 58 | - `jax.Array` 59 | - `pyopencl.array.Array` 60 | - `sparse.COO` 61 | - `tensorstore.TensorStore` (supports named dimensions) 62 | - `torch.Tensor` (supports named dimensions) 63 | - `xarray.DataArray` (supports named dimensions) 64 | - `zarr` (named dimensions WIP) 65 | 66 | See examples for each of these array types in 67 | [examples](https://github.com/pyapp-kit/ndv/tree/main/examples) 68 | 69 | > [!NOTE] 70 | > *You can add support for any custom storage class by subclassing 71 | > `ndv.DataWrapper` and [implementing a couple 72 | > methods](https://github.com/pyapp-kit/ndv/blob/main/examples/custom_store.py). 73 | > (This doesn't require modifying ndv, but contributions of new wrappers are 74 | > welcome!)* 75 | 76 | ## Installation 77 | 78 | Because ndv supports many combinations of GUI and graphics frameworks, 79 | you must install it along with additional dependencies for your desired backend. 80 | 81 | See the [installation guide](https://pyapp-kit.github.io/ndv/latest/install/) for 82 | complete details. 83 | 84 | To just get started quickly using Qt and vispy: 85 | 86 | ```python 87 | pip install ndv[qt] 88 | ``` 89 | 90 | For Jupyter with vispy, (no Qt or wxPython): 91 | 92 | ```python 93 | pip install ndv[jup] 94 | ``` 95 | 96 | ## Documentation 97 | 98 | For more information, and complete API reference, see the 99 | [documentation](https://pyapp-kit.github.io/ndv/). 100 | -------------------------------------------------------------------------------- /docs/_overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% set prebuild = 'pre-release' if config.extra.pre_release else 'dev' if config.extra.dev_build else '' %} 4 | 5 | {% block announce %} 6 | {%- if prebuild -%} 7 | 8 |
9 | You are currently viewing documentation for a {{prebuild}} build. 10 | This may reference unreleased features. 11 | For latest release, see 12 | stable release docs. 13 |
14 | {%- endif -%} 15 | {% endblock %} 16 | 17 | {% block outdated %} 18 | You're not viewing the latest stable version. 19 | 20 | Click here to go to last release. 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /docs/cookbook/embedding.md: -------------------------------------------------------------------------------- 1 | # Embedding `ArrayViewer` 2 | 3 | `ndv` can be embedded in an existing Qt (or wx) application and enriched with additional 4 | elements in a custom layout. The following document shows some examples of such 5 | implementation. 6 | 7 | The key in each case is the use of the 8 | [`ArrayViewer.widget`][ndv.controllers.ArrayViewer.widget] method, which returns 9 | a native widget for the current GUI backend. 10 | 11 | ## Change the content of `ArrayViewer` via push buttons 12 | 13 | The following script shows an example on how to dynamically select a data set 14 | and load it in the `ArrayViewer`. 15 | 16 | ````python title="examples/cookbook/ndv_embedded.py" 17 | --8<-- "examples/cookbook/ndv_embedded.py" 18 | ```` 19 | 20 | {{ screenshot: examples/cookbook/ndv_embedded.py }} 21 | 22 | ## Use multiple `ndv.ArrayViewer` controllers in the same widget 23 | 24 | The following script shows an example on how to create multiple instances of the 25 | `ArrayViewer` controller in the same widget and load two different datasets in 26 | each one. 27 | 28 | ````python title="examples/cookbook/multi_ndv.py" 29 | --8<-- "examples/cookbook/multi_ndv.py" 30 | ```` 31 | 32 | {{ screenshot: examples/cookbook/multi_ndv.py }} 33 | 34 | ## A minimal microscope dashboard using `openwfs` 35 | 36 | You can use `ndv` to take an external image source (i.e. a widefield camera) and 37 | show its content in real-time in a custom widget embedding `ArrayViewer`. The 38 | script below uses [`openwfs`](https://github.com/IvoVellekoop/openwfs) to 39 | generate synthetic images of a sample and continuously update the view, and 40 | allows to move the field of view over the X and Y axis. 41 | 42 | ````python title="examples/cookbook/microscope_dashboard.py" 43 | --8<-- "examples/cookbook/microscope_dashboard.py" 44 | ```` 45 | 46 | {{ screenshot: examples/cookbook/microscope_dashboard.py }} 47 | -------------------------------------------------------------------------------- /docs/cookbook/streaming.md: -------------------------------------------------------------------------------- 1 | # Streaming updates 2 | 3 | `ndv` can be used to visualize data that is continuously updated, such as 4 | images from a camera or a live data stream. The following document shows some 5 | examples of such implementation. 6 | 7 | ## Basic streaming, with no history 8 | 9 | To visualize a live data stream, simply create an `ndv.ArrayViewer` controller 10 | with an empty buffer matching your data shape. Then, when new data is available, 11 | update the buffer in place with the new data. Calling `update()` on the 12 | [`ArrayDisplayModel.current_index`][ndv.models.ArrayDisplayModel] 13 | will force the display to fetch your new data: 14 | 15 | ````python title="examples/streaming.py" 16 | --8<-- "examples/streaming.py" 17 | ```` 18 | 19 | ## Streaming, remembering the last N frames 20 | 21 | To visualize a live data stream while keeping the last N frames in memory, 22 | you can use the [`ndv.models.RingBuffer`][] class. It offers a convenient 23 | `append()` method to add new data, and takes care of updating the "apparent" 24 | shape of the data (as far as the viewer is concerned): 25 | 26 | ````python title="examples/streaming_with_history.py" 27 | --8<-- "examples/streaming_with_history.py" 28 | ```` 29 | -------------------------------------------------------------------------------- /docs/css/install-table.css: -------------------------------------------------------------------------------- 1 | /* Install table. */ 2 | 3 | #install-table { 4 | display: grid; 5 | grid-template-columns: 120px 1fr; 6 | gap: 5px; 7 | margin: 0 auto; 8 | } 9 | 10 | #install-table .category-label { 11 | display: flex; 12 | font-size: 16px; 13 | font-weight: 200; 14 | color: var(--md-default-fg-color--light); 15 | align-items: center; 16 | height: auto; 17 | padding-left: 10px; 18 | border-left: 2px solid var(--md-default-fg-color--lightest); 19 | } 20 | #install-table .command-label { 21 | color: var(--md-default-fg-color); 22 | border-color: var(--md-default-fg-color--light); 23 | } 24 | 25 | #install-table .grid-right { 26 | display: flex; 27 | justify-content: space-between; 28 | flex-wrap: wrap; 29 | gap: 1px; 30 | } 31 | 32 | #install-table button { 33 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 34 | flex: 1; 35 | padding: 10px 0; 36 | margin: 0 2px; 37 | font-size: 14px; 38 | text-align: left; 39 | padding-left: 15px; 40 | padding-right: 15px; 41 | border: none; 42 | background-color: var(--md-accent-fg-color--transparent); 43 | color: var(--md-default-fg-color--light); 44 | cursor: pointer; 45 | transition: background-color 0.2s, color 0.2s; 46 | } 47 | 48 | #install-table button:first-child { 49 | margin-left: 0; 50 | } 51 | 52 | #install-table button:last-child { 53 | margin-right: 0; 54 | } 55 | 56 | #install-table button.active, 57 | #install-table button:hover { 58 | background-color: var(--md-primary-fg-color); 59 | color: var(--md-primary-bg-color); 60 | } 61 | #install-table button:hover { 62 | background-color: var(--md-primary-fg-color--light); 63 | } 64 | 65 | #install-table .command-section { 66 | position: relative; 67 | display: flex; 68 | align-items: center; 69 | justify-content: space-between; 70 | flex-wrap: wrap; 71 | gap: 1px; 72 | font-size: 14px; 73 | /* grid-column: span 2; */ 74 | margin-top: 10px; 75 | padding: 10px 15px; 76 | background-color: var(--md-code-bg-color); 77 | border: 1px solid var(--md-default-fg-color--lighter); 78 | } 79 | 80 | #install-table #command-output { 81 | font-family: monospace; 82 | color: var(--md-code-fg-color); 83 | } 84 | 85 | #install-table button.md-clipboard { 86 | background-color: transparent; 87 | color: var(--md-default-fg-color--lightest); 88 | right: 15px; 89 | top: 15px; 90 | } 91 | 92 | #install-table button.md-clipboard.copied { 93 | color: lime !important; 94 | } 95 | 96 | #install-table button.md-clipboard::after { 97 | width: 1.5em; 98 | height: 1.5em; 99 | } 100 | 101 | #install-table button.md-clipboard:hover, 102 | #install-table button.md-clipboard:focus { 103 | color: var(--md-accent-fg-color); 104 | } 105 | -------------------------------------------------------------------------------- /docs/css/material.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | 6 | /* Custom admonition: preview */ 7 | :root { 8 | --md-admonition-icon--preview: url('data:image/svg+xml;charset=utf-8,'); 9 | } 10 | 11 | .md-typeset .admonition.preview, 12 | .md-typeset details.preview { 13 | border-color: rgb(220, 139, 240); 14 | } 15 | 16 | .md-typeset .preview>.admonition-title, 17 | .md-typeset .preview>summary { 18 | background-color: rgba(142, 43, 155, 0.1); 19 | } 20 | 21 | .md-typeset .preview>.admonition-title::before, 22 | .md-typeset .preview>summary::before { 23 | background-color: rgb(220, 139, 240); 24 | -webkit-mask-image: var(--md-admonition-icon--preview); 25 | mask-image: var(--md-admonition-icon--preview); 26 | } -------------------------------------------------------------------------------- /docs/css/ndv.css: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: 0.05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: " "; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } 28 | 29 | img.auto-screenshot { 30 | border-radius: 6px; 31 | margin-top: 1em; 32 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); 33 | } 34 | -------------------------------------------------------------------------------- /docs/env_var.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | `ndv` recognizes the following environment variables: 4 | 5 | *Boolean variables can be set to `1`, `0`, `True`, or `False` (case insensitive).* 6 | 7 | |
Variable
| Description | Default | 8 | |--------------------------------------------|-----------------| ------- | 9 | | **`NDV_CANVAS_BACKEND`** | Explicitly choose the graphics library: `"vispy"` or `"pygfx"` | auto | 10 | | **`NDV_GUI_FRONTEND`** | Explicitly choose the GUI library: `"qt"`, `"wx"`, or `"jupyter"` | auto | 11 | | **`NDV_DEBUG_EXCEPTIONS`** | Whether to drop into a debugger when an exception is raised. (for development) | `False` | 12 | | **`NDV_EXIT_ON_EXCEPTION`** | Whether to exit the application on the first unhandled exception. (for development) | `False` | 13 | | **`NDV_IPYTHON_MAGIC`** | Whether to use [`%gui` magic](https://ipython.readthedocs.io/en/stable/config/eventloops.html#integrating-with-gui-event-loops) when running in IPython, to enable interactive usage. | `True` | 14 | | **`NDV_SYNCHRONOUS`** | Whether to force data request/draw to be synchronous. (*note: this currently has no effect on Jupyter, which is always asynchronous) | `False` | 15 | 16 | ## Framework selection 17 | 18 | Depending on how you've [installed ndv](./install.md), you may end up with 19 | multiple supported GUI or graphics libraries installed. You can control which 20 | ones `ndv` uses with **`NDV_CANVAS_BACKEND`** and **`NDV_GUI_FRONTEND`**, 21 | respectively, as described above. Note that currently, only one GUI framework 22 | can be used per session. 23 | 24 | !!! info "Defaults" 25 | 26 | **GUI:** 27 | 28 | `ndv` tries to be aware of the GUI library you are using. So it will use 29 | `jupyter` if you are in a Jupyter notebook, `qt` if a `QApplication` is 30 | already running, and `wx` if a `wx.App` is already running. Finally, it 31 | will check for the availability of libraries in the order of `qt`, `wx`, 32 | `jupyter`. 33 | 34 | **Graphics:** 35 | 36 | If you have both VisPy and pygfx installed, `ndv` will (currently) default 37 | to using VisPy. 38 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | !!! tip "TLDR;" 4 | 5 | === "For a desktop usage" 6 | 7 | ```python 8 | pip install "ndv[vispy,pyqt]" 9 | ``` 10 | 11 | === "For Jupyter notebook/lab" 12 | 13 | ```python 14 | pip install "ndv[vispy,jupyter]" 15 | ``` 16 | 17 | `ndv` can be used in a variety of contexts. It supports various **GUI 18 | frameworks**, including [PyQt](https://riverbankcomputing.com/software/pyqt), 19 | [PySide](https://wiki.qt.io/Qt_for_Python), [wxPython](https://wxpython.org), 20 | and [Jupyter Lab & Notebooks](https://jupyter.org). It also works with 21 | different **graphics libraries**, including [VisPy](https://vispy.org) (for 22 | OpenGL) and [Pygfx](https://github.com/pygfx/pygfx) (for WebGPU). 23 | 24 | These frameworks are *not* included directly with `ndv` and must be installed 25 | independently. We provide a set of installation extras depending on the graphics 26 | and GUI libraries you want to use: 27 | 28 | 29 |
30 | 31 | !!! note "Framework Selection" 32 | 33 | If you have *multiple* supported GUI or graphics libraries installed, you can 34 | select which ones `ndv` uses with 35 | [environment variables](env_var.md#framework-selection). 36 | -------------------------------------------------------------------------------- /docs/js/install-table.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const tableData = { 3 | Source: ["PyPI", "Conda", "Github (dev version)"], 4 | Graphics: ["VisPy", "Pygfx"], 5 | Frontend: ["PyQt6", "PySide6", "wxPython", "Jupyter"], 6 | }; 7 | 8 | const pipCommand = 'pip install' 9 | const condaCommand = 'conda install -c conda-forge' 10 | const gitCommand = 'pip install "git+https://github.com/pyapp-kit/ndv.git@main#egg' 11 | const condaVispy = 'vispy pyopengl'; 12 | const condaJupyter = 'jupyter jupyter-rfb pyglfw ipywidgets'; 13 | const commandMap = { 14 | "PyPI,VisPy,PyQt6": `${pipCommand} "ndv[vispy,pyqt]"`, 15 | "PyPI,VisPy,PySide6": `${pipCommand} "ndv[vispy,pyside]"`, 16 | "PyPI,VisPy,wxPython": `${pipCommand} "ndv[vispy,wxpython]"`, 17 | "PyPI,VisPy,Jupyter": `${pipCommand} "ndv[vispy,jupyter]"`, 18 | "PyPI,Pygfx,PyQt6": `${pipCommand} "ndv[pygfx,pyqt]"`, 19 | "PyPI,Pygfx,PySide6": `${pipCommand} "ndv[pygfx,pyside]"`, 20 | "PyPI,Pygfx,wxPython": `${pipCommand} "ndv[pygfx,wxpython]"`, 21 | "PyPI,Pygfx,Jupyter": `${pipCommand} "ndv[pygfx,jupyter]"`, 22 | "Conda,VisPy,PyQt6": `pyqt6 is not available in conda-forge, use PySide6`, 23 | "Conda,VisPy,PySide6": `${condaCommand} ndv ${condaVispy} 'pyside6<6.8'`, 24 | "Conda,VisPy,wxPython": `${condaCommand} ndv ${condaVispy} wxpython`, 25 | "Conda,VisPy,Jupyter": `${condaCommand} ndv ${condaVispy} ${condaJupyter}`, 26 | "Conda,Pygfx,PyQt6": `${condaCommand} ndv pygfx qt6-main`, 27 | "Conda,Pygfx,PySide6": `${condaCommand} ndv pygfx 'pyside6<6.8'`, 28 | "Conda,Pygfx,wxPython": `${condaCommand} ndv pygfx wxpython`, 29 | "Conda,Pygfx,Jupyter": `${condaCommand} ndv pygfx ${condaJupyter}`, 30 | "Github (dev version),VisPy,PyQt6": `${gitCommand}=ndv[vispy,pyqt]"`, 31 | "Github (dev version),VisPy,PySide6": `${gitCommand}=ndv[vispy,pyside]"`, 32 | "Github (dev version),VisPy,wxPython": `${gitCommand}=ndv[vispy,wx]"`, 33 | "Github (dev version),VisPy,Jupyter": `${gitCommand}=ndv[vispy,jupyter]"`, 34 | "Github (dev version),Pygfx,PyQt6": `${gitCommand}=ndv[pygfx,pyqt]"`, 35 | "Github (dev version),Pygfx,PySide6": `${gitCommand}=ndv[pygfx,pyside]"`, 36 | "Github (dev version),Pygfx,wxPython": `${gitCommand}=ndv[pygfx,wx]"`, 37 | "Github (dev version),Pygfx,Jupyter": `${gitCommand}=ndv[pygfx,jupyter]"`, 38 | }; 39 | 40 | const container = document.getElementById("install-table"); 41 | 42 | const createTable = () => { 43 | Object.keys(tableData).forEach((category) => { 44 | const label = document.createElement("div"); 45 | label.classList.add("category-label"); 46 | label.textContent = category; 47 | 48 | const buttonsContainer = document.createElement("div"); 49 | buttonsContainer.classList.add("grid-right", "buttons"); 50 | 51 | tableData[category].forEach((item, index) => { 52 | const button = document.createElement("button"); 53 | button.textContent = item; 54 | button.dataset.value = item; 55 | 56 | // Activate the first button in the category 57 | if (index === 0) { 58 | button.classList.add("active"); 59 | } 60 | 61 | // Event listener for button click 62 | button.addEventListener("click", (event) => { 63 | // Deactivate all buttons in this category 64 | Array.from(buttonsContainer.children).forEach((btn) => btn.classList.remove("active")); 65 | 66 | // Activate the clicked button 67 | button.classList.add("active"); 68 | 69 | // Update command 70 | updateCommand(); 71 | }); 72 | 73 | buttonsContainer.appendChild(button); 74 | }); 75 | 76 | container.appendChild(label); 77 | container.appendChild(buttonsContainer); 78 | }); 79 | 80 | const label = document.createElement("div"); 81 | label.classList.add("category-label", "command-label"); 82 | label.textContent = "Command:"; 83 | 84 | const commandDiv = document.createElement("div"); 85 | commandDiv.classList.add("grid-right", "command-section"); 86 | commandDiv.innerHTML = ` 87 |

Select options to generate command

88 | 89 | `; 90 | container.appendChild(label); 91 | container.appendChild(commandDiv); 92 | 93 | // Add copy functionality 94 | const copyButton = commandDiv.querySelector(".md-clipboard"); 95 | copyButton.addEventListener("click", copyToClipboard); 96 | 97 | // Update the command output initially 98 | updateCommand(); 99 | }; 100 | 101 | const updateCommand = () => { 102 | const activeButtons = document.querySelectorAll("button.active"); 103 | const selectedOptions = Array.from(activeButtons).map((btn) => btn.dataset.value); 104 | const commandOutput = document.getElementById("command-output"); 105 | console.log(); 106 | 107 | if (selectedOptions.length === 0) { 108 | commandOutput.textContent = "Select options to generate command"; 109 | } else { 110 | commandOutput.textContent = commandMap[selectedOptions.join(",")]; 111 | } 112 | }; 113 | const copyToClipboard = () => { 114 | const commandOutput = document.getElementById("command-output").textContent; 115 | navigator.clipboard 116 | .writeText(commandOutput) 117 | .then(() => { 118 | // give a little animated feedback 119 | const commandDiv = document.querySelector(".command-section .md-clipboard"); 120 | commandDiv.classList.add("copied"); 121 | setTimeout(() => { 122 | commandDiv.classList.remove("copied"); 123 | }, 500); 124 | }) 125 | .catch((error) => { 126 | console.error("Failed to copy to clipboard", error); 127 | }); 128 | }; 129 | 130 | if (container) { 131 | createTable(); 132 | } 133 | }); 134 | -------------------------------------------------------------------------------- /docs/motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation and Scope 2 | 3 | It can be informative to know what problems the developers were trying to solve 4 | when creating a library, and under what constraints. `ndv` was created by a 5 | former [napari](https://napari.org) core developer and collaborators out of a 6 | desire to quickly view multi-dimensional arrays with minimal import times 7 | minimal dependencies. The original need was for a component in a broader 8 | (microscope control) application, where a fast and minimal viewer was needed to 9 | display data. 10 | 11 | ## Goals 12 | 13 | - [x] **N-dimensional viewer**: The current focus is on viewing multi-dimensional 14 | arrays (and currently just a single array at a time), with sliders 15 | controlling slicing from an arbitrary number of dimensions. 2D and 3D 16 | volumetric views are supported. 17 | - [x] **Minimal dependencies**: `ndv` should have as few dependencies as 18 | possible (both direct and indirect). Installing `napari[all]==0.5.5` into a 19 | clean environment brings a total of 126 dependencies. `ndv[qt]==0.2.0` has 20 | 29, and we aim to keep that low. 21 | - [x] **Quick to import**: `ndv` should import and show a viewer in a reasonable 22 | amount of time. "Reasonable" is of course relative and subjective, but we 23 | aim for less than 1 second on a modern laptop (currently at <100ms). 24 | - [x] **Broad GUI Compatibility**: A common feature request for `napari` is to support 25 | Jupyter notebooks. `ndv` [can work with](./install.md) Qt, 26 | wxPython, *and* Jupyter. 27 | - [x] **Flexible Graphics Providers**: `ndv` works with VisPy in a classical OpenGL 28 | context, but has an abstracting layer that allows for other graphics engines. 29 | We currently also support `pygfx`, a WGPU-based graphics engine. 30 | - [x] **Model/View architecture**: `ndv` should have a clear separation between the 31 | data model and the view. The model should be serializable and easily 32 | transferable between different views. (The primary model is currently 33 | [`ArrayDisplayModel`][ndv.models.ArrayDisplayModel]) 34 | - [x] **Asynchronous first**: `ndv` should be asynchronous by default: meaning 35 | that the data request/response process happens in the background, and the 36 | GUI remains responsive. (Optimization of remote, multi-resolution data is on 37 | the roadmap, but not currently implemented). 38 | 39 | ## Scope and Roadmap 40 | 41 | We *do* want to support the following features: 42 | 43 | - [ ] **Multiple data sources**: We want to allow for multiple data sources to be 44 | displayed in the same viewer, with flexible coordinate transforms. 45 | - [ ] **Non-image data**: We would like to support non-image data, such as points 46 | segmentation masks, and meshes. 47 | - [ ] **Multi-resolution (pyramid) data**: We would like to support multi-resolution 48 | data, to allow for fast rendering of large datasets based on the current view. 49 | - [ ] **Frustum culling**: We would like to support frustum culling to allow for 50 | efficient rendering of large datasets. 51 | - [ ] **Ortho-viewer**: `ndv`'s clean model/view separation should allow for 52 | easy creation of an ortho-viewer (e.g. synchronized `XY`, `XZ`, `YZ` views). 53 | 54 | ## Non-Goals 55 | 56 | We *do not* plan to support the following features in the near future 57 | (if ever): 58 | 59 | - **Oblique Slicing**: While not an explicit non-goal, oblique slicing (à la 60 | [Big Data Viewer](https://imagej.net/plugins/bdv/)) is different enough 61 | that it won't realistically be implemented in the near future. 62 | - **Image Processing**: General image processing is out of scope. We aim to 63 | provide a viewer, not a full image processing library. 64 | - **Interactive segmentation and painting**: While extensible mouse event handling 65 | *is* in scope, we don't intend to implement painting or interactive 66 | segmentation tools. 67 | - **Plugins**: We don't intend to support a plugin architecture. We aim to keep 68 | the core library as small as possible, and encourage users to build on top 69 | of it with their own tools. 70 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | To quickly run any of the examples in this directory, without needing to install 4 | either ndv or even python, you can [install 5 | uv](https://docs.astral.sh/uv/getting-started/installation/), and then use [`uv 6 | run`](https://docs.astral.sh/uv/guides/scripts/): 7 | 8 | ```shell 9 | # locally within the ndv repo 10 | uv run examples/numpy_arr.py 11 | 12 | # from anywhere 13 | uv run https://github.com/pyapp-kit/ndv/raw/refs/heads/main/examples/numpy_arr.py 14 | ``` 15 | 16 | Replace `numpy_arr.py` with the name of the example file in this directory you 17 | want to run. 18 | 19 | ## Notebooks 20 | 21 | To run any of the `.ipynb` files in this directory, you can use uv with 22 | [`juv`](https://github.com/manzt/juv) 23 | 24 | ```shell 25 | uvx juv run examples/notebook.ipynb 26 | ``` 27 | 28 | *At the time of writing, juv only appears to support local files, so you should 29 | clone this repo first.* 30 | -------------------------------------------------------------------------------- /examples/backend_test/pygfx_mwe.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pygfx 3 | from qtpy.QtWidgets import QApplication 4 | from wgpu.gui.qt import QWgpuCanvas 5 | 6 | app = QApplication([]) 7 | canvas = QWgpuCanvas(size=(512, 512)) 8 | renderer = pygfx.renderers.WgpuRenderer(canvas) 9 | 10 | # create data 11 | data = np.random.rand(512, 512).astype(np.float32) 12 | 13 | # create scene and camera 14 | scene = pygfx.Scene() 15 | camera = pygfx.OrthographicCamera(*data.shape) 16 | camera.local.position = data.shape[0] / 2, data.shape[1] / 2, 0 17 | controller = pygfx.PanZoomController(camera, register_events=renderer) 18 | 19 | # add image 20 | scene.add( 21 | pygfx.Image( 22 | pygfx.Geometry(grid=pygfx.Texture(data, dim=2)), 23 | pygfx.ImageBasicMaterial(depth_test=False), 24 | ) 25 | ) 26 | 27 | canvas.request_draw(lambda: renderer.render(scene, camera)) 28 | app.exec() 29 | -------------------------------------------------------------------------------- /examples/backend_test/vispy_mwe.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from qtpy.QtWidgets import QApplication 3 | from vispy import scene 4 | 5 | qapp = QApplication([]) 6 | canvas = scene.SceneCanvas(keys="interactive", size=(800, 600)) 7 | 8 | # Set up a viewbox to display the image with interactive pan/zoom 9 | view = canvas.central_widget.add_view() 10 | 11 | # Create the image 12 | img_data = np.random.rand(512, 512).astype(np.float32) 13 | 14 | image = scene.visuals.Image(img_data, interpolation="nearest", parent=view.scene) 15 | view.camera = scene.PanZoomCamera(aspect=1) 16 | view.camera.flip = (0, 1, 0) 17 | view.camera.set_range() 18 | 19 | canvas.show() 20 | qapp.exec() 21 | -------------------------------------------------------------------------------- /examples/cookbook/microscope_dashboard.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[vispy,pyqt]", 4 | # "openwfs", 5 | # ] 6 | # /// 7 | """A minimal microscope dashboard. 8 | 9 | The `SyntheticManager` acts as an microscope simulator. 10 | It generates images of a specimen and sends them to the `DashboardWidget`. 11 | 12 | The `DashboardWidget` is a simple GUI that displays the images and allows the user to: 13 | - starts/stops the simulation; 14 | - move the stage in the x and y directions. 15 | """ 16 | 17 | from typing import Any, cast 18 | 19 | import astropy.units as u 20 | import numpy as np 21 | from openwfs.simulation import Camera, Microscope, StaticSource 22 | from qtpy import QtWidgets as QtW 23 | from qtpy.QtCore import QObject, Qt, Signal 24 | 25 | from ndv import ArrayViewer 26 | 27 | 28 | class SyntheticManager(QObject): 29 | newFrame = Signal(np.ndarray) 30 | 31 | def __init__(self, parent: QObject | None = None) -> None: 32 | super().__init__(parent) 33 | 34 | specimen_resolution = (1024, 1024) # (height, width) in pixels of the image 35 | specimen_pixel_size = 60 * u.nm # resolution (pixel size) of the specimen image 36 | magnification = 40 # magnification from object plane to camera. 37 | numerical_aperture = 0.85 # numerical aperture of the microscope objective 38 | wavelength = 532.8 * u.nm # wavelength of the light, for computing diffraction. 39 | camera_resolution = (256, 256) # number of pixels on the camera 40 | 41 | img = np.random.randint(-10000, 10, specimen_resolution, dtype=np.int16) 42 | img = np.maximum(img, 0) 43 | src = StaticSource(img, pixel_size=specimen_pixel_size) 44 | 45 | self._microscope = Microscope( 46 | src, 47 | magnification=magnification, 48 | numerical_aperture=numerical_aperture, 49 | wavelength=wavelength, 50 | ) 51 | self._camera = Camera( 52 | self._microscope, 53 | analog_max=None, 54 | shot_noise=True, 55 | digital_max=255, 56 | shape=camera_resolution, 57 | ) 58 | 59 | def move_stage(self, axis: str, step: float) -> None: 60 | if axis == "x": 61 | self._microscope.xy_stage.x += step * u.um 62 | if axis == "y": 63 | self._microscope.xy_stage.y += step * u.um 64 | 65 | def toggle_simulation(self, start: bool) -> None: 66 | if start: 67 | self._timer_id = self.startTimer(100) 68 | elif hasattr(self, "_timer_id"): 69 | self.killTimer(self._timer_id) 70 | 71 | def timerEvent(self, e: Any) -> None: 72 | self.emit_frame() 73 | 74 | def emit_frame(self) -> None: 75 | self.newFrame.emit(self._camera.read()) 76 | 77 | 78 | class StageWidget(QtW.QGroupBox): 79 | stageMoved = Signal(str, int) 80 | 81 | def __init__(self, name: str, axes: list[str], parent: QtW.QWidget) -> None: 82 | super().__init__(name, parent) 83 | self._data_key = "data" 84 | 85 | def _make_button(txt: str, *data: Any) -> QtW.QPushButton: 86 | btn = QtW.QPushButton(txt) 87 | btn.setAutoRepeat(True) 88 | btn.setProperty(self._data_key, data) 89 | btn.clicked.connect(self._move_stage) 90 | return btn 91 | 92 | layout = QtW.QVBoxLayout(self) 93 | for ax in axes: 94 | # spinbox showing stage position 95 | spin = QtW.QDoubleSpinBox() 96 | spin.setMinimumWidth(80) 97 | spin.setAlignment(Qt.AlignmentFlag.AlignRight) 98 | spin.setSuffix(" µm") 99 | spin.setFocusPolicy(Qt.FocusPolicy.NoFocus) 100 | spin.setButtonSymbols(QtW.QAbstractSpinBox.ButtonSymbols.NoButtons) 101 | 102 | # buttons to move the stage 103 | down = _make_button("-", ax, spin) 104 | up = _make_button("+", ax, spin) 105 | 106 | row = QtW.QHBoxLayout() 107 | row.addWidget(QtW.QLabel(f"{ax}:"), 0) 108 | row.addWidget(spin, 0, Qt.AlignmentFlag.AlignRight) 109 | row.addWidget(down, 1) 110 | row.addWidget(up, 1) 111 | layout.addLayout(row) 112 | 113 | def _move_stage(self) -> None: 114 | button = cast("QtW.QPushButton", self.sender()) 115 | ax, spin = button.property(self._data_key) 116 | step = 1 if button.text() == "+" else -1 117 | cast("QtW.QDoubleSpinBox", spin).stepBy(step) 118 | self.stageMoved.emit(ax, step) 119 | 120 | 121 | class DashboardWidget(QtW.QWidget): 122 | simulationStarted = Signal(bool) 123 | stageMoved = Signal(str, int) 124 | 125 | def __init__(self) -> None: 126 | super().__init__() 127 | 128 | self._stage_widget = StageWidget("Stage", ["x", "y"], self) 129 | self._stage_widget.setEnabled(False) 130 | self._stage_widget.stageMoved.connect(self.stageMoved) 131 | 132 | self._viewer = ArrayViewer() 133 | self._viewer._async = False # Disable async rendering for simplicity 134 | 135 | self._start_button = QtW.QPushButton("Start") 136 | self._start_button.setMinimumWidth(120) 137 | self._start_button.toggled.connect(self.start_simulation) 138 | self._start_button.setCheckable(True) 139 | 140 | bottom = QtW.QHBoxLayout() 141 | bottom.setContentsMargins(0, 0, 0, 0) 142 | bottom.addWidget(self._start_button) 143 | bottom.addSpacing(120) 144 | bottom.addWidget(self._stage_widget) 145 | 146 | layout = QtW.QVBoxLayout(self) 147 | layout.setSpacing(0) 148 | layout.addWidget(self._viewer.widget(), 1) 149 | layout.addLayout(bottom) 150 | 151 | def start_simulation(self, checked: bool) -> None: 152 | self._stage_widget.setEnabled(checked) 153 | self._start_button.setText("Stop" if checked else "Start") 154 | self.simulationStarted.emit(checked) 155 | 156 | def set_data(self, frame: np.ndarray) -> None: 157 | self._viewer.data = frame 158 | 159 | 160 | app = QtW.QApplication([]) 161 | manager = SyntheticManager() 162 | wrapper = DashboardWidget() 163 | 164 | manager.newFrame.connect(wrapper.set_data) 165 | wrapper.simulationStarted.connect(manager.toggle_simulation) 166 | wrapper.stageMoved.connect(manager.move_stage) 167 | manager.emit_frame() # just to populate the viewer with an image 168 | wrapper.show() 169 | app.exec() 170 | -------------------------------------------------------------------------------- /examples/cookbook/multi_ndv.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[vispy,pyqt]", 4 | # "imageio[tifffile]", 5 | # ] 6 | # /// 7 | """An example on how to embed multiple `ArrayViewer` controllers in a custom Qt widget. 8 | 9 | It shows the `astronaut` and `cells3d` images side by side on two different viewers. 10 | """ 11 | 12 | from qtpy import QtWidgets 13 | 14 | from ndv import ArrayViewer, run_app 15 | from ndv.data import astronaut, cells3d 16 | 17 | 18 | class MultiNDVWrapper(QtWidgets.QWidget): 19 | def __init__(self) -> None: 20 | super().__init__() 21 | 22 | self._astronaut_viewer = ArrayViewer(astronaut().mean(axis=-1)) 23 | self._cells_virewer = ArrayViewer(cells3d(), current_index={0: 30, 1: 1}) 24 | 25 | # get `ArrayViewer` widget and add it to the layout 26 | layout = QtWidgets.QHBoxLayout(self) 27 | layout.addWidget(self._astronaut_viewer.widget()) 28 | layout.addWidget(self._cells_virewer.widget()) 29 | 30 | 31 | app = QtWidgets.QApplication([]) 32 | widget = MultiNDVWrapper() 33 | widget.show() 34 | run_app() 35 | -------------------------------------------------------------------------------- /examples/cookbook/ndv_embedded.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[vispy,pyqt]", 4 | # "imageio[tifffile]", 5 | # ] 6 | # /// 7 | """An example on how to embed the `ArrayViewer` controller in a custom Qt widget.""" 8 | 9 | from qtpy import QtWidgets 10 | 11 | from ndv import ArrayViewer, run_app 12 | from ndv.data import astronaut, cat 13 | 14 | 15 | class EmbeddingWidget(QtWidgets.QWidget): 16 | def __init__(self) -> None: 17 | super().__init__() 18 | self._viewer = ArrayViewer() 19 | 20 | self._cat_button = QtWidgets.QPushButton("Load cat image") 21 | self._cat_button.clicked.connect(self._load_cat) 22 | 23 | self._astronaut_button = QtWidgets.QPushButton("Load astronaut image") 24 | self._astronaut_button.clicked.connect(self._load_astronaut) 25 | 26 | btns = QtWidgets.QHBoxLayout() 27 | btns.addWidget(self._cat_button) 28 | btns.addWidget(self._astronaut_button) 29 | 30 | layout = QtWidgets.QVBoxLayout(self) 31 | # `ArrayViewer.widget()` returns the native Qt widget 32 | layout.addWidget(self._viewer.widget()) 33 | layout.addLayout(btns) 34 | 35 | self._load_cat() 36 | 37 | def _load_cat(self) -> None: 38 | self._viewer.data = cat().mean(axis=-1) 39 | 40 | def _load_astronaut(self) -> None: 41 | self._viewer.data = astronaut().mean(axis=-1) 42 | 43 | 44 | app = QtWidgets.QApplication([]) 45 | widget = EmbeddingWidget() 46 | widget.show() 47 | run_app() 48 | -------------------------------------------------------------------------------- /examples/custom_store.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import numpy as np 6 | 7 | import ndv 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Hashable, Mapping, Sequence 11 | 12 | 13 | class MyArrayThing: 14 | """Some custom data type that we want to visualize.""" 15 | 16 | def __init__(self, shape: tuple[int, ...]) -> None: 17 | self.shape = shape 18 | self._data = np.random.randint(0, 256, shape).astype(np.uint16) 19 | 20 | def __getitem__(self, item: Any) -> np.ndarray: 21 | return self._data[item] # type: ignore [no-any-return] 22 | 23 | 24 | class MyWrapper(ndv.DataWrapper[MyArrayThing]): 25 | @classmethod 26 | def supports(cls, data: Any) -> bool: 27 | """Return True if the data is supported by this wrapper""" 28 | if isinstance(data, MyArrayThing): 29 | return True 30 | return False 31 | 32 | @property 33 | def dims(self) -> tuple[Hashable, ...]: 34 | """Return the dimensions of the data""" 35 | return tuple(f"dim_{k}" for k in range(len(self.data.shape))) 36 | 37 | @property 38 | def coords(self) -> dict[Hashable, Sequence]: 39 | """Return a mapping of {dim: coords} for the data""" 40 | return {f"dim_{k}": range(v) for k, v in enumerate(self.data.shape)} 41 | 42 | @property 43 | def dtype(self) -> np.dtype: 44 | """Return the dtype of the data""" 45 | return self.data._data.dtype 46 | 47 | def isel(self, indexers: Mapping[int, int | slice]) -> np.ndarray: 48 | """Select a subset of the data. 49 | 50 | `indexers` is a mapping of {dim: index} where index is either an integer or a 51 | slice. 52 | """ 53 | idx = tuple(indexers.get(k, slice(None)) for k in range(len(self.data.shape))) 54 | return self.data[idx] 55 | 56 | 57 | data = MyArrayThing((10, 3, 512, 512)) 58 | ndv.imshow(data) 59 | -------------------------------------------------------------------------------- /examples/dask_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "dask[array]", 5 | # ] 6 | # /// 7 | from __future__ import annotations 8 | 9 | import numpy as np 10 | 11 | try: 12 | from dask.array.core import map_blocks 13 | except ImportError: 14 | raise ImportError("Please `pip install dask[array]` to run this example.") 15 | import ndv 16 | 17 | frame_size = (1024, 1024) 18 | 19 | 20 | def _dask_block(block_id: tuple[int, int, int, int, int]) -> np.ndarray | None: 21 | if isinstance(block_id, np.ndarray): 22 | return None 23 | data = np.random.randint(0, 255, size=frame_size, dtype=np.uint8) 24 | return data[(None,) * 3] 25 | 26 | 27 | chunks = [(1,) * x for x in (1000, 64, 3)] 28 | chunks += [(x,) for x in frame_size] 29 | dask_arr = map_blocks(_dask_block, chunks=chunks, dtype=np.uint8) 30 | 31 | v = ndv.imshow(dask_arr) 32 | -------------------------------------------------------------------------------- /examples/jax_arr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | try: 4 | import jax.numpy as jnp 5 | except ImportError: 6 | raise ImportError("Please install jax to run this example") 7 | import ndv 8 | 9 | jax_arr = jnp.asarray(ndv.data.nd_sine_wave()) 10 | v = ndv.imshow(jax_arr) 11 | -------------------------------------------------------------------------------- /examples/notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "aa695816", 7 | "metadata": { 8 | "jupyter": { 9 | "source_hidden": true 10 | } 11 | }, 12 | "outputs": [], 13 | "source": [ 14 | "# /// script\n", 15 | "# requires-python = \">=3.10\"\n", 16 | "# dependencies = [\n", 17 | "# \"imageio[tifffile]\",\n", 18 | "# \"ndv[jupyter,vispy]\",\n", 19 | "# ]\n", 20 | "# ///" 21 | ] 22 | }, 23 | { 24 | "cell_type": "code", 25 | "execution_count": 1, 26 | "id": "461399b0-e02d-43d9-9ede-c1aa6c180338", 27 | "metadata": {}, 28 | "outputs": [ 29 | { 30 | "data": { 31 | "application/vnd.jupyter.widget-view+json": { 32 | "model_id": "61c74e75ad264c2d954595f4b310f649", 33 | "version_major": 2, 34 | "version_minor": 0 35 | }, 36 | "text/plain": [ 37 | "RFBOutputContext()" 38 | ] 39 | }, 40 | "metadata": {}, 41 | "output_type": "display_data" 42 | }, 43 | { 44 | "data": { 45 | "application/vnd.jupyter.widget-view+json": { 46 | "model_id": "1fa995f6b1c4423aa24e73b9c4ce29f0", 47 | "version_major": 2, 48 | "version_minor": 0 49 | }, 50 | "text/plain": [ 51 | "VBox(children=(HBox(children=(Label(value='.ndarray (60, 2, 256, 256), uint16, 15.73MB'), Image(value=b'GIF89a…" 52 | ] 53 | }, 54 | "metadata": {}, 55 | "output_type": "display_data" 56 | } 57 | ], 58 | "source": [ 59 | "from ndv import data, imshow\n", 60 | "\n", 61 | "viewer = imshow(data.cells3d())\n", 62 | "viewer.display_model.channel_mode = \"composite\"\n", 63 | "viewer.display_model.current_index.update({0: 32})" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": 3, 69 | "id": "455ebabe-c2c1-4366-9784-65e45def5aa2", 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [ 73 | "viewer.display_model.visible_axes = (0, 3)" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": 4, 79 | "id": "fb96f975-aa05-4d8a-9f85-f4316293e05d", 80 | "metadata": {}, 81 | "outputs": [], 82 | "source": [ 83 | "viewer.display_model.default_lut.cmap = \"magma\"\n", 84 | "viewer.display_model.channel_mode = \"grayscale\"" 85 | ] 86 | } 87 | ], 88 | "metadata": { 89 | "kernelspec": { 90 | "display_name": "Python 3 (ipykernel)", 91 | "language": "python", 92 | "name": "python3" 93 | }, 94 | "language_info": { 95 | "codemirror_mode": { 96 | "name": "ipython", 97 | "version": 3 98 | }, 99 | "file_extension": ".py", 100 | "mimetype": "text/x-python", 101 | "name": "python", 102 | "nbconvert_exporter": "python", 103 | "pygments_lexer": "ipython3", 104 | "version": "3.12.6" 105 | } 106 | }, 107 | "nbformat": 4, 108 | "nbformat_minor": 5 109 | } 110 | -------------------------------------------------------------------------------- /examples/numpy_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "imageio[tifffile]", 5 | # ] 6 | # /// 7 | from __future__ import annotations 8 | 9 | import ndv 10 | 11 | try: 12 | img = ndv.data.cells3d() 13 | except Exception as e: 14 | print(e) 15 | img = ndv.data.nd_sine_wave((10, 3, 8, 512, 512)) 16 | 17 | viewer = ndv.imshow(img) 18 | -------------------------------------------------------------------------------- /examples/pyopencl_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "pyopencl", 5 | # "siphash24", 6 | # ] 7 | # /// 8 | from __future__ import annotations 9 | 10 | try: 11 | import pyopencl as cl 12 | import pyopencl.array as cl_array 13 | except ImportError: 14 | raise ImportError("Please install pyopencl to run this example") 15 | import ndv 16 | 17 | # Set up OpenCL context and queue 18 | context = cl.create_some_context(interactive=False) 19 | queue = cl.CommandQueue(context) 20 | 21 | 22 | gpu_data = cl_array.to_device(queue, ndv.data.nd_sine_wave()) 23 | 24 | ndv.imshow(gpu_data) 25 | -------------------------------------------------------------------------------- /examples/rgb.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # ] 5 | # /// 6 | import ndv 7 | 8 | n = ndv.imshow(ndv.data.rgba()) 9 | -------------------------------------------------------------------------------- /examples/sparse_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "sparse", 5 | # ] 6 | # /// 7 | from __future__ import annotations 8 | 9 | try: 10 | import sparse 11 | except ImportError: 12 | raise ImportError("Please install sparse to run this example") 13 | 14 | import numpy as np 15 | 16 | import ndv 17 | 18 | shape = (255, 4, 512, 512) 19 | N = int(np.prod(shape) * 0.001) 20 | coords = np.random.randint(low=0, high=shape, size=(N, len(shape)), dtype=np.uint16).T 21 | data = np.random.randint(0, 255, N, dtype=np.uint8) 22 | 23 | 24 | # Create the sparse array from the coordinates and data 25 | sparse_array = sparse.COO(coords, data, shape=shape) 26 | 27 | ndv.imshow(sparse_array) 28 | -------------------------------------------------------------------------------- /examples/streaming.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,pygfx]", 4 | # "imageio[tifffile]", 5 | # ] 6 | # /// 7 | """Example of streaming data.""" 8 | 9 | import numpy as np 10 | 11 | import ndv 12 | 13 | # some data we're going to stream (as if it was coming from a camera) 14 | data = ndv.data.cells3d()[:, 1] 15 | 16 | # a buffer to hold the current frame in the viewer 17 | buffer = np.zeros_like(data[0]) 18 | viewer = ndv.ArrayViewer(buffer) 19 | viewer.show() 20 | 21 | 22 | # function that will be called after the app is running 23 | def stream(nframes: int = len(data) * 4) -> None: 24 | for i in range(nframes): 25 | # iterate over the data, update the buffer *in place*, 26 | buffer[:] = data[i % len(data)] 27 | # and update the viewer index to redraw 28 | viewer.display_model.current_index.update() 29 | ndv.process_events() # force viewer updates for this example 30 | 31 | 32 | ndv.call_later(200, stream) 33 | ndv.run_app() 34 | -------------------------------------------------------------------------------- /examples/streaming_with_history.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,pygfx]", 4 | # "imageio[tifffile]", 5 | # ] 6 | # /// 7 | """Example of streaming data, retaining the last N frames.""" 8 | 9 | import ndv 10 | from ndv.models import RingBuffer 11 | 12 | # some data we're going to stream (as if it was coming from a camera) 13 | data = ndv.data.cells3d()[:, 1] 14 | 15 | # a ring buffer to hold the data as it comes in 16 | # the ring buffer is a circular buffer that holds the last N frames 17 | N = 50 18 | rb = RingBuffer(max_capacity=N, dtype=(data.dtype, data.shape[-2:])) 19 | 20 | # pass the ring buffer to the viewer 21 | viewer = ndv.ArrayViewer(rb) 22 | viewer.show() 23 | 24 | 25 | # function that will be called after the app is running 26 | def stream() -> None: 27 | # iterate over the data, add it to the ring buffer 28 | for n, plane in enumerate(data): 29 | rb.append(plane) 30 | # and update the viewer index to redraw (and possibly move the slider) 31 | viewer.display_model.current_index.update({0: max(n, N - 1)}) 32 | 33 | ndv.process_events() # force viewer updates for this example 34 | 35 | 36 | ndv.call_later(200, stream) 37 | ndv.run_app() 38 | -------------------------------------------------------------------------------- /examples/tensorstore_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "tensorstore", 5 | # ] 6 | # /// 7 | from __future__ import annotations 8 | 9 | import ndv 10 | 11 | try: 12 | from ndv.data import cosem_dataset 13 | 14 | ts_array = cosem_dataset() 15 | except ImportError: 16 | raise ImportError("Please install tensorstore to run this example") 17 | 18 | ndv.imshow(ts_array) 19 | -------------------------------------------------------------------------------- /examples/torch_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "torch>=2.5", 5 | # ] 6 | # /// 7 | from __future__ import annotations 8 | 9 | try: 10 | import torch 11 | except ImportError: 12 | raise ImportError("Please install torch to run this example") 13 | 14 | import warnings 15 | 16 | import ndv 17 | 18 | warnings.filterwarnings("ignore", "Named tensors") # Named tensors are experimental 19 | 20 | # Example usage 21 | try: 22 | torch_data = torch.tensor( # type: ignore [call-arg] 23 | ndv.data.nd_sine_wave(), 24 | names=("t", "c", "z", "y", "x"), 25 | ) 26 | except TypeError: 27 | print("Named tensors are not supported in your version of PyTorch") 28 | torch_data = torch.tensor(ndv.data.nd_sine_wave()) 29 | 30 | ndv.imshow(torch_data, visible_axes=("y", -1)) 31 | -------------------------------------------------------------------------------- /examples/xarray_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "xarray", 5 | # "scipy", 6 | # "pooch", 7 | # ] 8 | # /// 9 | from __future__ import annotations 10 | 11 | try: 12 | import xarray as xr 13 | except ImportError: 14 | raise ImportError("Please install xarray[io] to run this example") 15 | import ndv 16 | 17 | da = xr.tutorial.open_dataset("air_temperature").air 18 | ndv.imshow(da, default_lut={"cmap": "thermal"}, visible_axes=("lat", "lon")) 19 | -------------------------------------------------------------------------------- /examples/zarr_arr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "ndv[pyqt,vispy]", 4 | # "zarr", 5 | # "fsspec", 6 | # "aiohttp", 7 | # ] 8 | # /// 9 | from __future__ import annotations 10 | 11 | import ndv 12 | 13 | try: 14 | import zarr 15 | import zarr.storage 16 | 17 | URL = "https://janelia-cosem-datasets.s3.amazonaws.com/jrc_hela-3/jrc_hela-3.zarr/recon-1/em/fibsem-uint8" 18 | 19 | zarr_arr = zarr.open(URL, mode="r") 20 | except ImportError: 21 | raise ImportError("Please `pip install zarr aiohttp` to run this example") 22 | 23 | 24 | ndv.imshow(zarr_arr["s4"], current_index={1: 30}, visible_axes=(0, 2)) 25 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "ndv" 2 | site_url: https://pyapp-kit.github.io/ndv/ 3 | site_description: "minimal n-dimensional python array viewer." 4 | repo_name: "pyapp-kit/ndv" 5 | repo_url: https://github.com/pyapp-kit/ndv 6 | watch: [mkdocs.yml, README.md, src] 7 | 8 | # maximum strictness 9 | # https://www.mkdocs.org/user-guide/configuration/#validation 10 | strict: true 11 | validation: 12 | omitted_files: warn 13 | absolute_links: warn 14 | unrecognized_links: warn 15 | anchors: warn 16 | 17 | nav: 18 | - Home: index.md 19 | - install.md 20 | - motivation.md 21 | - env_var.md 22 | - Cookbook: 23 | - cookbook/embedding.md 24 | - cookbook/streaming.md 25 | # This is populated by api-autonav plugin 26 | # - API reference: reference/ 27 | 28 | theme: 29 | name: material 30 | custom_dir: docs/_overrides 31 | icon: 32 | logo: material/cube-scan 33 | repo: fontawesome/brands/github 34 | palette: 35 | # Palette toggle for automatic mode 36 | - media: "(prefers-color-scheme)" 37 | toggle: 38 | icon: material/brightness-auto 39 | name: Switch to light mode 40 | 41 | # Palette toggle for light mode 42 | - media: "(prefers-color-scheme: light)" 43 | scheme: default 44 | primary: blue 45 | toggle: 46 | icon: material/brightness-7 47 | name: Switch to dark mode 48 | 49 | # Palette toggle for dark mode 50 | - media: "(prefers-color-scheme: dark)" 51 | scheme: slate 52 | primary: blue grey 53 | toggle: 54 | icon: material/brightness-4 55 | name: Switch to system preference 56 | features: 57 | - search.highlight 58 | - search.suggest 59 | - content.code.copy 60 | - content.code.annotate 61 | - navigation.indexes 62 | - navigation.footer 63 | - navigation.sections 64 | - toc.follow 65 | 66 | extra_css: 67 | - css/material.css 68 | - css/ndv.css 69 | - css/install-table.css 70 | 71 | extra_javascript: 72 | - js/install-table.js 73 | 74 | markdown_extensions: 75 | - admonition 76 | - attr_list 77 | - md_in_html 78 | - pymdownx.details 79 | - pymdownx.keys 80 | - pymdownx.tilde 81 | - pymdownx.tabbed: 82 | alternate_style: true 83 | - pymdownx.emoji: 84 | emoji_index: !!python/name:material.extensions.emoji.twemoji 85 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 86 | - pymdownx.snippets: 87 | check_paths: true 88 | - pymdownx.highlight: 89 | pygments_lang_class: true 90 | line_spans: __span 91 | - pymdownx.tasklist: 92 | custom_checkbox: true 93 | - pymdownx.inlinehilite 94 | - pymdownx.superfences 95 | - toc: 96 | permalink: "#" 97 | 98 | plugins: 99 | - autorefs: 100 | resolve_closest: true 101 | - search 102 | - minify: 103 | minify_html: true 104 | minify_js: true 105 | minify_css: true 106 | cache_safe: true 107 | - spellcheck: 108 | backends: 109 | - codespell: 110 | dictionaries: [clear] 111 | - api-autonav: 112 | modules: [src/ndv] 113 | - mkdocstrings: 114 | handlers: 115 | python: 116 | inventories: 117 | - https://docs.python.org/3/objects.inv 118 | - https://numpy.org/doc/stable/objects.inv 119 | - https://docs.pydantic.dev/latest/objects.inv 120 | - https://cmap-docs.readthedocs.io/objects.inv 121 | - https://psygnal.readthedocs.io/en/latest/objects.inv 122 | options: 123 | docstring_section_style: list 124 | docstring_style: "numpy" 125 | filters: ["!^_"] 126 | heading_level: 1 127 | inherited_members: true 128 | merge_init_into_class: true 129 | parameter_headings: true 130 | preload_modules: [ndv] 131 | relative_crossrefs: true 132 | scoped_crossrefs: true 133 | separate_signature: true 134 | # show_bases: false 135 | show_inheritance_diagram: true 136 | show_root_heading: true 137 | # show_root_full_path: false 138 | show_signature_annotations: true 139 | # show_source: false 140 | show_symbol_type_heading: true 141 | show_symbol_type_toc: true 142 | signature_crossrefs: true 143 | summary: true 144 | unwrap_annotated: true 145 | 146 | hooks: 147 | - docs/hooks.py 148 | 149 | extra: 150 | version: 151 | provider: mike 152 | # either of these tags will enable the "viewing pre" announcement banner 153 | # see _overrides/main.html 154 | pre_release: !ENV ["DOCS_PRERELEASE", false] 155 | dev_build: !ENV ["DOCS_DEV", false] 156 | social: 157 | - icon: fontawesome/brands/github 158 | link: https://github.com/pyapp-kit 159 | - icon: fontawesome/brands/python 160 | link: https://pypi.org/project/ndv/ 161 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://peps.python.org/pep-0517/ 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | # https://hatch.pypa.io/latest/config/metadata/ 7 | [tool.hatch.version] 8 | source = "vcs" 9 | 10 | # read more about configuring hatch at: 11 | # https://hatch.pypa.io/latest/config/build/ 12 | [tool.hatch.build.targets.wheel] 13 | only-include = ["src"] 14 | sources = ["src"] 15 | 16 | # https://peps.python.org/pep-0621/ 17 | [project] 18 | name = "ndv" 19 | dynamic = ["version"] 20 | description = "Simple, fast-loading, n-dimensional array viewer, with minimal dependencies." 21 | readme = "README.md" 22 | requires-python = ">=3.9" 23 | license = { text = "BSD-3-Clause" } 24 | authors = [ 25 | { name = "Talley Lambert", email = "talley.lambert@gmail.com" }, 26 | { name = "Gabriel Selzer", email = "gjselzer@wisc.edu" }, 27 | ] 28 | classifiers = [ 29 | "Development Status :: 3 - Alpha", 30 | "License :: OSI Approved :: BSD License", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Typing :: Typed", 38 | ] 39 | dependencies = [ 40 | "cmap >=0.3", 41 | "numpy >=1.23", 42 | "psygnal >=0.10", 43 | "pydantic >=2.9", 44 | "typing_extensions >= 4.0", 45 | ] 46 | 47 | # https://peps.python.org/pep-0621/#dependencies-optional-dependencies 48 | [project.optional-dependencies] 49 | # Supported GUI frontends 50 | jupyter = [ 51 | "ipywidgets >=8.0.5", 52 | "jupyter >=1.1", 53 | "jupyter_rfb >=0.3.3", 54 | "glfw >=2.4", 55 | ] 56 | pyqt = [ 57 | "pyqt6 >=6.4,!=6.6", 58 | "pyqt6 >=6.5.3; python_version >= '3.12'", 59 | "qtpy >=2", 60 | "superqt[iconify] >=0.7.2", 61 | ] 62 | pyside = [ 63 | # defer to superqt's pyside6 restrictions 64 | "superqt[iconify,pyside6] >=0.7.2", 65 | # https://github.com/pyapp-kit/ndv/issues/59 66 | "pyside6 ==6.6.3; sys_platform == 'win32'", 67 | "numpy >=1.23,<2; sys_platform == 'win32'", # needed for pyside6.6 68 | "pyside6 >=6.4", 69 | "pyside6 >=6.6; python_version >= '3.12'", 70 | "qtpy >=2", 71 | ] 72 | wxpython = [ 73 | "pyconify>=0.2.1", 74 | "wxpython >=4.2.2", 75 | ] 76 | 77 | # Supported Canavs backends 78 | vispy = ["vispy>=0.14.3", "pyopengl >=3.1"] 79 | pygfx = ["pygfx>=0.8.0"] 80 | 81 | # ready to go bundles with pygfx 82 | qt = ["ndv[pygfx,pyqt]", "imageio[tifffile] >=2.20"] 83 | jup = ["ndv[pygfx,jupyter]", "imageio[tifffile] >=2.20"] 84 | wx = ["ndv[pygfx,wxpython]", "imageio[tifffile] >=2.20"] 85 | 86 | 87 | [project.urls] 88 | homepage = "https://github.com/pyapp-kit/ndv" 89 | repository = "https://github.com/pyapp-kit/ndv" 90 | 91 | [dependency-groups] 92 | array-libs = [ 93 | "aiohttp>=3.11.11", 94 | "dask[array]>=2024.8.0", 95 | "jax[cpu]>=0.4.30", 96 | "numpy>=1.23", 97 | "pooch>=1.8.2", 98 | "pyopencl>=2025.1", 99 | "pyopencl[pocl]>=2025.1 ; sys_platform == 'linux'", 100 | "sparse>=0.15.5", 101 | "tensorstore>=0.1.69", 102 | "torch>=2.6.0", 103 | "xarray>=2024.7.0", 104 | "zarr >2,<3", 105 | ] 106 | test = ["pytest >=8", "pytest-cov >=6"] 107 | testqt = [{ include-group = "test" }, "pytest-qt >=4.4"] 108 | dev = [ 109 | { include-group = "testqt" }, 110 | # omitting wxpython from dev env for now 111 | # because `uv sync && pytest hangs` on a wx test in the "full" env 112 | # use `make test extras=wx,[pygfx|vispy] isolated=1` to test 113 | "ndv[vispy,pygfx,pyqt,jupyter]", 114 | "imageio[tifffile] >=2.20", 115 | "ipykernel>=6.29.5", 116 | "ipython>=8.18.1", 117 | "mypy>=1.14.1", 118 | "pdbpp>=0.10.3 ; sys_platform != 'win32'", 119 | "pre-commit>=4.1.0", 120 | "rich>=13.9.4", 121 | "ruff>=0.9.4", 122 | ] 123 | docs = [ 124 | "mkdocs >=1.6.1", 125 | "mkdocs-api-autonav >=0.1.2", 126 | "mkdocs-material >=9.5.49", 127 | "mkdocs-minify-plugin >=0.8.0", 128 | "mkdocs-spellcheck[codespell] >=1.1.0", 129 | "mkdocstrings-python >=1.13.0", 130 | "mike >=2.1.3", 131 | "ruff>=0.9.4", 132 | # EXAMPLES 133 | "ndv[vispy,qt]", 134 | "openwfs >=1.0.0", 135 | ] 136 | [tool.uv.sources] 137 | ndv = { workspace = true } 138 | 139 | # https://docs.astral.sh/ruff 140 | [tool.ruff] 141 | line-length = 88 142 | target-version = "py39" 143 | src = ["src"] 144 | 145 | # https://docs.astral.sh/ruff/rules 146 | [tool.ruff.lint] 147 | pydocstyle = { convention = "numpy" } 148 | select = [ 149 | "E", # style errors 150 | "W", # style warnings 151 | "F", # flakes 152 | "D", # pydocstyle 153 | "D417", # Missing argument descriptions in Docstrings 154 | "I", # isort 155 | "UP", # pyupgrade 156 | "C4", # flake8-comprehensions 157 | "B", # flake8-bugbear 158 | "A001", # flake8-builtins 159 | "RUF", # ruff-specific rules 160 | "TC", # flake8-type-checking 161 | "TID", # flake8-tidy-imports 162 | ] 163 | ignore = [ 164 | "D401", # First line should be in imperative mood 165 | "D10", # Missing docstring... 166 | ] 167 | 168 | [tool.ruff.lint.per-file-ignores] 169 | "tests/*.py" = ["D", "S"] 170 | "examples/*.py" = ["D", "B9"] 171 | 172 | 173 | # https://docs.astral.sh/ruff/formatter/ 174 | [tool.ruff.format] 175 | docstring-code-format = true 176 | skip-magic-trailing-comma = false # default is false 177 | 178 | # https://mypy.readthedocs.io/en/stable/config_file.html 179 | [tool.mypy] 180 | files = "src/**/*.py" 181 | strict = true 182 | disallow_any_generics = false 183 | disallow_subclassing_any = false 184 | show_error_codes = true 185 | pretty = true 186 | plugins = ["pydantic.mypy"] 187 | 188 | [[tool.mypy.overrides]] 189 | module = ["jupyter_rfb.*", "vispy.*", "ipywidgets.*"] 190 | ignore_missing_imports = true 191 | 192 | # https://docs.pytest.org/ 193 | [tool.pytest.ini_options] 194 | addopts = ["-v"] 195 | minversion = "7.0" 196 | testpaths = ["tests"] 197 | filterwarnings = [ 198 | "error", 199 | "ignore:Method has been garbage collected::superqt", 200 | # occasionally happens on linux CI with vispy 201 | "ignore:Got wrong number of dimensions", 202 | "ignore:Unable to import recommended hash", 203 | # CI-only error on jupyter, vispy, macos 204 | "ignore:.*Failed to find a suitable pixel format", 205 | # unsolved python 3.10 warning on shutdown, either xarray or dask 206 | "ignore:unclosed transport:ResourceWarning", 207 | "ignore:Jupyter is migrating its paths", 208 | ] 209 | markers = ["allow_leaks: mark test to allow widget leaks"] 210 | 211 | # https://coverage.readthedocs.io/ 212 | [tool.coverage.report] 213 | show_missing = true 214 | exclude_lines = [ 215 | "pragma: no cover", 216 | "if TYPE_CHECKING:", 217 | "@overload", 218 | "except ImportError", 219 | "\\.\\.\\.", 220 | "raise NotImplementedError()", 221 | "pass", 222 | ] 223 | 224 | 225 | [tool.coverage.run] 226 | source = ["ndv"] 227 | omit = ["src/ndv/viewer/_backends/_protocols.py"] 228 | 229 | [tool.check-manifest] 230 | ignore = [".pre-commit-config.yaml", ".ruff_cache/**/*", "tests/**/*"] 231 | 232 | [tool.typos.default] 233 | extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome", ".*ser_schema"] 234 | 235 | [tool.typos.files] 236 | extend-exclude = ["**/*.ipynb"] 237 | -------------------------------------------------------------------------------- /src/ndv/__init__.py: -------------------------------------------------------------------------------- 1 | """Fast and flexible n-dimensional data viewer.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | try: 6 | __version__ = version("ndv") 7 | except PackageNotFoundError: 8 | __version__ = "uninstalled" 9 | 10 | from . import data 11 | from .controllers import ArrayViewer 12 | from .models import DataWrapper 13 | from .util import imshow 14 | from .views import ( 15 | call_later, 16 | process_events, 17 | run_app, 18 | set_canvas_backend, 19 | set_gui_backend, 20 | ) 21 | 22 | __all__ = [ 23 | "ArrayViewer", 24 | "DataWrapper", 25 | "call_later", 26 | "data", 27 | "imshow", 28 | "process_events", 29 | "run_app", 30 | "set_canvas_backend", 31 | "set_gui_backend", 32 | ] 33 | -------------------------------------------------------------------------------- /src/ndv/_types.py: -------------------------------------------------------------------------------- 1 | """General model for ndv.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Hashable, Sequence 6 | from contextlib import suppress 7 | from enum import Enum, IntFlag, auto 8 | from functools import cache 9 | from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast 10 | 11 | from pydantic import PlainSerializer, PlainValidator 12 | from typing_extensions import TypeAlias 13 | 14 | if TYPE_CHECKING: 15 | from qtpy.QtCore import Qt 16 | from qtpy.QtWidgets import QWidget 17 | from wx import Cursor 18 | 19 | from ndv.views.bases import Viewable 20 | 21 | 22 | def _maybe_int(val: Any) -> Any: 23 | # try to convert to int if possible 24 | with suppress(ValueError, TypeError): 25 | val = int(float(val)) 26 | return val 27 | 28 | 29 | def _to_slice(val: Any) -> slice: 30 | # slices are returned as is 31 | if isinstance(val, slice): 32 | if not all( 33 | isinstance(i, (int, type(None))) for i in (val.start, val.stop, val.step) 34 | ): 35 | raise TypeError(f"Slice start/stop/step must all be integers: {val!r}") 36 | return val 37 | # single integers are converted to slices starting at that index 38 | if isinstance(val, int): 39 | return slice(val, val + 1) 40 | # sequences are interpreted as arguments to the slice constructor 41 | if isinstance(val, Sequence): 42 | return slice(*(int(x) if x is not None else None for x in val)) 43 | raise TypeError(f"Expected int or slice, got {type(val)}") 44 | 45 | 46 | Slice = Annotated[slice, PlainValidator(_to_slice)] 47 | 48 | # An axis key is any hashable object that can be used to index an axis 49 | # In many cases it will be an integer, but for some labeled arrays it may be a string 50 | # or other hashable object. It is up to the DataWrapper to convert these keys to 51 | # actual integer indices. 52 | AxisKey: TypeAlias = Annotated[ 53 | Hashable, PlainValidator(_maybe_int), PlainSerializer(str, return_type=str) 54 | ] 55 | # An channel key is any hashable object that can be used to describe a position along 56 | # an axis. In many cases it will be an integer, but it might also provide a contextual 57 | # label for one or more positions. 58 | ChannelKey: TypeAlias = Annotated[ 59 | Hashable, PlainValidator(_maybe_int), PlainSerializer(str, return_type=str) 60 | ] 61 | 62 | 63 | class MouseButton(IntFlag): 64 | LEFT = auto() 65 | MIDDLE = auto() 66 | RIGHT = auto() 67 | NONE = auto() 68 | 69 | 70 | class MouseMoveEvent(NamedTuple): 71 | """Event emitted when the user moves the cursor.""" 72 | 73 | x: float 74 | y: float 75 | btn: MouseButton = MouseButton.NONE 76 | 77 | 78 | class MousePressEvent(NamedTuple): 79 | """Event emitted when mouse button is pressed.""" 80 | 81 | x: float 82 | y: float 83 | btn: MouseButton 84 | 85 | 86 | class MouseReleaseEvent(NamedTuple): 87 | """Event emitted when mouse button is released.""" 88 | 89 | x: float 90 | y: float 91 | btn: MouseButton 92 | 93 | 94 | class CursorType(Enum): 95 | DEFAULT = "default" 96 | CROSS = "cross" 97 | V_ARROW = "v_arrow" 98 | H_ARROW = "h_arrow" 99 | ALL_ARROW = "all_arrow" 100 | BDIAG_ARROW = "bdiag_arrow" 101 | FDIAG_ARROW = "fdiag_arrow" 102 | 103 | def apply_to(self, widget: Viewable) -> None: 104 | """Applies the cursor type to the given widget.""" 105 | native = widget.frontend_widget() 106 | if hasattr(native, "setCursor"): 107 | cast("QWidget", native).setCursor(self.to_qt()) 108 | 109 | def to_qt(self) -> Qt.CursorShape: 110 | """Converts CursorType to Qt.CursorShape.""" 111 | from qtpy.QtCore import Qt 112 | 113 | return { 114 | CursorType.DEFAULT: Qt.CursorShape.ArrowCursor, 115 | CursorType.CROSS: Qt.CursorShape.CrossCursor, 116 | CursorType.V_ARROW: Qt.CursorShape.SizeVerCursor, 117 | CursorType.H_ARROW: Qt.CursorShape.SizeHorCursor, 118 | CursorType.ALL_ARROW: Qt.CursorShape.SizeAllCursor, 119 | CursorType.BDIAG_ARROW: Qt.CursorShape.SizeBDiagCursor, 120 | CursorType.FDIAG_ARROW: Qt.CursorShape.SizeFDiagCursor, 121 | }[self] 122 | 123 | def to_jupyter(self) -> str: 124 | """Converts CursorType to jupyter cursor strings.""" 125 | return { 126 | CursorType.DEFAULT: "default", 127 | CursorType.CROSS: "crosshair", 128 | CursorType.V_ARROW: "ns-resize", 129 | CursorType.H_ARROW: "ew-resize", 130 | CursorType.ALL_ARROW: "move", 131 | CursorType.BDIAG_ARROW: "nesw-resize", 132 | CursorType.FDIAG_ARROW: "nwse-resize", 133 | }[self] 134 | 135 | # Note a new object must be created every time. We should cache it! 136 | @cache 137 | def to_wx(self) -> Cursor: 138 | """Converts CursorType to jupyter cursor strings.""" 139 | from wx import ( 140 | CURSOR_ARROW, 141 | CURSOR_CROSS, 142 | CURSOR_SIZENESW, 143 | CURSOR_SIZENS, 144 | CURSOR_SIZENWSE, 145 | CURSOR_SIZEWE, 146 | CURSOR_SIZING, 147 | Cursor, 148 | ) 149 | 150 | return { 151 | CursorType.DEFAULT: Cursor(CURSOR_ARROW), 152 | CursorType.CROSS: Cursor(CURSOR_CROSS), 153 | CursorType.V_ARROW: Cursor(CURSOR_SIZENS), 154 | CursorType.H_ARROW: Cursor(CURSOR_SIZEWE), 155 | CursorType.ALL_ARROW: Cursor(CURSOR_SIZING), 156 | CursorType.BDIAG_ARROW: Cursor(CURSOR_SIZENESW), 157 | CursorType.FDIAG_ARROW: Cursor(CURSOR_SIZENWSE), 158 | }[self] 159 | -------------------------------------------------------------------------------- /src/ndv/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | """Controllers are the primary public interfaces that wrap models & views.""" 2 | 3 | from ._array_viewer import ArrayViewer 4 | 5 | __all__ = ["ArrayViewer"] 6 | -------------------------------------------------------------------------------- /src/ndv/controllers/_channel_controller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Iterable, Sequence 8 | 9 | import numpy as np 10 | 11 | from ndv._types import ChannelKey 12 | from ndv.models._lut_model import LUTModel 13 | from ndv.views.bases import LutView 14 | from ndv.views.bases._graphics._canvas_elements import ImageHandle 15 | 16 | 17 | class ChannelController: 18 | """Controller for a single channel in the viewer. 19 | 20 | This manages the connection between the LUT model (settings like colormap, 21 | contrast limits and visibility) and the LUT view (the front-end widget that 22 | allows the user to interact with these settings), as well as the image handle 23 | that displays the data, all for a single "channel" extracted from the data. 24 | """ 25 | 26 | def __init__( 27 | self, key: ChannelKey, lut_model: LUTModel, views: Sequence[LutView] 28 | ) -> None: 29 | self.key = key 30 | self.lut_views: list[LutView] = [] 31 | self.lut_model = lut_model 32 | self.lut_model.events.clims.connect(self._auto_scale) 33 | self.handles: list[ImageHandle] = [] 34 | 35 | for v in views: 36 | self.add_lut_view(v) 37 | 38 | def add_lut_view(self, view: LutView) -> None: 39 | """Add a LUT view to the controller.""" 40 | view.model = self.lut_model 41 | self.lut_views.append(view) 42 | # TODO: Could probably reuse cached clims 43 | self._auto_scale() 44 | 45 | def synchronize(self, *views: LutView) -> None: 46 | """Aligns all views against the backing model.""" 47 | _views: Iterable[LutView] = views or self.lut_views 48 | name = str(self.key) if self.key is not None else "" 49 | for view in _views: 50 | view.synchronize() 51 | view.set_channel_name(name) 52 | 53 | def update_texture_data(self, data: np.ndarray) -> None: 54 | """Update the data in the image handle.""" 55 | # WIP: 56 | # until we have a more sophisticated way to handle updating data 57 | # for multiple handles, we'll just update the first one 58 | if not (handles := self.handles): 59 | return 60 | handle = handles[0] 61 | handle.set_data(data) 62 | self._auto_scale() 63 | 64 | def add_handle(self, handle: ImageHandle) -> None: 65 | """Add an image texture handle to the controller.""" 66 | self.handles.append(handle) 67 | self.add_lut_view(handle) 68 | 69 | def get_value_at_index(self, idx: tuple[int, ...]) -> np.ndarray | float | None: 70 | """Get the value of the data at the given index.""" 71 | if not (handles := self.handles): 72 | return None 73 | # only getting one handle per channel for now 74 | handle = handles[0] 75 | if not handle.visible(): 76 | return None 77 | with suppress(IndexError): # skip out of bounds 78 | # here, we're retrieving the value from the in-memory data 79 | # stored by the backend visual, rather than querying the data itself 80 | # this is a quick workaround to get the value without having to 81 | # worry about other dimensions in the data source (since the 82 | # texture has already been reduced to RGB/RGBA/2D). But a more complete 83 | # implementation would gather the full current nD index and query 84 | # the data source directly. 85 | return handle.data()[idx] # type: ignore [no-any-return] 86 | return None 87 | 88 | def _auto_scale(self) -> None: 89 | if self.lut_model and len(self.handles): 90 | policy = self.lut_model.clims 91 | handle_clims = [policy.calc_clims(handle.data()) for handle in self.handles] 92 | mi, ma = handle_clims[0] 93 | for clims in handle_clims[1:]: 94 | mi = min(mi, clims[0]) 95 | ma = max(ma, clims[1]) 96 | 97 | for view in self.lut_views: 98 | view.set_clims((mi, ma)) 99 | -------------------------------------------------------------------------------- /src/ndv/data.py: -------------------------------------------------------------------------------- 1 | """Sample data for testing and examples.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import numpy as np 8 | 9 | __all__ = ["astronaut", "cat", "cells3d", "cosem_dataset", "nd_sine_wave"] 10 | 11 | 12 | def nd_sine_wave( 13 | shape: tuple[int, int, int, int, int] = (10, 3, 5, 512, 512), 14 | amplitude: float = 240, 15 | base_frequency: float = 5, 16 | ) -> np.ndarray: 17 | """5D dataset: `(10, 3, 5, 512, 512)`, float64.""" 18 | # Unpack the dimensions 19 | if not len(shape) == 5: 20 | raise ValueError("Shape must have 5 dimensions") 21 | angle_dim, freq_dim, phase_dim, ny, nx = shape 22 | 23 | # Create an empty array to hold the data 24 | output = np.zeros(shape) 25 | 26 | # Define spatial coordinates for the last two dimensions 27 | half_per = base_frequency * np.pi 28 | x = np.linspace(-half_per, half_per, nx) 29 | y = np.linspace(-half_per, half_per, ny) 30 | y, x = np.meshgrid(y, x) 31 | 32 | # Iterate through each parameter in the higher dimensions 33 | for phase_idx in range(phase_dim): 34 | for freq_idx in range(freq_dim): 35 | for angle_idx in range(angle_dim): 36 | # Calculate phase and frequency 37 | phase = np.pi / phase_dim * phase_idx 38 | frequency = 1 + (freq_idx * 0.1) # Increasing frequency with each step 39 | 40 | # Calculate angle 41 | angle = np.pi / angle_dim * angle_idx 42 | # Rotate x and y coordinates 43 | xr = np.cos(angle) * x - np.sin(angle) * y 44 | np.sin(angle) * x + np.cos(angle) * y 45 | 46 | # Compute the sine wave 47 | sine_wave = (amplitude * 0.5) * np.sin(frequency * xr + phase) 48 | sine_wave += amplitude * 0.5 49 | 50 | # Assign to the output array 51 | output[angle_idx, freq_idx, phase_idx] = sine_wave 52 | 53 | return output.astype(np.float32) 54 | 55 | 56 | def cells3d() -> np.ndarray: 57 | """Load cells3d from scikit-image `(60, 2, 256, 256)` uint16. 58 | 59 | Requires `imageio and tifffile` to be installed. 60 | """ 61 | try: 62 | from imageio.v2 import volread 63 | except ImportError as e: 64 | raise ImportError( 65 | "Please `pip install imageio[tifffile]` to load cells3d" 66 | ) from e 67 | 68 | url = "https://gitlab.com/scikit-image/data/-/raw/2cdc5ce89b334d28f06a58c9f0ca21aa6992a5ba/cells3d.tif" 69 | data = np.asarray(volread(url)) 70 | 71 | # this data has been stretched to 16 bit, and lacks certain intensity values 72 | # add a small random integer to each pixel ... so the histogram is not silly 73 | data = (data + np.random.randint(-24, 24, data.shape)).clip(0, 65535) 74 | return data.astype(np.uint16) 75 | 76 | 77 | def cat() -> np.ndarray: 78 | """Load RGB cat data `(300, 451, 3)`, uint8. 79 | 80 | Requires [imageio](https://pypi.org/project/imageio/) to be installed. 81 | """ 82 | return _imread("imageio:chelsea.png") 83 | 84 | 85 | def astronaut() -> np.ndarray: 86 | """Load RGB data `(512, 512, 3)`, uint8. 87 | 88 | Requires [imageio](https://pypi.org/project/imageio/) to be installed. 89 | """ 90 | return _imread("imageio:astronaut.png") 91 | 92 | 93 | def _imread(uri: str) -> np.ndarray: 94 | try: 95 | import imageio.v3 as iio 96 | except ImportError: 97 | raise ImportError("Please install imageio fetch data") from None 98 | return iio.imread(uri) # type: ignore [no-any-return] 99 | 100 | 101 | def cosem_dataset( 102 | uri: str = "", 103 | dataset: str = "jrc_hela-3", 104 | label: str = "er-mem_pred", 105 | level: int = 4, 106 | ) -> Any: 107 | """Load a dataset from the COSEM/OpenOrganelle project. 108 | 109 | Search for available options at: 110 | 111 | Requires [tensorstore](https://pypi.org/project/tensorstore/) to be installed. 112 | 113 | Parameters 114 | ---------- 115 | uri : str, optional 116 | The URI of the dataset to load. If not provided, the default URI is 117 | `f"{dataset}/{dataset}.n5/labels/{label}/s{level}/"`. 118 | dataset : str, optional 119 | The name of the dataset to load. Default is "jrc_hela-3". 120 | label : str, optional 121 | The label to load. Default is "er-mem_pred". 122 | level : int, optional 123 | The pyramid level to load. Default is 4. 124 | """ 125 | try: 126 | import tensorstore as ts 127 | except ImportError: 128 | raise ImportError("Please install tensorstore to fetch cosem data") from None 129 | 130 | if not uri: 131 | uri = f"{dataset}/{dataset}.n5/labels/{label}/s{level}/" 132 | 133 | ts_array = ts.open( 134 | { 135 | "driver": "n5", 136 | "kvstore": { 137 | "driver": "s3", 138 | "bucket": "janelia-cosem-datasets", 139 | "path": uri, 140 | }, 141 | # 1GB cache... but i don't think it's working 142 | "cache_pool": {"total_bytes_limit": 1e9}, 143 | }, 144 | ).result() 145 | ts_array = ts_array[ts.d[:].label["z", "y", "x"]] 146 | return ts_array[ts.d[("y", "x", "z")].transpose[:]] 147 | 148 | 149 | def rgba() -> np.ndarray: 150 | """3D RGBA dataset: `(256, 256, 256, 4)`, uint8.""" 151 | img = np.zeros((256, 256, 256, 4), dtype=np.uint8) 152 | 153 | # R,G,B are simple 154 | for i in range(256): 155 | img[:, i, :, 0] = i # Red 156 | img[:, i, :, 2] = 255 - i # Blue 157 | for j in range(256): 158 | img[:, :, j, 1] = j # Green 159 | 160 | # Alpha is a bit trickier - requires a meshgrid for efficient computation 161 | x, y, z = np.meshgrid(np.arange(256), np.arange(256), np.arange(256), indexing="ij") 162 | alpha = np.sqrt((x - 128) ** 2 + (y - 128) ** 2 + (z - 128) ** 2) 163 | img[:, :, :, 3] = np.clip(alpha, 0, 255) 164 | 165 | return img 166 | -------------------------------------------------------------------------------- /src/ndv/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Models for `ndv`.""" 2 | 3 | from ._array_display_model import ArrayDisplayModel, ChannelMode 4 | from ._base_model import NDVModel 5 | from ._data_wrapper import DataWrapper, RingBufferWrapper 6 | from ._lut_model import ( 7 | ClimPolicy, 8 | ClimsManual, 9 | ClimsMinMax, 10 | ClimsPercentile, 11 | ClimsStdDev, 12 | LUTModel, 13 | ) 14 | from ._ring_buffer import RingBuffer 15 | from ._roi_model import RectangularROIModel 16 | from ._viewer_model import ArrayViewerModel 17 | 18 | __all__ = [ 19 | "ArrayDisplayModel", 20 | "ArrayViewerModel", 21 | "ChannelMode", 22 | "ClimPolicy", 23 | "ClimsManual", 24 | "ClimsMinMax", 25 | "ClimsPercentile", 26 | "ClimsStdDev", 27 | "DataWrapper", 28 | "LUTModel", 29 | "NDVModel", 30 | "RectangularROIModel", 31 | "RingBuffer", 32 | "RingBufferWrapper", 33 | ] 34 | -------------------------------------------------------------------------------- /src/ndv/models/_base_model.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from psygnal import SignalGroupDescriptor 4 | from pydantic import BaseModel, ConfigDict 5 | 6 | 7 | class NDVModel(BaseModel): 8 | """Base evented model for NDV models. 9 | 10 | Uses [pydantic.BaseModel][] and [psygnal.SignalGroupDescriptor][]. 11 | """ 12 | 13 | model_config = ConfigDict( 14 | validate_assignment=True, 15 | validate_default=True, 16 | extra="forbid", 17 | ) 18 | events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor() 19 | -------------------------------------------------------------------------------- /src/ndv/models/_lut_model.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Annotated, Any, Callable, Literal, Optional, Union 3 | 4 | import numpy as np 5 | import numpy.typing as npt 6 | from annotated_types import Gt, Interval 7 | from cmap import Colormap 8 | from pydantic import ( 9 | BaseModel, 10 | ConfigDict, 11 | Field, 12 | PrivateAttr, 13 | field_validator, 14 | model_validator, 15 | ) 16 | from typing_extensions import TypeAlias 17 | 18 | from ._base_model import NDVModel 19 | 20 | AutoscaleType: TypeAlias = Union[ 21 | Callable[[npt.ArrayLike], tuple[float, float]], tuple[float, float], bool 22 | ] 23 | 24 | 25 | class ClimPolicy(BaseModel, ABC): 26 | """ABC for contrast limit policies.""" 27 | 28 | model_config = ConfigDict(frozen=True, extra="forbid") 29 | _cached_clims: Optional[tuple[float, float]] = PrivateAttr(None) 30 | 31 | @abstractmethod 32 | def get_limits(self, image: npt.NDArray) -> tuple[float, float]: 33 | """Return the contrast limits for the given image.""" 34 | 35 | def calc_clims(self, image: npt.NDArray) -> tuple[float, float]: 36 | self._cached_clims = value = self.get_limits(image) 37 | return value 38 | 39 | @property 40 | def cached_clims(self) -> Optional[tuple[float, float]]: 41 | """Return the last calculated clims.""" 42 | return self._cached_clims 43 | 44 | @property 45 | def is_manual(self) -> bool: 46 | return self.__class__ == ClimsManual 47 | 48 | 49 | class ClimsManual(ClimPolicy): 50 | """Manually specified contrast limits. 51 | 52 | Attributes 53 | ---------- 54 | min: float 55 | The minimum contrast limit. 56 | max: float 57 | The maximum contrast limit. 58 | """ 59 | 60 | clim_type: Literal["manual"] = "manual" 61 | min: float 62 | max: float 63 | 64 | def get_limits(self, data: npt.NDArray) -> tuple[float, float]: 65 | return self.min, self.max 66 | 67 | def __eq__(self, other: object) -> bool: 68 | return ( 69 | isinstance(other, ClimsManual) 70 | and abs(self.min - other.min) < 1e-6 71 | and abs(self.max - other.max) < 1e-6 72 | ) 73 | 74 | 75 | class ClimsMinMax(ClimPolicy): 76 | """Autoscale contrast limits based on the minimum and maximum values in the data.""" 77 | 78 | clim_type: Literal["minmax"] = "minmax" 79 | 80 | def get_limits(self, data: npt.NDArray) -> tuple[float, float]: 81 | return (np.nanmin(data), np.nanmax(data)) 82 | 83 | def __eq__(self, other: object) -> bool: 84 | return isinstance(other, ClimsMinMax) 85 | 86 | 87 | class ClimsPercentile(ClimPolicy): 88 | """Autoscale contrast limits based on percentiles of the data. 89 | 90 | Attributes 91 | ---------- 92 | min_percentile: float 93 | The lower percentile for the contrast limits. 94 | max_percentile: float 95 | The upper percentile for the contrast limits. 96 | """ 97 | 98 | clim_type: Literal["percentile"] = "percentile" 99 | min_percentile: Annotated[float, Interval(ge=0, le=100)] = 0 100 | max_percentile: Annotated[float, Interval(ge=0, le=100)] = 100 101 | 102 | def get_limits(self, data: npt.NDArray) -> tuple[float, float]: 103 | return tuple(np.nanpercentile(data, [self.min_percentile, self.max_percentile])) 104 | 105 | def __eq__(self, other: object) -> bool: 106 | return ( 107 | isinstance(other, ClimsPercentile) 108 | and self.min_percentile == other.min_percentile 109 | and self.max_percentile == other.max_percentile 110 | ) 111 | 112 | 113 | class ClimsStdDev(ClimPolicy): 114 | """Automatically set contrast limits based on standard deviations from the mean. 115 | 116 | Attributes 117 | ---------- 118 | n_stdev: float 119 | Number of standard deviations to use. 120 | center: Optional[float] 121 | Center value for the standard deviation calculation. If None, the mean is 122 | used. 123 | """ 124 | 125 | clim_type: Literal["stddev"] = "stddev" 126 | n_stdev: Annotated[float, Gt(0)] = 2 # number of standard deviations 127 | center: Optional[float] = None # None means center around the mean 128 | 129 | def get_limits(self, data: npt.NDArray) -> tuple[float, float]: 130 | center = np.nanmean(data) if self.center is None else self.center 131 | diff = self.n_stdev * np.nanstd(data) 132 | return center - diff, center + diff 133 | 134 | def __eq__(self, other: object) -> bool: 135 | return ( 136 | isinstance(other, ClimsStdDev) 137 | and self.n_stdev == other.n_stdev 138 | and self.center == other.center 139 | ) 140 | 141 | 142 | # we can add this, but it needs to have a proper pydantic serialization method 143 | # similar to ReducerType 144 | # class CustomClims(ClimPolicy): 145 | # type_: Literal["custom"] = "custom" 146 | # func: Callable[[npt.ArrayLike], tuple[float, float]] 147 | 148 | # def get_limits(self, data: npt.NDArray) -> tuple[float, float]: 149 | # return self.func(data) 150 | 151 | 152 | ClimsType = Union[ClimsManual, ClimsPercentile, ClimsStdDev, ClimsMinMax] 153 | 154 | 155 | class LUTModel(NDVModel): 156 | """Representation of how to display a channel of an array. 157 | 158 | Attributes 159 | ---------- 160 | visible : bool 161 | Whether to display this channel. 162 | NOTE: This has implications for data retrieval, as we may not want to request 163 | channels that are not visible. 164 | See [`ArrayDisplayModel.current_index`][ndv.models.ArrayDisplayModel]. 165 | cmap : cmap.Colormap 166 | [`cmap.Colormap`](https://cmap-docs.readthedocs.io/colormaps/) to use for this 167 | channel. This can be expressed as any channel. This can be expressed as any 168 | ["colormap like" object](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects) 169 | clims : Union[ClimsManual, ClimsPercentile, ClimsStdDev, ClimsMinMax] 170 | Method for determining the contrast limits for this channel. If a 2-element 171 | `tuple` or `list` is provided, it is interpreted as a manual contrast limit. 172 | bounds : tuple[float | None, float | None] 173 | Optional extrema for limiting the range of the contrast limits 174 | gamma : float 175 | Gamma applied to the data before applying the colormap. By default, `1.0`. 176 | """ 177 | 178 | visible: bool = True 179 | cmap: Colormap = Field(default_factory=lambda: Colormap("gray")) 180 | clims: ClimsType = Field(discriminator="clim_type", default_factory=ClimsMinMax) 181 | clim_bounds: tuple[Optional[float], Optional[float]] = (None, None) 182 | gamma: float = 1.0 183 | 184 | @model_validator(mode="before") 185 | def _validate_model(cls, v: Any) -> Any: 186 | # cast bare string/colormap inputs to cmap declaration 187 | if isinstance(v, (str, Colormap)): 188 | return {"cmap": v} 189 | return v 190 | 191 | @field_validator("clims", mode="before") 192 | @classmethod 193 | def _validate_clims(cls, v: ClimsType) -> ClimsType: 194 | if v is None or ( 195 | isinstance(v, dict) 196 | and v.get("min_percentile") == 0 197 | and v.get("max_percentile") == 100 198 | ): 199 | return ClimsMinMax() 200 | if isinstance(v, (tuple, list, np.ndarray)): 201 | if len(v) == 2: 202 | return ClimsManual(min=v[0], max=v[1]) 203 | raise ValueError("Clims sequence must have exactly 2 elements.") 204 | return v 205 | -------------------------------------------------------------------------------- /src/ndv/models/_reducer.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any, Callable, Protocol, SupportsIndex, Union, cast 3 | 4 | import numpy as np 5 | import numpy.typing as npt 6 | from pydantic_core import core_schema 7 | from typing_extensions import TypeAlias 8 | 9 | _ShapeLike: TypeAlias = Union[SupportsIndex, Sequence[SupportsIndex]] 10 | 11 | 12 | class Reducer(Protocol): 13 | """Function to reduce an array along an axis. 14 | 15 | A reducer is any function that takes an array-like, and an optional axis argument, 16 | and returns a reduced array. Examples include `np.max`, `np.mean`, etc. 17 | """ 18 | 19 | def __call__(self, a: npt.ArrayLike, axis: _ShapeLike = ...) -> npt.ArrayLike: 20 | """Reduce an array along an axis.""" 21 | 22 | 23 | def _str_to_callable(obj: Any) -> Callable: 24 | """Deserialize a callable from a string.""" 25 | if isinstance(obj, str): 26 | # e.g. "numpy.max" -> numpy.max 27 | try: 28 | mod_name, qual_name = obj.rsplit(".", 1) 29 | mod = __import__(mod_name, fromlist=[qual_name]) 30 | obj = getattr(mod, qual_name) 31 | except Exception: 32 | try: 33 | # fallback to numpy 34 | # e.g. "max" -> numpy.max 35 | obj = getattr(np, obj) 36 | except Exception: 37 | raise 38 | 39 | if not callable(obj): 40 | raise TypeError(f"Expected a callable or string, got {type(obj)}") 41 | return cast("Callable", obj) 42 | 43 | 44 | def _callable_to_str(obj: Union[str, Callable]) -> str: 45 | """Serialize a callable to a string.""" 46 | if isinstance(obj, str): 47 | return obj 48 | # e.g. numpy.max -> "numpy.max" 49 | return f"{obj.__module__}.{obj.__qualname__}" 50 | 51 | 52 | class ReducerType(Reducer): 53 | """Reducer type for pydantic. 54 | 55 | This just provides a pydantic core schema for a generic callable that accepts an 56 | array and an axis argument and returns an array (of reduced dimensionality). 57 | This serializes/deserializes the callable as a string (module.qualname). 58 | """ 59 | 60 | @classmethod 61 | def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> Any: 62 | """Get the Pydantic schema for this object.""" 63 | ser_schema = core_schema.plain_serializer_function_ser_schema(_callable_to_str) 64 | return core_schema.no_info_before_validator_function( 65 | _str_to_callable, 66 | # using callable_schema() would be more correct, but prevents dumping schema 67 | core_schema.any_schema(), 68 | serialization=ser_schema, 69 | ) 70 | -------------------------------------------------------------------------------- /src/ndv/models/_roi_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import field_validator 4 | 5 | from ndv.models._base_model import NDVModel 6 | 7 | 8 | class RectangularROIModel(NDVModel): 9 | """Representation of an axis-aligned rectangular Region of Interest (ROI). 10 | 11 | Attributes 12 | ---------- 13 | visible : bool 14 | Whether to display this roi. 15 | bounding_box : tuple[tuple[float, float], tuple[float, float]] 16 | The minimum (2D) point and the maximum (2D) point contained within the 17 | region. Using these two points, an axis-aligned bounding box can be 18 | constructed. 19 | """ 20 | 21 | visible: bool = True 22 | bounding_box: tuple[tuple[float, float], tuple[float, float]] = ((0, 0), (0, 0)) 23 | 24 | @field_validator("bounding_box", mode="after") 25 | @classmethod 26 | def _validate_bounding_box( 27 | cls, bb: tuple[tuple[float, float], tuple[float, float]] 28 | ) -> tuple[tuple[float, float], tuple[float, float]]: 29 | x1 = min(bb[0][0], bb[1][0]) 30 | y1 = min(bb[0][1], bb[1][1]) 31 | x2 = max(bb[0][0], bb[1][0]) 32 | y2 = max(bb[0][1], bb[1][1]) 33 | return ((x1, y1), (x2, y2)) 34 | -------------------------------------------------------------------------------- /src/ndv/models/_viewer_model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import TYPE_CHECKING 3 | 4 | import cmap 5 | import cmap._colormap 6 | from pydantic import Field 7 | 8 | from ndv.models._base_model import NDVModel 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | from typing import TypedDict 13 | 14 | from psygnal import Signal, SignalGroup 15 | 16 | class ArrayViewerModelKwargs(TypedDict, total=False): 17 | """Keyword arguments for `ArrayViewerModel`.""" 18 | 19 | default_luts: "Sequence[cmap._colormap.ColorStopsLike]" 20 | interaction_mode: "InteractionMode" 21 | show_controls: bool 22 | show_3d_button: bool 23 | show_histogram_button: bool 24 | show_reset_zoom_button: bool 25 | show_roi_button: bool 26 | show_channel_mode_selector: bool 27 | show_play_button: bool 28 | show_data_info: bool 29 | show_progress_spinner: bool 30 | 31 | 32 | class InteractionMode(str, Enum): 33 | """An enum defining graphical interaction mechanisms with an array Viewer.""" 34 | 35 | PAN_ZOOM = "pan_zoom" 36 | CREATE_ROI = "create_roi" 37 | 38 | def __str__(self) -> str: 39 | """Return the string representation of the enum value.""" 40 | return self.value 41 | 42 | 43 | def _default_luts() -> "list[cmap.Colormap]": 44 | return [ 45 | cmap.Colormap(x) 46 | for x in ("gray", "green", "magenta", "cyan", "red", "blue", "yellow") 47 | ] 48 | 49 | 50 | class ArrayViewerModel(NDVModel): 51 | """Options and state for the [`ArrayViewer`][ndv.ArrayViewer]. 52 | 53 | Attributes 54 | ---------- 55 | interaction_mode : InteractionMode 56 | Describes the current interaction mode of the Viewer. 57 | show_controls : bool, optional 58 | Control visibility of *all* controls at once. By default True. 59 | show_3d_button : bool, optional 60 | Whether to show the 3D button, by default True. 61 | show_histogram_button : bool, optional 62 | Whether to show the histogram button, by default True. 63 | show_reset_zoom_button : bool, optional 64 | Whether to show the reset zoom button, by default True. 65 | show_roi_button : bool, optional 66 | Whether to show the ROI button, by default True. 67 | show_channel_mode_selector : bool, optional 68 | Whether to show the channel mode selector, by default True. 69 | show_play_button : bool, optional 70 | Whether to show the play button, by default True. 71 | show_progress_spinner : bool, optional 72 | Whether to show the progress spinner, by default 73 | show_data_info : bool, optional 74 | Whether to show shape, dtype, size, etc. about the array 75 | default_luts : list[cmap.Colormap], optional 76 | List of colormaps to use when populating the LUT dropdown menu in the viewer. 77 | Only editable upon initialization. Values may be any `cmap` 78 | [ColormapLike](https://cmap-docs.readthedocs.io/en/stable/colormaps/#colormaplike-objects) 79 | object (most commonly, just a string name of the colormap, like 80 | "gray" or "viridis"). 81 | """ 82 | 83 | interaction_mode: InteractionMode = InteractionMode.PAN_ZOOM 84 | show_controls: bool = True 85 | show_3d_button: bool = True 86 | show_histogram_button: bool = True 87 | show_reset_zoom_button: bool = True 88 | show_roi_button: bool = True 89 | show_channel_mode_selector: bool = True 90 | show_play_button: bool = True 91 | show_data_info: bool = True 92 | show_progress_spinner: bool = False 93 | default_luts: list[cmap.Colormap] = Field( 94 | default_factory=_default_luts, frozen=True 95 | ) 96 | 97 | if TYPE_CHECKING: 98 | # just to make IDE autocomplete better 99 | # it's still hard to indicate dynamic members in the events group 100 | class ArrayViewerModelEvents(SignalGroup): 101 | """Signal group for ArrayViewerModel.""" 102 | 103 | interaction_mode = Signal(InteractionMode, InteractionMode) 104 | show_controls = Signal(bool, bool) 105 | show_3d_button = Signal(bool, bool) 106 | show_histogram_button = Signal(bool, bool) 107 | show_reset_zoom_button = Signal(bool, bool) 108 | show_roi_button = Signal(bool, bool) 109 | show_channel_mode_selector = Signal(bool, bool) 110 | show_play_button = Signal(bool, bool) 111 | show_data_info = Signal(bool, bool) 112 | show_progress_spinner = Signal(bool, bool) 113 | 114 | events: ArrayViewerModelEvents = ArrayViewerModelEvents() # type: ignore 115 | -------------------------------------------------------------------------------- /src/ndv/py.typed: -------------------------------------------------------------------------------- 1 | You may remove this file if you don't intend to add types to your package 2 | 3 | Details at: 4 | 5 | https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages 6 | -------------------------------------------------------------------------------- /src/ndv/util.py: -------------------------------------------------------------------------------- 1 | """Utility and convenience functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, overload 6 | 7 | from ndv.controllers import ArrayViewer 8 | from ndv.views._app import run_app 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any, Unpack 12 | 13 | from .models._array_display_model import ArrayDisplayModel, ArrayDisplayModelKwargs 14 | from .models._data_wrapper import DataWrapper 15 | from .models._viewer_model import ArrayViewerModel, ArrayViewerModelKwargs 16 | 17 | 18 | @overload 19 | def imshow( 20 | data: Any | DataWrapper, 21 | /, 22 | *, 23 | viewer_options: ArrayViewerModel | ArrayViewerModelKwargs | None = ..., 24 | display_model: ArrayDisplayModel = ..., 25 | ) -> ArrayViewer: ... 26 | @overload 27 | def imshow( 28 | data: Any | DataWrapper, 29 | /, 30 | *, 31 | viewer_options: ArrayViewerModel | ArrayViewerModelKwargs | None = ..., 32 | **display_kwargs: Unpack[ArrayDisplayModelKwargs], 33 | ) -> ArrayViewer: ... 34 | def imshow( 35 | data: Any | DataWrapper, 36 | /, 37 | *, 38 | viewer_options: ArrayViewerModel | ArrayViewerModelKwargs | None = None, 39 | display_model: ArrayDisplayModel | None = None, 40 | **display_kwargs: Unpack[ArrayDisplayModelKwargs], 41 | ) -> ArrayViewer: 42 | """Display an array or DataWrapper in a new `ArrayViewer` window. 43 | 44 | This convenience function creates an `ArrayViewer` instance populated with `data`, 45 | calls `show()` on it, and then runs the application. 46 | 47 | Parameters 48 | ---------- 49 | data : Any | DataWrapper 50 | The data to be displayed. Any ArrayLike object or an `ndv.DataWrapper`. 51 | display_model: ArrayDisplayModel, optional 52 | The display model to use. If not provided, a new one will be created. 53 | viewer_options: ArrayViewerModel | ArrayViewerModelKwargs, optional 54 | Either a [`ArrayViewerModel`][ndv.models.ArrayViewerModel] or a dictionary of 55 | keyword arguments used to create one. 56 | See docs for [`ArrayViewerModel`][ndv.models.ArrayViewerModel] for options. 57 | **display_kwargs : Unpack[ArrayDisplayModelKwargs] 58 | Additional keyword arguments used to create the 59 | [`ArrayDisplayModel`][ndv.models.ArrayDisplayModel]. (Generally, this is 60 | used instead of passing a `display_model` directly.) 61 | 62 | Returns 63 | ------- 64 | ArrayViewer 65 | The `ArrayViewer` instance. 66 | """ 67 | viewer = ArrayViewer( 68 | data, 69 | display_model=display_model, 70 | viewer_options=viewer_options, 71 | **display_kwargs, 72 | ) 73 | viewer.show() 74 | 75 | run_app() 76 | return viewer 77 | -------------------------------------------------------------------------------- /src/ndv/views/__init__.py: -------------------------------------------------------------------------------- 1 | """Wrappers around GUI & graphics frameworks. 2 | 3 | Most stuff in this module is not intended for public use, but [`ndv.views.bases`][] 4 | shows the protocol that GUI & graphics classes should implement. 5 | """ 6 | 7 | from ._app import ( 8 | CanvasBackend, 9 | GuiFrontend, 10 | call_later, 11 | get_array_canvas_class, 12 | get_array_view_class, 13 | get_histogram_canvas_class, 14 | gui_frontend, 15 | process_events, 16 | run_app, 17 | set_canvas_backend, 18 | set_gui_backend, 19 | ) 20 | 21 | __all__ = [ 22 | "CanvasBackend", 23 | "GuiFrontend", 24 | "call_later", 25 | "get_array_canvas_class", 26 | "get_array_view_class", 27 | "get_histogram_canvas_class", 28 | "gui_frontend", 29 | "process_events", 30 | "run_app", 31 | "set_canvas_backend", 32 | "set_gui_backend", 33 | ] 34 | -------------------------------------------------------------------------------- /src/ndv/views/_jupyter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/ndv/7ff1ebf8f341501a6c85021cd6781907a7987d5a/src/ndv/views/_jupyter/__init__.py -------------------------------------------------------------------------------- /src/ndv/views/_jupyter/_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from types import MethodType 5 | from typing import TYPE_CHECKING, Any, Callable 6 | 7 | from jupyter_rfb import RemoteFrameBuffer 8 | 9 | from ndv._types import ( 10 | MouseButton, 11 | MouseMoveEvent, 12 | MousePressEvent, 13 | MouseReleaseEvent, 14 | ) 15 | from ndv.views.bases._app import NDVApp 16 | 17 | if TYPE_CHECKING: 18 | from ndv.views.bases import ArrayView 19 | from ndv.views.bases._graphics._mouseable import Mouseable 20 | 21 | 22 | class JupyterAppWrap(NDVApp): 23 | """Provider for Jupyter notebooks/lab (NOT ipython).""" 24 | 25 | def is_running(self) -> bool: 26 | if ipy_shell := self._ipython_shell(): 27 | return bool(ipy_shell.__class__.__name__ == "ZMQInteractiveShell") 28 | return False 29 | 30 | def create_app(self) -> Any: 31 | if not self.is_running() and not os.getenv("PYTEST_CURRENT_TEST"): 32 | # if we got here, it probably means that someone used 33 | # NDV_GUI_FRONTEND=jupyter without actually being in a jupyter notebook 34 | # we allow it in tests, but not in normal usage. 35 | raise RuntimeError( # pragma: no cover 36 | "Jupyter is not running a notebook shell. Cannot create app." 37 | ) 38 | 39 | # No app creation needed... 40 | # but make sure we can actually import the stuff we need 41 | import ipywidgets # noqa: F401 42 | import jupyter # noqa: F401 43 | 44 | def array_view_class(self) -> type[ArrayView]: 45 | from ._array_view import JupyterArrayView 46 | 47 | return JupyterArrayView 48 | 49 | @staticmethod 50 | def mouse_btn(btn: Any) -> MouseButton: 51 | if btn == 0: 52 | return MouseButton.NONE 53 | if btn == 1: 54 | return MouseButton.LEFT 55 | if btn == 2: 56 | return MouseButton.RIGHT 57 | if btn == 3: 58 | return MouseButton.MIDDLE 59 | 60 | raise Exception(f"Jupyter mouse button {btn} is unknown") 61 | 62 | def filter_mouse_events( 63 | self, canvas: Any, receiver: Mouseable 64 | ) -> Callable[[], None]: 65 | if not isinstance(canvas, RemoteFrameBuffer): 66 | raise TypeError( 67 | f"Expected canvas to be RemoteFrameBuffer, got {type(canvas)}" 68 | ) 69 | 70 | # patch the handle_event from _jupyter_rfb.CanvasBackend 71 | # to intercept various mouse events. 72 | super_handle_event = canvas.handle_event 73 | active_btn: MouseButton = MouseButton.NONE 74 | 75 | def handle_event(self: RemoteFrameBuffer, ev: dict) -> None: 76 | nonlocal active_btn 77 | 78 | intercepted = False 79 | etype = ev["event_type"] 80 | if etype == "pointer_move": 81 | mme = MouseMoveEvent(x=ev["x"], y=ev["y"], btn=active_btn) 82 | intercepted |= receiver.on_mouse_move(mme) 83 | if cursor := receiver.get_cursor(mme): 84 | canvas.cursor = cursor.to_jupyter() 85 | receiver.mouseMoved.emit(mme) 86 | elif etype == "pointer_down": 87 | if "button" in ev: 88 | active_btn = JupyterAppWrap.mouse_btn(ev["button"]) 89 | else: 90 | active_btn = MouseButton.NONE 91 | mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=active_btn) 92 | intercepted |= receiver.on_mouse_press(mpe) 93 | receiver.mousePressed.emit(mpe) 94 | elif etype == "double_click": 95 | # Note that in Jupyter, a double_click event is not a pointer event 96 | # and as such, we need to handle both press and release. See 97 | # https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270 98 | btn = JupyterAppWrap.mouse_btn(ev["button"]) 99 | mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=btn) 100 | intercepted |= receiver.on_mouse_double_press(mpe) 101 | receiver.mouseDoublePressed.emit(mpe) 102 | # Release 103 | mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=btn) 104 | intercepted |= receiver.on_mouse_release(mre) 105 | receiver.mouseReleased.emit(mre) 106 | elif etype == "pointer_up": 107 | mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=active_btn) 108 | active_btn = MouseButton.NONE 109 | intercepted |= receiver.on_mouse_release(mre) 110 | receiver.mouseReleased.emit(mre) 111 | 112 | if not intercepted: 113 | super_handle_event(ev) 114 | 115 | canvas.handle_event = MethodType(handle_event, canvas) 116 | return lambda: setattr(canvas, "handle_event", super_handle_event) 117 | -------------------------------------------------------------------------------- /src/ndv/views/_pygfx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/ndv/7ff1ebf8f341501a6c85021cd6781907a7987d5a/src/ndv/views/_pygfx/__init__.py -------------------------------------------------------------------------------- /src/ndv/views/_pygfx/_util.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from rendercanvas import BaseRenderCanvas 5 | 6 | 7 | def rendercanvas_class() -> "BaseRenderCanvas": 8 | from ndv.views._app import GuiFrontend, gui_frontend 9 | 10 | frontend = gui_frontend() 11 | if frontend == GuiFrontend.QT: 12 | import rendercanvas.qt 13 | from qtpy.QtCore import QSize 14 | 15 | class QRenderWidget(rendercanvas.qt.QRenderWidget): 16 | def sizeHint(self) -> QSize: 17 | return QSize(self.width(), self.height()) 18 | 19 | return QRenderWidget 20 | 21 | if frontend == GuiFrontend.JUPYTER: 22 | import rendercanvas.jupyter 23 | 24 | return rendercanvas.jupyter.JupyterRenderCanvas 25 | if frontend == GuiFrontend.WX: 26 | # ...still not working 27 | # import rendercanvas.wx 28 | # return rendercanvas.wx.WxRenderWidget 29 | from wgpu.gui.wx import WxWgpuCanvas 30 | 31 | return WxWgpuCanvas 32 | -------------------------------------------------------------------------------- /src/ndv/views/_qt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/ndv/7ff1ebf8f341501a6c85021cd6781907a7987d5a/src/ndv/views/_qt/__init__.py -------------------------------------------------------------------------------- /src/ndv/views/_qt/_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Any, Callable, ClassVar 5 | 6 | from qtpy.QtCore import QEvent, QObject, Qt, QTimer 7 | from qtpy.QtGui import QMouseEvent 8 | from qtpy.QtWidgets import QApplication, QWidget 9 | 10 | from ndv._types import ( 11 | CursorType, 12 | MouseButton, 13 | MouseMoveEvent, 14 | MousePressEvent, 15 | MouseReleaseEvent, 16 | ) 17 | from ndv.views.bases._app import NDVApp 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Container 21 | from concurrent.futures import Future 22 | 23 | from ndv.views.bases import ArrayView 24 | from ndv.views.bases._app import P, T 25 | from ndv.views.bases._graphics._mouseable import Mouseable 26 | 27 | 28 | class QtAppWrap(NDVApp): 29 | """Provider for PyQt5/PySide2/PyQt6/PySide6.""" 30 | 31 | _APP_INSTANCE: ClassVar[Any] = None 32 | IPY_MAGIC_KEY = "qt" 33 | 34 | def create_app(self) -> Any: 35 | if (qapp := QApplication.instance()) is None: 36 | # if we're running in IPython 37 | # start the %gui qt magic if NDV_IPYTHON_MAGIC!=0 38 | if not self._maybe_enable_ipython_gui(): 39 | # otherwise create a new QApplication 40 | # must be stored in a class variable to prevent garbage collection 41 | QtAppWrap._APP_INSTANCE = qapp = QApplication(sys.argv) 42 | qapp.setOrganizationName("ndv") 43 | qapp.setApplicationName("ndv") 44 | 45 | self._install_excepthook() 46 | return qapp 47 | 48 | def run(self) -> None: 49 | app = QApplication.instance() or self.create_app() 50 | 51 | for wdg in QApplication.topLevelWidgets(): 52 | wdg.raise_() 53 | 54 | if ipy_shell := self._ipython_shell(): 55 | # if we're already in an IPython session with %gui qt, don't block 56 | if str(ipy_shell.active_eventloop).startswith("qt"): 57 | return 58 | 59 | app.exec() 60 | 61 | def call_in_main_thread( 62 | self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 63 | ) -> Future[T]: 64 | from ._main_thread import call_in_main_thread 65 | 66 | return call_in_main_thread(func, *args, **kwargs) 67 | 68 | def array_view_class(self) -> type[ArrayView]: 69 | from ._array_view import QtArrayView 70 | 71 | return QtArrayView 72 | 73 | def filter_mouse_events( 74 | self, canvas: Any, receiver: Mouseable 75 | ) -> Callable[[], None]: 76 | if not isinstance(canvas, QWidget): 77 | raise TypeError(f"Expected canvas to be QWidget, got {type(canvas)}") 78 | 79 | f = MouseEventFilter(canvas, receiver) 80 | canvas.installEventFilter(f) 81 | return lambda: canvas.removeEventFilter(f) 82 | 83 | def process_events(self) -> None: 84 | """Process events for the application.""" 85 | QApplication.processEvents() 86 | 87 | def call_later(self, msec: int, func: Callable[[], None]) -> None: 88 | """Call `func` after `msec` milliseconds.""" 89 | QTimer.singleShot(msec, Qt.TimerType.PreciseTimer, func) 90 | 91 | 92 | class MouseEventFilter(QObject): 93 | def __init__(self, canvas: QWidget, receiver: Mouseable): 94 | super().__init__() 95 | self.canvas = canvas 96 | self.receiver = receiver 97 | self.active_button = MouseButton.NONE 98 | 99 | def mouse_btn(self, btn: Any) -> MouseButton: 100 | if btn == Qt.MouseButton.LeftButton: 101 | return MouseButton.LEFT 102 | if btn == Qt.MouseButton.RightButton: 103 | return MouseButton.RIGHT 104 | if btn == Qt.MouseButton.NoButton: 105 | return MouseButton.NONE 106 | 107 | raise Exception(f"Qt mouse button {btn} is unknown") 108 | 109 | def set_cursor(self, type: CursorType) -> None: 110 | self.canvas.setCursor(type.to_qt()) 111 | 112 | def eventFilter(self, obj: QObject | None, qevent: QEvent | None) -> bool: 113 | """Event filter installed on the canvas to handle mouse events. 114 | 115 | here is where we get a chance to intercept mouse events before allowing 116 | the canvas to respond to them. Return `True` to prevent the event from 117 | being passed to the canvas. 118 | """ 119 | if qevent is None: 120 | return False # pragma: no cover 121 | 122 | try: 123 | # use children in case backend has a subwidget stealing events. 124 | children: Container = self.canvas.children() 125 | except RuntimeError: 126 | # native is likely dead 127 | return False 128 | 129 | intercept = False 130 | receiver = self.receiver 131 | if ( 132 | qevent.type() == qevent.Type.ContextMenu 133 | and type(obj).__name__ == "CanvasBackendDesktop" 134 | ): 135 | return False # pragma: no cover 136 | if obj is self.canvas or obj in children: 137 | if isinstance(qevent, QMouseEvent): 138 | pos = qevent.pos() 139 | etype = qevent.type() 140 | btn = self.mouse_btn(qevent.button()) 141 | if etype == QEvent.Type.MouseMove: 142 | mme = MouseMoveEvent(x=pos.x(), y=pos.y(), btn=self.active_button) 143 | intercept |= receiver.on_mouse_move(mme) 144 | if cursor := receiver.get_cursor(mme): 145 | self.set_cursor(cursor) 146 | receiver.mouseMoved.emit(mme) 147 | elif etype == QEvent.Type.MouseButtonDblClick: 148 | self.active_button = btn 149 | mpe = MousePressEvent(x=pos.x(), y=pos.y(), btn=self.active_button) 150 | intercept |= receiver.on_mouse_double_press(mpe) 151 | receiver.mouseDoublePressed.emit(mpe) 152 | elif etype == QEvent.Type.MouseButtonPress: 153 | self.active_button = btn 154 | mpe = MousePressEvent(x=pos.x(), y=pos.y(), btn=self.active_button) 155 | intercept |= receiver.on_mouse_press(mpe) 156 | receiver.mousePressed.emit(mpe) 157 | elif etype == QEvent.Type.MouseButtonRelease: 158 | mre = MouseReleaseEvent( 159 | x=pos.x(), y=pos.y(), btn=self.active_button 160 | ) 161 | self.active_button = MouseButton.NONE 162 | intercept |= receiver.on_mouse_release(mre) 163 | receiver.mouseReleased.emit(mre) 164 | elif etype == QEvent.Type.MouseButtonRelease: 165 | mre = MouseReleaseEvent( 166 | x=pos.x(), y=pos.y(), btn=self.active_button 167 | ) 168 | self.active_button = MouseButton.NONE 169 | intercept |= receiver.on_mouse_release(mre) 170 | receiver.mouseReleased.emit(mre) 171 | elif qevent.type() == QEvent.Type.Leave: 172 | intercept |= receiver.on_mouse_leave() 173 | receiver.mouseLeft.emit() 174 | return intercept 175 | -------------------------------------------------------------------------------- /src/ndv/views/_qt/_main_thread.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from concurrent.futures import Future 4 | from typing import TYPE_CHECKING, Callable 5 | 6 | from qtpy.QtCore import QCoreApplication, QMetaObject, QObject, Qt, QThread, Slot 7 | 8 | if TYPE_CHECKING: 9 | from typing_extensions import ParamSpec, TypeVar 10 | 11 | T = TypeVar("T") 12 | P = ParamSpec("P") 13 | 14 | 15 | class MainThreadInvoker(QObject): 16 | _current_callable: Callable | None = None 17 | _moved: bool = False 18 | 19 | def invoke( 20 | self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 21 | ) -> Future[T]: 22 | """Invokes a function in the main thread and returns a Future.""" 23 | future: Future[T] = Future() 24 | 25 | def wrapper() -> None: 26 | try: 27 | result = func(*args, **kwargs) 28 | future.set_result(result) 29 | except Exception as e: 30 | future.set_exception(e) 31 | 32 | self._current_callable = wrapper 33 | QMetaObject.invokeMethod( 34 | self, "_invoke_current", Qt.ConnectionType.QueuedConnection 35 | ) 36 | return future 37 | 38 | @Slot() # type: ignore [misc] 39 | def _invoke_current(self) -> None: 40 | """Invokes the current callable.""" 41 | if (cb := self._current_callable) is not None: 42 | cb() 43 | _INVOKERS.discard(self) 44 | 45 | 46 | if (QAPP := QCoreApplication.instance()) is None: 47 | raise RuntimeError("QApplication must be created before this module is imported.") 48 | 49 | _APP_THREAD = QAPP.thread() 50 | 51 | _INVOKERS = set() 52 | 53 | 54 | def call_in_main_thread( 55 | func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 56 | ) -> Future[T]: 57 | if QThread.currentThread() is not _APP_THREAD: 58 | invoker = MainThreadInvoker() 59 | invoker.moveToThread(_APP_THREAD) 60 | _INVOKERS.add(invoker) 61 | return invoker.invoke(func, *args, **kwargs) 62 | 63 | future: Future[T] = Future() 64 | future.set_result(func(*args, **kwargs)) 65 | return future 66 | -------------------------------------------------------------------------------- /src/ndv/views/_qt/_save_button.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget 7 | from superqt.iconify import QIconifyIcon 8 | 9 | if TYPE_CHECKING: 10 | from ndv.models import DataWrapper 11 | 12 | 13 | class SaveButton(QPushButton): 14 | def __init__( 15 | self, 16 | data_wrapper: DataWrapper, 17 | parent: QWidget | None = None, 18 | ): 19 | super().__init__(parent=parent) 20 | self.setIcon(QIconifyIcon("mdi:content-save")) 21 | self.clicked.connect(self._on_click) 22 | 23 | self._data_wrapper = data_wrapper 24 | self._last_loc = str(Path.home()) 25 | 26 | def _on_click(self) -> None: 27 | self._last_loc, _ = QFileDialog.getSaveFileName( 28 | self, "Choose destination", str(self._last_loc), "" 29 | ) 30 | suffix = Path(self._last_loc).suffix 31 | if suffix in (".zarr", ".ome.zarr", ""): 32 | self._data_wrapper.save_as_zarr(self._last_loc) 33 | else: 34 | raise ValueError(f"Unsupported file format: {self._last_loc}") 35 | -------------------------------------------------------------------------------- /src/ndv/views/_resources/spin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/ndv/7ff1ebf8f341501a6c85021cd6781907a7987d5a/src/ndv/views/_resources/spin.gif -------------------------------------------------------------------------------- /src/ndv/views/_vispy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyapp-kit/ndv/7ff1ebf8f341501a6c85021cd6781907a7987d5a/src/ndv/views/_vispy/__init__.py -------------------------------------------------------------------------------- /src/ndv/views/_wx/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ndv/views/_wx/_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Callable 4 | 5 | import wx 6 | from wx import ( 7 | EVT_LEAVE_WINDOW, 8 | EVT_LEFT_DCLICK, 9 | EVT_LEFT_DOWN, 10 | EVT_LEFT_UP, 11 | EVT_MOTION, 12 | EvtHandler, 13 | MouseEvent, 14 | ) 15 | 16 | from ndv._types import MouseButton, MouseMoveEvent, MousePressEvent, MouseReleaseEvent 17 | from ndv.views.bases._app import NDVApp 18 | 19 | from ._main_thread import call_in_main_thread 20 | 21 | if TYPE_CHECKING: 22 | from concurrent.futures import Future 23 | 24 | from ndv.views.bases import ArrayView 25 | from ndv.views.bases._app import P, T 26 | from ndv.views.bases._graphics._mouseable import Mouseable 27 | 28 | _app = None 29 | 30 | 31 | class WxAppWrap(NDVApp): 32 | """Provider for wxPython.""" 33 | 34 | IPY_MAGIC_KEY = "wx" 35 | 36 | def create_app(self) -> Any: 37 | global _app 38 | if (wxapp := wx.App.Get()) is None: 39 | _app = wxapp = wx.App() 40 | 41 | self._maybe_enable_ipython_gui() 42 | self._install_excepthook() 43 | return wxapp 44 | 45 | def run(self) -> None: 46 | app = wx.App.Get() or self.create_app() 47 | 48 | if ipy_shell := self._ipython_shell(): 49 | # if we're already in an IPython session with %gui qt, don't block 50 | if str(ipy_shell.active_eventloop).startswith("wx"): 51 | return 52 | 53 | app.MainLoop() 54 | 55 | def call_in_main_thread( 56 | self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 57 | ) -> Future[T]: 58 | return call_in_main_thread(func, *args, **kwargs) 59 | 60 | def array_view_class(self) -> type[ArrayView]: 61 | from ._array_view import WxArrayView 62 | 63 | return WxArrayView 64 | 65 | def filter_mouse_events( 66 | self, canvas: Any, receiver: Mouseable 67 | ) -> Callable[[], None]: 68 | if not isinstance(canvas, EvtHandler): 69 | raise TypeError( 70 | f"Expected vispy canvas to be wx EvtHandler, got {type(canvas)}" 71 | ) 72 | 73 | if hasattr(canvas, "_subwidget"): 74 | canvas = canvas._subwidget 75 | 76 | # TIP: event.Skip() allows the event to propagate to other handlers. 77 | 78 | active_button: MouseButton = MouseButton.NONE 79 | 80 | def on_mouse_move(event: MouseEvent) -> None: 81 | nonlocal active_button 82 | nonlocal canvas 83 | 84 | mme = MouseMoveEvent(x=event.GetX(), y=event.GetY(), btn=active_button) 85 | if not receiver.on_mouse_move(mme): 86 | receiver.mouseMoved.emit(mme) 87 | event.Skip() 88 | # FIXME: get_cursor is VERY slow, unsure why. 89 | if cursor := receiver.get_cursor(mme): 90 | canvas.SetCursor(cursor.to_wx()) 91 | 92 | def on_mouse_leave(event: MouseEvent) -> None: 93 | nonlocal active_button 94 | nonlocal canvas 95 | 96 | if not receiver.on_mouse_leave(): 97 | event.Skip() 98 | receiver.mouseLeft.emit() 99 | 100 | def on_mouse_press(event: MouseEvent) -> None: 101 | nonlocal active_button 102 | 103 | # NB This function is bound to the left mouse button press 104 | active_button = MouseButton.LEFT 105 | mpe = MousePressEvent(x=event.GetX(), y=event.GetY(), btn=active_button) 106 | if not receiver.on_mouse_press(mpe): 107 | receiver.mousePressed.emit(mpe) 108 | event.Skip() 109 | 110 | def on_mouse_double_press(event: MouseEvent) -> None: 111 | nonlocal active_button 112 | 113 | # NB This function is bound to the left mouse button press 114 | active_button = MouseButton.LEFT 115 | mpe = MousePressEvent(x=event.GetX(), y=event.GetY(), btn=active_button) 116 | if not receiver.on_mouse_double_press(mpe): 117 | receiver.mouseDoublePressed.emit(mpe) 118 | event.Skip() 119 | 120 | def on_mouse_release(event: MouseEvent) -> None: 121 | nonlocal active_button 122 | 123 | mre = MouseReleaseEvent(x=event.GetX(), y=event.GetY(), btn=active_button) 124 | active_button = MouseButton.NONE 125 | if not receiver.on_mouse_release(mre): 126 | receiver.mouseReleased.emit(mre) 127 | event.Skip() 128 | 129 | canvas.Bind(EVT_MOTION, handler=on_mouse_move) 130 | canvas.Bind(EVT_LEAVE_WINDOW, handler=on_mouse_leave) 131 | canvas.Bind(EVT_LEFT_DOWN, handler=on_mouse_press) 132 | canvas.Bind(EVT_LEFT_DCLICK, handler=on_mouse_double_press) 133 | canvas.Bind(EVT_LEFT_UP, handler=on_mouse_release) 134 | 135 | def _unbind() -> None: 136 | canvas.Unbind(EVT_MOTION, handler=on_mouse_move) 137 | canvas.Unbind(EVT_LEAVE_WINDOW, handler=on_mouse_leave) 138 | canvas.Unbind(EVT_LEFT_DOWN, handler=on_mouse_press) 139 | canvas.Unbind(EVT_LEFT_DCLICK, handler=on_mouse_double_press) 140 | canvas.Unbind(EVT_LEFT_UP, handler=on_mouse_release) 141 | 142 | return _unbind 143 | 144 | def process_events(self) -> None: 145 | """Process events.""" 146 | wx.SafeYield() 147 | 148 | def call_later(self, msec: int, func: Callable[[], None]) -> None: 149 | """Call `func` after `msec` milliseconds.""" 150 | wx.CallLater(msec, func) 151 | -------------------------------------------------------------------------------- /src/ndv/views/_wx/_labeled_slider.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | 4 | class WxLabeledSlider(wx.Panel): 5 | """A simple labeled slider widget for wxPython.""" 6 | 7 | def __init__(self, parent: wx.Window) -> None: 8 | super().__init__(parent) 9 | 10 | self.label = wx.StaticText(self) 11 | self.slider = wx.Slider(self, style=wx.HORIZONTAL) 12 | 13 | sizer = wx.BoxSizer(wx.HORIZONTAL) 14 | sizer.Add(self.label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) 15 | sizer.Add(self.slider, 1, wx.EXPAND) 16 | self.SetSizer(sizer) 17 | 18 | def setRange(self, min_val: int, max_val: int) -> None: 19 | self.slider.SetMin(min_val) 20 | self.slider.SetMax(max_val) 21 | 22 | def setValue(self, value: int) -> None: 23 | self.slider.SetValue(value) 24 | 25 | def value(self) -> int: 26 | return self.slider.GetValue() # type: ignore [no-any-return] 27 | 28 | def setSingleStep(self, step: int) -> None: 29 | self.slider.SetLineSize(step) 30 | -------------------------------------------------------------------------------- /src/ndv/views/_wx/_main_thread.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from concurrent.futures import Future 4 | from typing import TYPE_CHECKING, Callable 5 | 6 | import wx 7 | 8 | if TYPE_CHECKING: 9 | from typing_extensions import ParamSpec, TypeVar 10 | 11 | T = TypeVar("T") 12 | P = ParamSpec("P") 13 | 14 | 15 | class MainThreadInvoker: 16 | def __init__(self) -> None: 17 | """Utility for invoking functions in the main thread.""" 18 | # Ensure this is initialized from the main thread 19 | if not wx.IsMainThread(): 20 | raise RuntimeError( 21 | "MainThreadInvoker must be initialized in the main thread" 22 | ) 23 | 24 | def invoke( 25 | self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 26 | ) -> Future[T]: 27 | """Invokes a function in the main thread and returns a Future.""" 28 | future: Future[T] = Future() 29 | 30 | def wrapper() -> None: 31 | try: 32 | result = func(*args, **kwargs) 33 | future.set_result(result) 34 | except Exception as e: 35 | future.set_exception(e) 36 | 37 | wx.CallAfter(wrapper) 38 | return future 39 | 40 | 41 | _MAIN_THREAD_INVOKER = MainThreadInvoker() 42 | 43 | 44 | def call_in_main_thread( 45 | func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 46 | ) -> Future[T]: 47 | if not wx.IsMainThread(): 48 | return _MAIN_THREAD_INVOKER.invoke(func, *args, **kwargs) 49 | 50 | future: Future[T] = Future() 51 | future.set_result(func(*args, **kwargs)) 52 | return future 53 | -------------------------------------------------------------------------------- /src/ndv/views/bases/__init__.py: -------------------------------------------------------------------------------- 1 | """Abstract base classes for views and viewable objects.""" 2 | 3 | from ._app import NDVApp 4 | from ._array_view import ArrayView 5 | from ._graphics._canvas import ArrayCanvas, HistogramCanvas 6 | from ._graphics._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle 7 | from ._graphics._mouseable import Mouseable 8 | from ._lut_view import LutView 9 | from ._view_base import Viewable 10 | 11 | __all__ = [ 12 | "ArrayCanvas", 13 | "ArrayView", 14 | "CanvasElement", 15 | "HistogramCanvas", 16 | "ImageHandle", 17 | "LutView", 18 | "Mouseable", 19 | "NDVApp", 20 | "RectangularROIHandle", 21 | "Viewable", 22 | ] 23 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | import traceback 6 | from concurrent.futures import Executor, Future, ThreadPoolExecutor 7 | from contextlib import suppress 8 | from functools import cache 9 | from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, cast 10 | 11 | if TYPE_CHECKING: 12 | from types import TracebackType 13 | 14 | from IPython.core.interactiveshell import InteractiveShell 15 | from typing_extensions import ParamSpec, TypeVar 16 | 17 | from ndv.views.bases import ArrayView 18 | from ndv.views.bases._graphics._mouseable import Mouseable 19 | 20 | T = TypeVar("T") 21 | P = ParamSpec("P") 22 | 23 | ENV_IPYTHON_GUI_MAGIC = os.getenv("NDV_IPYTHON_MAGIC", "true").lower() 24 | """Whether to use %gui magic when running in IPython. Default True.""" 25 | 26 | 27 | class NDVApp: 28 | """Base class for application wrappers.""" 29 | 30 | USE_IPY_MAGIC = ENV_IPYTHON_GUI_MAGIC not in ("0", "false", "no") 31 | # must be valid key for %gui in IPython 32 | IPY_MAGIC_KEY: ClassVar[Literal["qt", "wx", None]] = None 33 | 34 | def create_app(self) -> Any: 35 | """Create the application instance, if not already created.""" 36 | raise NotImplementedError 37 | 38 | def array_view_class(self) -> type[ArrayView]: 39 | raise NotImplementedError 40 | 41 | def run(self) -> None: 42 | """Run the application.""" 43 | pass 44 | 45 | def filter_mouse_events( 46 | self, canvas: Any, receiver: Mouseable 47 | ) -> Callable[[], None]: 48 | """Install mouse event filter on `canvas`, redirecting events to `receiver`.""" 49 | raise NotImplementedError 50 | 51 | def call_in_main_thread( 52 | self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs 53 | ) -> Future[T]: 54 | """Call `func` in the main gui thread.""" 55 | future: Future[T] = Future() 56 | future.set_result(func(*args, **kwargs)) 57 | return future 58 | 59 | def get_executor(self) -> Executor: 60 | """Return an executor for running tasks in the background.""" 61 | return _thread_pool_executor() 62 | 63 | @staticmethod 64 | def _ipython_shell() -> InteractiveShell | None: 65 | if (ipy := sys.modules.get("IPython")) and (shell := ipy.get_ipython()): 66 | return cast("InteractiveShell", shell) 67 | return None 68 | 69 | def _maybe_enable_ipython_gui(self) -> bool: 70 | if ( 71 | (ipy_shell := self._ipython_shell()) 72 | and self.USE_IPY_MAGIC 73 | and (key := self.IPY_MAGIC_KEY) 74 | ): 75 | ipy_shell.enable_gui(key) # type: ignore [no-untyped-call] 76 | return True 77 | return False 78 | 79 | @staticmethod 80 | def _install_excepthook() -> None: 81 | """Install a custom excepthook that does not raise sys.exit(). 82 | 83 | This is necessary to prevent the application from closing when an exception 84 | is raised. 85 | """ 86 | if hasattr(sys, "_original_excepthook_"): 87 | # don't install the excepthook more than once 88 | return 89 | sys._original_excepthook_ = sys.excepthook # type: ignore 90 | sys.excepthook = ndv_excepthook 91 | 92 | def process_events(self) -> None: 93 | """Process events for the application.""" 94 | pass 95 | 96 | def call_later(self, msec: int, func: Callable[[], None]) -> None: 97 | """Call `func` after `msec` milliseconds.""" 98 | # generic implementation using python threading 99 | 100 | from threading import Timer 101 | 102 | Timer(msec / 1000, func).start() 103 | 104 | 105 | @cache 106 | def _thread_pool_executor() -> ThreadPoolExecutor: 107 | return ThreadPoolExecutor(max_workers=2) 108 | 109 | 110 | # -------------------- Exception handling -------------------- 111 | DEBUG_EXCEPTIONS = "NDV_DEBUG_EXCEPTIONS" 112 | """Whether to drop into a debugger when an exception is raised. Default False.""" 113 | 114 | EXIT_ON_EXCEPTION = "NDV_EXIT_ON_EXCEPTION" 115 | """Whether to exit the application when an exception is raised. Default False.""" 116 | 117 | 118 | def _print_exception( 119 | exc_type: type[BaseException], 120 | exc_value: BaseException, 121 | exc_traceback: TracebackType | None, 122 | ) -> None: 123 | try: 124 | import psygnal 125 | from rich.console import Console 126 | from rich.traceback import Traceback 127 | 128 | tb = Traceback.from_exception( 129 | exc_type, exc_value, exc_traceback, suppress=[psygnal], max_frames=10 130 | ) 131 | Console(stderr=True).print(tb) 132 | except ImportError: 133 | traceback.print_exception(exc_type, value=exc_value, tb=exc_traceback) 134 | 135 | 136 | def ndv_excepthook( 137 | exc_type: type[BaseException], exc_value: BaseException, tb: TracebackType | None 138 | ) -> None: 139 | _print_exception(exc_type, exc_value, tb) 140 | if not tb: 141 | return 142 | 143 | if ( 144 | (debugpy := sys.modules.get("debugpy")) 145 | and debugpy.is_client_connected() 146 | and ("pydevd" in sys.modules) 147 | ): 148 | with suppress(Exception): 149 | import threading 150 | 151 | import pydevd 152 | 153 | py_db = pydevd.get_global_debugger() 154 | thread = threading.current_thread() 155 | additional_info = py_db.set_additional_thread_info(thread) 156 | additional_info.is_tracing += 1 157 | 158 | try: 159 | arg = (exc_type, exc_value, tb) 160 | py_db.stop_on_unhandled_exception(py_db, thread, additional_info, arg) 161 | finally: 162 | additional_info.is_tracing -= 1 163 | elif os.getenv(DEBUG_EXCEPTIONS) in ("1", "true", "True"): 164 | # Default to pdb if no better option is available 165 | import pdb 166 | 167 | pdb.post_mortem(tb) 168 | 169 | if os.getenv(EXIT_ON_EXCEPTION) in ("1", "true", "True"): 170 | print(f"\n{EXIT_ON_EXCEPTION} is set, exiting.") 171 | sys.exit(1) 172 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_array_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from collections.abc import Sequence 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from psygnal import Signal 8 | 9 | from ndv.models._array_display_model import ChannelMode 10 | 11 | from ._view_base import Viewable 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Container, Hashable, Mapping, Sequence 15 | 16 | from ndv._types import AxisKey, ChannelKey 17 | from ndv.models._viewer_model import ArrayViewerModel 18 | from ndv.views.bases import LutView 19 | 20 | 21 | class ArrayView(Viewable): 22 | """ABC for ND Array viewers widget. 23 | 24 | Currently, this is the "main" widget that contains the array display and 25 | all the controls for interacting with the array, including sliders, LUTs, 26 | and histograms. 27 | """ 28 | 29 | currentIndexChanged = Signal() 30 | resetZoomClicked = Signal() 31 | histogramRequested = Signal(int) 32 | nDimsRequested = Signal(int) 33 | channelModeChanged = Signal(ChannelMode) 34 | 35 | # model: _ArrayDataDisplayModel is likely a temporary parameter 36 | @abstractmethod 37 | def __init__( 38 | self, 39 | canvas_widget: Any, 40 | viewer_model: ArrayViewerModel, 41 | **kwargs: Any, 42 | ) -> None: ... 43 | @abstractmethod 44 | def create_sliders(self, coords: Mapping[Hashable, Sequence]) -> None: ... 45 | @abstractmethod 46 | def current_index(self) -> Mapping[AxisKey, int | slice]: ... 47 | @abstractmethod 48 | def set_current_index(self, value: Mapping[AxisKey, int | slice]) -> None: ... 49 | 50 | @abstractmethod 51 | def visible_axes(self) -> Sequence[AxisKey]: ... 52 | @abstractmethod 53 | def set_visible_axes(self, axes: Sequence[AxisKey]) -> None: ... 54 | 55 | @abstractmethod 56 | def set_channel_mode(self, mode: ChannelMode) -> None: ... 57 | @abstractmethod 58 | def set_data_info(self, data_info: str) -> None: ... 59 | @abstractmethod 60 | def set_hover_info(self, hover_info: str) -> None: ... 61 | @abstractmethod 62 | def hide_sliders( 63 | self, axes_to_hide: Container[Hashable], *, show_remainder: bool = ... 64 | ) -> None: ... 65 | @abstractmethod 66 | def add_lut_view(self, key: ChannelKey) -> LutView: ... 67 | @abstractmethod 68 | def remove_lut_view(self, view: LutView) -> None: ... 69 | 70 | def add_histogram(self, channel: ChannelKey, widget: Any) -> None: 71 | raise NotImplementedError 72 | 73 | def remove_histogram(self, widget: Any) -> None: 74 | raise NotImplementedError 75 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_graphics/__init__.py: -------------------------------------------------------------------------------- 1 | """Base classes for graphics elements.""" 2 | 3 | from ._canvas import ArrayCanvas, HistogramCanvas 4 | from ._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle 5 | from ._mouseable import Mouseable 6 | 7 | __all__ = [ 8 | "ArrayCanvas", 9 | "CanvasElement", 10 | "HistogramCanvas", 11 | "ImageHandle", 12 | "Mouseable", 13 | "RectangularROIHandle", 14 | ] 15 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_graphics/_canvas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import TYPE_CHECKING, Literal 5 | 6 | import numpy as np 7 | 8 | from ndv.views.bases._lut_view import LutView 9 | from ndv.views.bases._view_base import Viewable 10 | 11 | from ._mouseable import Mouseable 12 | 13 | if TYPE_CHECKING: 14 | import numpy as np 15 | 16 | from ndv.models._viewer_model import ArrayViewerModel 17 | 18 | from ._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle 19 | 20 | 21 | class GraphicsCanvas(Viewable, Mouseable): 22 | """ABC for graphics canvas providers.""" 23 | 24 | @abstractmethod 25 | def refresh(self) -> None: ... 26 | @abstractmethod 27 | def set_range( 28 | self, 29 | x: tuple[float, float] | None = None, 30 | y: tuple[float, float] | None = None, 31 | z: tuple[float, float] | None = None, 32 | margin: float = ..., 33 | ) -> None: 34 | """Sets the bounds of the camera.""" 35 | ... 36 | 37 | @abstractmethod 38 | def canvas_to_world( 39 | self, pos_xy: tuple[float, float] 40 | ) -> tuple[float, float, float]: 41 | """Map XY canvas position (pixels) to XYZ coordinate in world space.""" 42 | 43 | @abstractmethod 44 | def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: ... 45 | 46 | 47 | # TODO: These classes will probably be merged and refactored in the future. 48 | 49 | 50 | class ArrayCanvas(GraphicsCanvas): 51 | """ABC for canvases that show array data.""" 52 | 53 | @abstractmethod 54 | def __init__(self, viewer_model: ArrayViewerModel | None = ...) -> None: ... 55 | @abstractmethod 56 | def set_ndim(self, ndim: Literal[2, 3]) -> None: ... 57 | @abstractmethod 58 | @abstractmethod 59 | def add_image(self, data: np.ndarray | None = ...) -> ImageHandle: ... 60 | @abstractmethod 61 | def add_volume(self, data: np.ndarray | None = ...) -> ImageHandle: ... 62 | @abstractmethod 63 | def add_bounding_box(self) -> RectangularROIHandle: ... 64 | 65 | 66 | class HistogramCanvas(GraphicsCanvas, LutView): 67 | """A histogram-based view for LookUp Table (LUT) adjustment.""" 68 | 69 | def set_vertical(self, vertical: bool) -> None: 70 | """If True, orient axes vertically (x-axis on left).""" 71 | 72 | def set_log_base(self, base: float | None) -> None: 73 | """Sets the axis scale of the range. 74 | 75 | Properties 76 | ---------- 77 | enabled : bool 78 | If true, the range will be displayed with a logarithmic (base 10) 79 | scale. If false, the range will be displayed with a linear scale. 80 | """ 81 | 82 | def set_data(self, values: np.ndarray, bin_edges: np.ndarray) -> None: 83 | """Sets the histogram data. 84 | 85 | Properties 86 | ---------- 87 | values : np.ndarray 88 | The histogram values. 89 | bin_edges : np.ndarray 90 | The bin edges of the histogram. 91 | """ 92 | 93 | def highlight(self, value: float | None) -> None: 94 | """Highlights a domain value on the histogram.""" 95 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_graphics/_canvas_elements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from enum import Enum, auto 5 | from typing import TYPE_CHECKING 6 | 7 | from psygnal import Signal 8 | 9 | from ndv.views.bases._lut_view import LutView 10 | 11 | from ._mouseable import Mouseable 12 | 13 | if TYPE_CHECKING: 14 | from typing import Any 15 | 16 | import cmap as _cmap 17 | import numpy as np 18 | 19 | from ndv.models._lut_model import ClimPolicy 20 | 21 | 22 | class CanvasElement(Mouseable): 23 | """Protocol defining an interactive element on the Canvas.""" 24 | 25 | @abstractmethod 26 | def visible(self) -> bool: 27 | """Defines whether the element is visible on the canvas.""" 28 | 29 | @abstractmethod 30 | def set_visible(self, visible: bool) -> None: 31 | """Sets element visibility.""" 32 | 33 | @abstractmethod 34 | def can_select(self) -> bool: 35 | """Defines whether the element can be selected.""" 36 | 37 | @abstractmethod 38 | def selected(self) -> bool: 39 | """Returns element selection status.""" 40 | 41 | @abstractmethod 42 | def set_selected(self, selected: bool) -> None: 43 | """Sets element selection status.""" 44 | 45 | def remove(self) -> None: 46 | """Removes the element from the canvas.""" 47 | 48 | 49 | class ImageHandle(CanvasElement, LutView): 50 | @abstractmethod 51 | def data(self) -> np.ndarray: ... 52 | @abstractmethod 53 | def set_data(self, data: np.ndarray) -> None: ... 54 | @abstractmethod 55 | def clims(self) -> tuple[float, float]: ... 56 | @abstractmethod 57 | def set_clims(self, clims: tuple[float, float]) -> None: ... 58 | @abstractmethod 59 | def gamma(self) -> float: ... 60 | @abstractmethod 61 | def set_gamma(self, gamma: float) -> None: ... 62 | @abstractmethod 63 | def colormap(self) -> _cmap.Colormap: ... 64 | @abstractmethod 65 | def set_colormap(self, cmap: _cmap.Colormap) -> None: ... 66 | 67 | # -- LutView methods -- # 68 | def close(self) -> None: 69 | self.remove() 70 | 71 | def frontend_widget(self) -> Any: 72 | return None 73 | 74 | def set_channel_name(self, name: str) -> None: 75 | pass 76 | 77 | def set_clim_policy(self, policy: ClimPolicy) -> None: 78 | pass 79 | 80 | def set_channel_visible(self, visible: bool) -> None: 81 | self.set_visible(visible) 82 | 83 | 84 | class ROIMoveMode(Enum): 85 | """Describes graphical mechanisms for ROI translation.""" 86 | 87 | HANDLE = auto() # Moving one handle (but not all) 88 | TRANSLATE = auto() # Translating everything 89 | 90 | 91 | class RectangularROIHandle(CanvasElement): 92 | """An axis-aligned rectanglular ROI.""" 93 | 94 | boundingBoxChanged = Signal(tuple[tuple[float, float], tuple[float, float]]) 95 | 96 | def set_bounding_box( 97 | self, minimum: tuple[float, float], maximum: tuple[float, float] 98 | ) -> None: 99 | """Sets the bounding box.""" 100 | 101 | def set_fill(self, color: _cmap.Color) -> None: 102 | """Sets the fill color.""" 103 | 104 | def set_border(self, color: _cmap.Color) -> None: 105 | """Sets the border color.""" 106 | 107 | def set_handles(self, color: _cmap.Color) -> None: 108 | """Sets the handle face color.""" 109 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_graphics/_mouseable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from psygnal import Signal 4 | 5 | from ndv._types import CursorType, MouseMoveEvent, MousePressEvent, MouseReleaseEvent 6 | 7 | 8 | class Mouseable: 9 | """Mixin class for objects that can be interacted with using the mouse. 10 | 11 | The signals here are to be emitted by the view object that inherits this class; 12 | usually by intercepting native mouse events with `filter_mouse_events`. 13 | 14 | The methods allow the object to handle its own mouse events before emitting the 15 | signals. If the method returns `True`, the event is considered handled and should 16 | not be passed to the next receiver in the chain. 17 | """ 18 | 19 | mouseMoved = Signal(MouseMoveEvent) 20 | mouseLeft = Signal() 21 | mousePressed = Signal(MousePressEvent) 22 | mouseDoublePressed = Signal(MousePressEvent) 23 | mouseReleased = Signal(MouseReleaseEvent) 24 | 25 | def on_mouse_move(self, event: MouseMoveEvent) -> bool: 26 | return False 27 | 28 | def on_mouse_leave(self) -> bool: 29 | return False 30 | 31 | def on_mouse_double_press(self, event: MousePressEvent) -> bool: 32 | return False 33 | 34 | def on_mouse_press(self, event: MousePressEvent) -> bool: 35 | return False 36 | 37 | def on_mouse_release(self, event: MouseReleaseEvent) -> bool: 38 | return False 39 | 40 | def get_cursor(self, event: MouseMoveEvent) -> CursorType | None: 41 | return None 42 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_lut_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import TYPE_CHECKING 5 | 6 | from ._view_base import Viewable 7 | 8 | if TYPE_CHECKING: 9 | import cmap 10 | 11 | from ndv.models._lut_model import ClimPolicy, LUTModel 12 | 13 | 14 | class LutView(Viewable): 15 | """Manages LUT properties (contrast, colormap, etc...) in a view object.""" 16 | 17 | _model: LUTModel | None = None 18 | 19 | @abstractmethod 20 | def set_channel_name(self, name: str) -> None: 21 | """Set the name of the channel to `name`.""" 22 | 23 | @abstractmethod 24 | def set_clim_policy(self, policy: ClimPolicy) -> None: 25 | """Set the clim policy to `policy`. 26 | 27 | Usually corresponds to an "autoscale" checkbox. 28 | 29 | Note that this method must not modify the backing LUTModel. 30 | """ 31 | 32 | @abstractmethod 33 | def set_colormap(self, cmap: cmap.Colormap) -> None: 34 | """Set the colormap to `cmap`. 35 | 36 | Usually corresponds to a dropdown menu. 37 | 38 | Note that this method must not modify the backing LUTModel. 39 | """ 40 | 41 | @abstractmethod 42 | def set_clims(self, clims: tuple[float, float]) -> None: 43 | """Set the (low, high) contrast limits to `clims`. 44 | 45 | Usually this will be a range slider or two text boxes. 46 | 47 | Note that this method must not modify the backing LUTModel. 48 | """ 49 | 50 | def set_clim_bounds( 51 | self, 52 | bounds: tuple[float | None, float | None] = (None, None), 53 | ) -> None: 54 | """Defines the minimum and maximum possible values for the clims.""" 55 | 56 | @abstractmethod 57 | def set_channel_visible(self, visible: bool) -> None: 58 | """Check or uncheck the visibility indicator of the LUT. 59 | 60 | Usually corresponds to a checkbox. 61 | 62 | Note that this method must not modify the backing LUTModel. 63 | """ 64 | 65 | def set_gamma(self, gamma: float) -> None: 66 | """Set the gamma value of the LUT. 67 | 68 | Note that this method must not modify the backing LUTModel. 69 | """ 70 | return None 71 | 72 | @property 73 | def model(self) -> LUTModel | None: 74 | return self._model 75 | 76 | @model.setter 77 | def model(self, model: LUTModel | None) -> None: 78 | # Disconnect old model 79 | if self._model is not None: 80 | self._model.events.clims.disconnect(self.set_clim_policy) 81 | self._model.events.clim_bounds.disconnect(self.set_clim_bounds) 82 | self._model.events.cmap.disconnect(self.set_colormap) 83 | self._model.events.gamma.disconnect(self.set_gamma) 84 | self._model.events.visible.disconnect(self.set_channel_visible) 85 | 86 | # Connect new model 87 | self._model = model 88 | if self._model is not None: 89 | self._model.events.clims.connect(self.set_clim_policy) 90 | self._model.events.clim_bounds.connect(self.set_clim_bounds) 91 | self._model.events.cmap.connect(self.set_colormap) 92 | self._model.events.gamma.connect(self.set_gamma) 93 | self._model.events.visible.connect(self.set_channel_visible) 94 | 95 | self.synchronize() 96 | 97 | def synchronize(self) -> None: 98 | """Aligns the view against the backing model.""" 99 | if model := self._model: 100 | self.set_clim_policy(model.clims) 101 | self.set_clim_bounds(model.clim_bounds) 102 | self.set_colormap(model.cmap) 103 | self.set_gamma(model.gamma) 104 | self.set_channel_visible(model.visible) 105 | -------------------------------------------------------------------------------- /src/ndv/views/bases/_view_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class Viewable(ABC): 6 | """ABC representing anything that can be viewed on screen. 7 | 8 | For example, a widget, a window, a frame, canvas, etc. 9 | """ 10 | 11 | @abstractmethod 12 | def frontend_widget(self) -> Any: 13 | """Return the native object backing the viewable objects.""" 14 | 15 | @abstractmethod 16 | def set_visible(self, visible: bool) -> None: 17 | """Sets the visibility of the view/widget itself.""" 18 | 19 | @abstractmethod 20 | def close(self) -> None: 21 | """Close the view/widget.""" 22 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import gc 4 | import importlib 5 | import importlib.util 6 | import os 7 | from collections.abc import Iterator 8 | from contextlib import contextmanager 9 | from typing import TYPE_CHECKING, Any 10 | from unittest.mock import patch 11 | 12 | import pytest 13 | 14 | from ndv.views import gui_frontend 15 | from ndv.views._app import GUI_ENV_VAR, GuiFrontend 16 | 17 | if TYPE_CHECKING: 18 | from asyncio import AbstractEventLoop 19 | from collections.abc import Iterator 20 | 21 | import wx 22 | from pytest import FixtureRequest 23 | from qtpy.QtWidgets import QApplication 24 | 25 | 26 | @pytest.fixture 27 | def asyncio_app() -> Iterator[AbstractEventLoop]: 28 | import asyncio 29 | 30 | loop = asyncio.new_event_loop() 31 | asyncio.set_event_loop(loop) 32 | yield loop 33 | loop.close() 34 | 35 | 36 | @pytest.fixture(scope="session") 37 | def wxapp() -> Iterator[wx.App]: 38 | import wx 39 | 40 | if (_wxapp := wx.App.Get()) is None: 41 | _wxapp = wx.App() 42 | yield _wxapp 43 | 44 | 45 | @pytest.fixture 46 | def any_app(request: pytest.FixtureRequest) -> Iterator[Any]: 47 | # this fixture will use the appropriate application depending on the env var 48 | # NDV_GUI_FRONTEND='qt' pytest 49 | # NDV_GUI_FRONTEND='jupyter' pytest 50 | try: 51 | frontend = gui_frontend() 52 | except RuntimeError: 53 | # if we don't find any frontend, and jupyter is available, use that 54 | # since it requires very little setup 55 | if importlib.util.find_spec("jupyter"): 56 | os.environ[GUI_ENV_VAR] = "jupyter" 57 | gui_frontend.cache_clear() 58 | 59 | frontend = gui_frontend() 60 | 61 | if frontend == GuiFrontend.QT: 62 | app = request.getfixturevalue("qapp") 63 | qtbot = request.getfixturevalue("qtbot") 64 | with patch.object(app, "exec", lambda *_: app.processEvents()): 65 | with _catch_qt_leaks(request, app): 66 | yield app, qtbot 67 | elif frontend == GuiFrontend.JUPYTER: 68 | yield request.getfixturevalue("asyncio_app") 69 | elif frontend == GuiFrontend.WX: 70 | yield request.getfixturevalue("wxapp") 71 | else: 72 | raise RuntimeError("No GUI frontend found") 73 | 74 | 75 | @contextmanager 76 | def _catch_qt_leaks(request: FixtureRequest, qapp: QApplication) -> Iterator[None]: 77 | """Run after each test to ensure no widgets have been left around. 78 | 79 | When this test fails, it means that a widget being tested has an issue closing 80 | cleanly. Perhaps a strong reference has leaked somewhere. Look for 81 | `functools.partial(self._method)` or `lambda: self._method` being used in that 82 | widget's code. 83 | """ 84 | # check for the "allow_leaks" marker 85 | if "allow_leaks" in request.node.keywords: 86 | yield 87 | return 88 | 89 | nbefore = len(qapp.topLevelWidgets()) 90 | failures_before = request.session.testsfailed 91 | yield 92 | # if the test failed, don't worry about checking widgets 93 | if request.session.testsfailed - failures_before: 94 | return 95 | try: 96 | from vispy.app.backends._qt import CanvasBackendDesktop 97 | 98 | allow: tuple[type, ...] = (CanvasBackendDesktop,) 99 | except (ImportError, RuntimeError): 100 | allow = () 101 | 102 | # This is a known widget that is not cleaned up properly 103 | remaining = [w for w in qapp.topLevelWidgets() if not isinstance(w, allow)] 104 | if len(remaining) > nbefore: 105 | test_node = request.node 106 | 107 | test = f"{test_node.path.name}::{test_node.originalname}" 108 | msg = f"{len(remaining)} topLevelWidgets remaining after {test!r}:" 109 | 110 | for widget in remaining: 111 | try: 112 | obj_name = widget.objectName() 113 | except Exception: 114 | obj_name = None 115 | msg += f"\n{widget!r} {obj_name!r}" 116 | # Get the referrers of the widget 117 | referrers = gc.get_referrers(widget) 118 | msg += "\n Referrers:" 119 | for ref in referrers: 120 | if ref is remaining: 121 | continue 122 | msg += f"\n - {ref}, {id(ref):#x}" 123 | 124 | raise AssertionError(msg) 125 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import ndv 4 | import ndv.views._app 5 | 6 | 7 | def test_set_gui_backend() -> None: 8 | backends = { 9 | "qt": ("ndv.views._qt._app", "QtAppWrap"), 10 | "jupyter": ("ndv.views._jupyter._app", "JupyterAppWrap"), 11 | "wx": ("ndv.views._wx._app", "WxAppWrap"), 12 | } 13 | with patch.object(ndv.views._app, "_load_app") as mock_load: 14 | for backend, import_tuple in backends.items(): 15 | ndv.set_gui_backend(backend) # type:ignore 16 | ndv.views._app.ndv_app() 17 | mock_load.assert_called_once_with(*import_tuple) 18 | 19 | mock_load.reset_mock() 20 | ndv.views._app._APP = None 21 | ndv.set_gui_backend() 22 | 23 | 24 | def test_set_canvas_backend() -> None: 25 | backends = [ 26 | ndv.views._app.CanvasBackend.VISPY, 27 | ndv.views._app.CanvasBackend.PYGFX, 28 | ] 29 | for backend in backends: 30 | ndv.set_canvas_backend(backend.value) # type: ignore 31 | assert ndv.views._app.canvas_backend() == backend 32 | ndv.set_canvas_backend() 33 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import runpy 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | try: 10 | import pytestqt 11 | 12 | if pytestqt.qt_compat.qt_api.pytest_qt_api.startswith("pyside"): 13 | pytest.skip( 14 | "viewer still occasionally segfaults with pyside", allow_module_level=True 15 | ) 16 | 17 | except ImportError: 18 | pytest.skip("This module requires qt frontend", allow_module_level=True) 19 | 20 | 21 | EXAMPLES = Path(__file__).parent.parent / "examples" 22 | EXAMPLES_PY = list(EXAMPLES.glob("*.py")) 23 | 24 | 25 | # NOTE: I think a lot of the flakiness in this test is due to the fact that 26 | # the examples may well recreate a QApplication, and that's problematic in a single 27 | # process. We would need to patch those calls a bit better to avoid that. 28 | @pytest.mark.allow_leaks 29 | @pytest.mark.usefixtures("any_app") 30 | @pytest.mark.parametrize("example", EXAMPLES_PY, ids=lambda x: x.name) 31 | @pytest.mark.filterwarnings("ignore:Downcasting integer data") 32 | @pytest.mark.filterwarnings("ignore:.*Falling back to CPUScaledTexture") 33 | @pytest.mark.skipif( 34 | bool(os.name == "nt" and os.getenv("CI")), reason="Test flaky on Windows CI" 35 | ) 36 | def test_example(example: Path) -> None: 37 | try: 38 | runpy.run_path(str(example)) 39 | except ImportError as e: 40 | pytest.skip(str(e)) 41 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from ndv.models._array_display_model import ArrayDisplayModel 4 | from ndv.models._roi_model import RectangularROIModel 5 | 6 | 7 | def test_array_display_model() -> None: 8 | m = ArrayDisplayModel() 9 | 10 | mock = Mock() 11 | m.events.channel_axis.connect(mock) 12 | m.current_index.item_added.connect(mock) 13 | m.current_index.item_changed.connect(mock) 14 | 15 | m.channel_axis = 4 16 | mock.assert_called_once_with(4, None) # new, old 17 | mock.reset_mock() 18 | m.current_index["5"] = 1 19 | mock.assert_called_once_with(5, 1) # key, value 20 | mock.reset_mock() 21 | m.current_index[5] = 4 22 | mock.assert_called_once_with(5, 4, 1) # key, new, old 23 | mock.reset_mock() 24 | 25 | assert ArrayDisplayModel.model_json_schema(mode="validation") 26 | assert ArrayDisplayModel.model_json_schema(mode="serialization") 27 | 28 | 29 | def test_rectangular_roi_model() -> None: 30 | m = RectangularROIModel() 31 | 32 | mock = Mock() 33 | m.events.bounding_box.connect(mock) 34 | m.events.visible.connect(mock) 35 | 36 | m.bounding_box = ((10, 10), (20, 20)) 37 | mock.assert_called_once_with( 38 | ((10, 10), (20, 20)), # New bounding box value 39 | ((0, 0), (0, 0)), # Initial bounding box on construction 40 | ) 41 | mock.reset_mock() 42 | 43 | m.visible = False 44 | mock.assert_called_once_with( 45 | False, # New visibility 46 | True, # Initial visibility on construction 47 | ) 48 | mock.reset_mock() 49 | 50 | assert RectangularROIModel.model_json_schema(mode="validation") 51 | assert RectangularROIModel.model_json_schema(mode="serialization") 52 | -------------------------------------------------------------------------------- /tests/test_ring_buffer.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from ndv.models._ring_buffer import RingBuffer 5 | 6 | 7 | def test_dtype() -> None: 8 | r = RingBuffer(5) 9 | assert r.dtype == np.dtype(np.float64) 10 | 11 | r = RingBuffer(5, dtype=bool) 12 | assert r.dtype == np.dtype(bool) 13 | 14 | 15 | def test_sizes() -> None: 16 | rb = RingBuffer(5, dtype=(int, 2)) 17 | assert rb.maxlen == 5 18 | assert len(rb) == 0 19 | assert rb.shape == (0, 2) 20 | 21 | rb.append([0, 0]) 22 | assert rb.maxlen == 5 23 | assert len(rb) == 1 24 | assert rb.shape == (1, 2) 25 | 26 | 27 | def test_append() -> None: 28 | rb = RingBuffer(5) 29 | 30 | rb.append(1) 31 | np.testing.assert_equal(rb, np.array([1])) 32 | assert len(rb) == 1 33 | 34 | rb.append(2) 35 | np.testing.assert_equal(rb, np.array([1, 2])) 36 | assert len(rb) == 2 37 | 38 | rb.append(3) 39 | rb.append(4) 40 | rb.append(5) 41 | np.testing.assert_equal(rb, np.array([1, 2, 3, 4, 5])) 42 | assert len(rb) == 5 43 | 44 | rb.append(6) 45 | np.testing.assert_equal(rb, np.array([2, 3, 4, 5, 6])) 46 | assert len(rb) == 5 47 | 48 | assert rb[4] == 6 49 | assert rb[-1] == 6 50 | 51 | 52 | def test_getitem() -> None: 53 | rb = RingBuffer(5) 54 | rb.extend([1, 2, 3]) 55 | rb.extendleft([4, 5]) 56 | expected = np.array([4, 5, 1, 2, 3]) 57 | np.testing.assert_equal(rb, expected) 58 | 59 | for i in range(rb.maxlen): 60 | assert expected[i] == rb[i] 61 | 62 | ii = [0, 4, 3, 1, 2] 63 | np.testing.assert_equal(rb[ii], expected[ii]) 64 | 65 | 66 | def test_getitem_negative_index() -> None: 67 | rb = RingBuffer(5) 68 | rb.extend([1, 2, 3]) 69 | assert rb[-1] == 3 70 | 71 | 72 | def test_appendleft() -> None: 73 | rb = RingBuffer(5) 74 | 75 | rb.appendleft(1) 76 | np.testing.assert_equal(rb, np.array([1])) 77 | assert len(rb) == 1 78 | 79 | rb.appendleft(2) 80 | np.testing.assert_equal(rb, np.array([2, 1])) 81 | assert len(rb) == 2 82 | 83 | rb.appendleft(3) 84 | rb.appendleft(4) 85 | rb.appendleft(5) 86 | np.testing.assert_equal(rb, np.array([5, 4, 3, 2, 1])) 87 | assert len(rb) == 5 88 | 89 | rb.appendleft(6) 90 | np.testing.assert_equal(rb, np.array([6, 5, 4, 3, 2])) 91 | assert len(rb) == 5 92 | 93 | 94 | def test_extend() -> None: 95 | rb = RingBuffer(5) 96 | rb.extend([1, 2, 3]) 97 | np.testing.assert_equal(rb, np.array([1, 2, 3])) 98 | rb.popleft() 99 | rb.extend([4, 5, 6]) 100 | np.testing.assert_equal(rb, np.array([2, 3, 4, 5, 6])) 101 | rb.extendleft([0, 1]) 102 | np.testing.assert_equal(rb, np.array([0, 1, 2, 3, 4])) 103 | 104 | rb.extendleft([1, 2, 3, 4, 5, 6, 7]) 105 | np.testing.assert_equal(rb, np.array([1, 2, 3, 4, 5])) 106 | 107 | rb.extend([1, 2, 3, 4, 5, 6, 7]) 108 | np.testing.assert_equal(rb, np.array([3, 4, 5, 6, 7])) 109 | 110 | 111 | def test_pops() -> None: 112 | rb = RingBuffer(3) 113 | rb.append(1) 114 | rb.appendleft(2) 115 | rb.append(3) 116 | np.testing.assert_equal(rb, np.array([2, 1, 3])) 117 | 118 | assert rb.pop() == 3 119 | np.testing.assert_equal(rb, np.array([2, 1])) 120 | 121 | assert rb.popleft() == 2 122 | np.testing.assert_equal(rb, np.array([1])) 123 | 124 | # test empty pops 125 | empty = RingBuffer(1) 126 | with pytest.raises(IndexError, match="pop from an empty RingBuffer"): 127 | empty.pop() 128 | with pytest.raises(IndexError, match="pop from an empty RingBuffer"): 129 | empty.popleft() 130 | 131 | 132 | def test_2d() -> None: 133 | rb = RingBuffer(5, dtype=(float, 2)) 134 | 135 | rb.append([1, 2]) 136 | np.testing.assert_equal(rb, np.array([[1, 2]])) 137 | assert len(rb) == 1 138 | assert np.shape(rb) == (1, 2) 139 | 140 | rb.append([3, 4]) 141 | np.testing.assert_equal(rb, np.array([[1, 2], [3, 4]])) 142 | assert len(rb) == 2 143 | assert np.shape(rb) == (2, 2) 144 | 145 | rb.appendleft([5, 6]) 146 | np.testing.assert_equal(rb, np.array([[5, 6], [1, 2], [3, 4]])) 147 | assert len(rb) == 3 148 | assert np.shape(rb) == (3, 2) 149 | 150 | np.testing.assert_equal(rb[0], [5, 6]) 151 | np.testing.assert_equal(rb[0, :], [5, 6]) 152 | np.testing.assert_equal(rb[:, 0], [5, 1, 3]) 153 | 154 | 155 | def test_3d() -> None: 156 | np.random.seed(0) 157 | frame_shape = (32, 32) 158 | 159 | rb = RingBuffer(5, dtype=("u2", frame_shape)) 160 | frame = np.random.randint(0, 65535, frame_shape, dtype="u2") 161 | rb.append(frame) 162 | np.testing.assert_equal(rb, frame[None]) 163 | frame2 = np.random.randint(0, 65535, frame_shape, dtype="u2") 164 | rb.append(frame2) 165 | np.testing.assert_equal(rb[-1], frame2) 166 | np.testing.assert_equal(rb, np.array([frame, frame2])) 167 | 168 | # fill buffer 169 | for _ in range(5): 170 | rb.append(np.random.randint(0, 65535, frame_shape, dtype="u2")) 171 | 172 | # add known frame 173 | frame3 = np.random.randint(0, 65535, frame_shape, dtype="u2") 174 | rb.append(frame3) 175 | np.testing.assert_equal(rb[-1], frame3) 176 | 177 | 178 | def test_iter() -> None: 179 | rb = RingBuffer(5) 180 | for i in range(5): 181 | rb.append(i) 182 | for i, j in zip(rb, range(5)): 183 | assert i == j 184 | 185 | 186 | def test_repr() -> None: 187 | rb = RingBuffer(5, dtype=int) 188 | for i in range(5): 189 | rb.append(i) 190 | 191 | assert repr(rb) == "" 192 | 193 | 194 | def test_no_overwrite() -> None: 195 | rb = RingBuffer(3, allow_overwrite=False) 196 | rb.append(1) 197 | rb.append(2) 198 | rb.appendleft(3) 199 | with pytest.raises(IndexError, match="overwrite"): 200 | rb.appendleft(4) 201 | with pytest.raises(IndexError, match="overwrite"): 202 | rb.extendleft([4]) 203 | rb.extendleft([]) 204 | 205 | np.testing.assert_equal(rb, np.array([3, 1, 2])) 206 | with pytest.raises(IndexError, match="overwrite"): 207 | rb.append(4) 208 | with pytest.raises(IndexError, match="overwrite"): 209 | rb.extend([4]) 210 | rb.extend([]) 211 | 212 | # works fine if we pop the surplus 213 | rb.pop() 214 | rb.append(4) 215 | np.testing.assert_equal(rb, np.array([3, 1, 4])) 216 | 217 | 218 | def test_degenerate() -> None: 219 | r = RingBuffer(0) 220 | np.testing.assert_equal(r, np.array([])) 221 | 222 | # this does not error with deque(maxlen=0), so should not error here 223 | try: 224 | r.append(0) 225 | r.appendleft(0) 226 | except IndexError: 227 | pytest.fail("IndexError raised when appending to a degenerate RingBuffer") 228 | -------------------------------------------------------------------------------- /tests/views/_jupyter/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests pertaining to Qt components""" 2 | 3 | from importlib.util import find_spec 4 | 5 | import pytest 6 | 7 | if not find_spec("ipywidgets"): 8 | pytest.skip( 9 | "Skipping Jupyter tests as Jupyter is not installed", allow_module_level=True 10 | ) 11 | -------------------------------------------------------------------------------- /tests/views/_jupyter/test_array_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import Mock 4 | 5 | import ipywidgets 6 | from pytest import fixture 7 | 8 | from ndv.models._viewer_model import ArrayViewerModel 9 | from ndv.views._jupyter._array_view import JupyterArrayView 10 | 11 | 12 | @fixture 13 | def viewer() -> JupyterArrayView: 14 | viewer = JupyterArrayView(ipywidgets.DOMWidget(), ArrayViewerModel()) 15 | viewer.add_lut_view(None) 16 | return viewer 17 | 18 | 19 | def test_array_options(viewer: JupyterArrayView) -> None: 20 | lut = viewer._luts[None] 21 | 22 | assert viewer._ndims_btn.layout.display is None 23 | viewer._viewer_model.show_3d_button = False 24 | assert viewer._ndims_btn.layout.display == "none" 25 | 26 | assert lut._histogram_btn.layout.display is None 27 | viewer._viewer_model.show_histogram_button = False 28 | assert lut._histogram_btn.layout.display == "none" 29 | 30 | assert viewer._reset_zoom_btn.layout.display is None 31 | viewer._viewer_model.show_reset_zoom_button = False 32 | assert viewer._reset_zoom_btn.layout.display == "none" 33 | 34 | assert viewer._channel_mode_combo.layout.display is None 35 | viewer._viewer_model.show_channel_mode_selector = False 36 | assert viewer._channel_mode_combo.layout.display == "none" 37 | 38 | assert viewer._add_roi_btn.layout.display is None 39 | viewer._viewer_model.show_roi_button = False 40 | assert viewer._add_roi_btn.layout.display == "none" 41 | 42 | 43 | def test_histogram(viewer: JupyterArrayView) -> None: 44 | channel = None 45 | lut = viewer._luts[channel] 46 | 47 | # Ensure lut signal gets passed through the viewer with the channel as the arg 48 | histogram_mock = Mock() 49 | viewer.histogramRequested.connect(histogram_mock) 50 | lut._histogram_btn.value = True 51 | histogram_mock.assert_called_once_with(channel) 52 | 53 | # FIXME: Throws event loop errors 54 | # histogram = get_histogram_canvas_class()() # will raise if not supported 55 | # histogram_wdg = histogram.frontend_widget() 56 | # viewer.add_histogram(channel, histogram_wdg) 57 | -------------------------------------------------------------------------------- /tests/views/_jupyter/test_lut_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import cmap 6 | from jupyter_rfb.widget import RemoteFrameBuffer 7 | from pytest import fixture 8 | 9 | from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel 10 | from ndv.views._jupyter._array_view import JupyterLutView 11 | from ndv.views.bases._graphics._canvas import HistogramCanvas 12 | 13 | 14 | @fixture 15 | def model() -> LUTModel: 16 | return LUTModel() 17 | 18 | 19 | @fixture 20 | def view(model: LUTModel) -> JupyterLutView: 21 | view = JupyterLutView(None) 22 | # Set the model 23 | assert view.model is None 24 | view.model = model 25 | assert view.model is model 26 | return view 27 | 28 | 29 | def test_JupyterLutView_update_model(model: LUTModel, view: JupyterLutView) -> None: 30 | """Ensures the view updates when the model is changed.""" 31 | 32 | # Test modifying model.clims 33 | assert view._auto_clim.value 34 | model.clims = ClimsManual(min=0, max=1) 35 | assert not view._auto_clim.value 36 | model.clims = ClimsPercentile(min_percentile=0, max_percentile=100) 37 | assert view._auto_clim.value 38 | model.clims = ClimsPercentile(min_percentile=1, max_percentile=99) 39 | assert view._auto_clim.value 40 | assert view._auto_clim.lower_tail.value == 1 41 | assert view._auto_clim.upper_tail.value == 1 42 | 43 | # Test modifying model.visible 44 | assert view._visible.value 45 | model.visible = False 46 | assert not view._visible.value 47 | model.visible = True 48 | assert view._visible.value 49 | 50 | new_cmap = cmap.Colormap("red") 51 | new_name = new_cmap.name.split(":")[-1] 52 | assert view._cmap.value != new_name 53 | model.cmap = new_cmap 54 | assert view._cmap.value == new_name 55 | 56 | 57 | def test_JupyterLutView_update_view(model: LUTModel, view: JupyterLutView) -> None: 58 | """Ensures the model updates when the view is changed.""" 59 | 60 | new_visible = not model.visible 61 | view._visible.value = new_visible 62 | assert view._visible.value == new_visible 63 | 64 | new_cmap = view._cmap.options[1] 65 | assert model.cmap != new_cmap 66 | view._cmap.value = new_cmap 67 | assert model.cmap == new_cmap 68 | 69 | # Test toggling auto_clim 70 | assert model.clims == ClimsMinMax() 71 | view._auto_clim.value = False 72 | mi, ma = view._clims.value 73 | assert model.clims == ClimsManual(min=mi, max=ma) 74 | view._auto_clim.value = True 75 | assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100) 76 | 77 | # Test modifying tails changes percentiles 78 | view._auto_clim.lower_tail.value = 0.1 79 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=100) 80 | view._auto_clim.upper_tail.value = 0.2 81 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=99.8) 82 | 83 | # When gui clims change, autoscale should be disabled 84 | model.clims = ClimsMinMax() 85 | view._clims.value = (0, 1) 86 | assert model.clims == ClimsManual(min=0, max=1) # type:ignore 87 | 88 | 89 | def test_JupyterLutView_histogram_controls(view: JupyterLutView) -> None: 90 | # Mock up a histogram 91 | hist_mock = MagicMock(spec=HistogramCanvas) 92 | hist_frontend = RemoteFrameBuffer() 93 | hist_mock.frontend_widget.return_value = hist_frontend 94 | 95 | # Add the histogram and assert it was correctly added 96 | view.add_histogram(hist_mock) 97 | assert view._histogram is hist_mock 98 | 99 | # Assert histogram button toggles visibility 100 | view._histogram_btn.value = True 101 | assert view._histogram_container.layout.display == "flex" 102 | view._histogram_btn.value = False 103 | assert view._histogram_container.layout.display == "none" 104 | 105 | # Assert toggling the log button alters the logarithmic base 106 | view._log.value = True 107 | hist_mock.set_log_base.assert_called_once_with(10) 108 | hist_mock.reset_mock() 109 | 110 | view._log.value = False 111 | hist_mock.set_log_base.assert_called_once_with(None) 112 | hist_mock.reset_mock() 113 | 114 | # Assert pressing the reset view button sets the histogram range 115 | view._reset_histogram.click() 116 | hist_mock.set_range.assert_called_once_with() 117 | hist_mock.reset_mock() 118 | 119 | # Assert pressing the reset view button turns off log mode 120 | view._log.value = True 121 | view._reset_histogram.click() 122 | assert not view._log.value 123 | hist_mock.reset_mock() 124 | -------------------------------------------------------------------------------- /tests/views/_pygfx/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests pertaining to Pygfx components""" 2 | 3 | from importlib.util import find_spec 4 | 5 | import pytest 6 | 7 | if not find_spec("pygfx"): 8 | pytest.skip( 9 | "Skipping Pygfx tests as Pygfx is not installed", allow_module_level=True 10 | ) 11 | -------------------------------------------------------------------------------- /tests/views/_pygfx/test_histogram.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import pytest 5 | from pygfx.objects import WheelEvent 6 | 7 | from ndv._types import ( 8 | CursorType, 9 | MouseButton, 10 | MouseMoveEvent, 11 | MousePressEvent, 12 | MouseReleaseEvent, 13 | ) 14 | from ndv.models._lut_model import ClimsManual, LUTModel 15 | from ndv.views._pygfx._histogram import PyGFXHistogramCanvas 16 | 17 | 18 | @pytest.mark.usefixtures("any_app") 19 | def test_hscroll() -> None: 20 | model = LUTModel( 21 | visible=True, 22 | cmap="red", 23 | # gamma=2, 24 | ) 25 | histogram = PyGFXHistogramCanvas() 26 | histogram.set_range(x=(0, 10), y=(0, 1)) 27 | histogram.model = model 28 | left, right = 0, 10 29 | histogram.set_clims((left, right)) 30 | 31 | old_x = histogram._camera.local.position[0] 32 | old_width = histogram._camera.width 33 | evt = WheelEvent(type="wheel", x=5, y=5, dx=-120, dy=0) 34 | histogram._controller.handle_event(evt, histogram._plot_view) 35 | new_x = histogram._camera.local.position[0] 36 | new_width = histogram._camera.width 37 | assert new_x < old_x 38 | assert abs(new_width - old_width) <= 1e-6 39 | 40 | evt = WheelEvent(type="wheel", x=5, y=5, dx=120, dy=0) 41 | histogram._controller.handle_event(evt, histogram._plot_view) 42 | new_x = histogram._camera.local.position[0] 43 | new_width = histogram._camera.width 44 | assert abs(new_x - old_x) <= 1e-6 45 | assert abs(new_width - old_width) <= 1e-6 46 | 47 | histogram.close() 48 | 49 | 50 | @pytest.mark.usefixtures("any_app") 51 | def test_highlight() -> None: 52 | # Set up a histogram 53 | histogram = PyGFXHistogramCanvas() 54 | assert not histogram._highlight.visible 55 | assert histogram._highlight.local.x == 0 56 | assert histogram._highlight.local.scale_y == 1 57 | 58 | # Add some data... 59 | values = np.random.randint(0, 100, (100)) 60 | bin_edges = np.linspace(0, 10, values.size + 1) 61 | histogram.set_data(values, bin_edges) 62 | # ...and ensure the scale is updated 63 | assert histogram._highlight.local.scale_y == values.max() / 0.98 64 | 65 | # Highlight a value... 66 | histogram.highlight(5) 67 | # ...and ensure the highlight is shown in the right place 68 | assert histogram._highlight.visible 69 | assert histogram._highlight.local.x == 5 70 | 71 | # Remove the highlight... 72 | histogram.highlight(None) 73 | # ...and ensure the highlight is hidden 74 | assert not histogram._highlight.visible 75 | 76 | histogram.close() 77 | 78 | 79 | @pytest.mark.usefixtures("any_app") 80 | def test_interaction() -> None: 81 | """Checks basic histogram functionality.""" 82 | model = LUTModel( 83 | visible=True, 84 | cmap="red", 85 | # gamma=2, 86 | ) 87 | histogram = PyGFXHistogramCanvas() 88 | histogram.set_range(x=(0, 10), y=(0, 1)) 89 | histogram.model = model 90 | left, right = 0, 10 91 | histogram.set_clims((left, right)) 92 | 93 | def world_to_canvas(x: float, y: float) -> tuple[float, float]: 94 | return histogram.world_to_canvas((x, y, 0)) 95 | 96 | # Test cursors 97 | x, y = world_to_canvas((left + right) / 2, 0.5) 98 | assert ( 99 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 100 | == CursorType.V_ARROW 101 | ) 102 | x, y = world_to_canvas(left, 0) 103 | assert ( 104 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 105 | == CursorType.H_ARROW 106 | ) 107 | x, y = world_to_canvas(right, 0) 108 | assert ( 109 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 110 | == CursorType.H_ARROW 111 | ) 112 | 113 | # Select and move gamma 114 | x, y = world_to_canvas((left + right) / 2, 0.5) 115 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 116 | x, y = world_to_canvas((left + right) / 2, 0.75) 117 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 118 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 119 | assert model.gamma == -np.log2(0.75) 120 | 121 | # Double clicking gamma resets to 1. 122 | x, y = world_to_canvas((left + right) / 2, 0.75) 123 | histogram.on_mouse_double_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 124 | assert model.gamma == 1 125 | 126 | # Select and move the left clim 127 | x, y = world_to_canvas(left, 0) 128 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 129 | left = 1 130 | x, y = world_to_canvas(left, 0) 131 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 132 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 133 | assert model.clims == ClimsManual(min=left, max=right) 134 | 135 | # Select and move the right clim 136 | x, y = world_to_canvas(right, 0) 137 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 138 | right = 9 139 | x, y = world_to_canvas(right, 0) 140 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 141 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 142 | assert model.clims == ClimsManual(min=left, max=right) 143 | 144 | # Ensure the right clim cannot move beyond the left clim 145 | x, y = world_to_canvas(right, 0) 146 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 147 | right = 0 148 | x, y = world_to_canvas(right, 0) 149 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 150 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 151 | assert model.clims == ClimsManual(min=left, max=left) 152 | 153 | # Ensure right clim is chosen when overlapping 154 | x, y = world_to_canvas(left, 0) 155 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 156 | right = 9 157 | x, y = world_to_canvas(right, 0) 158 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 159 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 160 | assert model.clims == ClimsManual(min=left, max=right) 161 | 162 | histogram.close() 163 | -------------------------------------------------------------------------------- /tests/views/_qt/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests pertaining to Qt components""" 2 | 3 | import pytest 4 | 5 | try: 6 | # NB: We could use importlib, but we'd have to search for qtpy 7 | # AND for one of the many bindings (PyQt5, PyQt6, PySide2) 8 | # This seems easier. 9 | from qtpy.QtCore import Qt # noqa: F401 10 | except ImportError: 11 | pytest.skip("Skipping Qt tests as Qt is not installed", allow_module_level=True) 12 | -------------------------------------------------------------------------------- /tests/views/_qt/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytest import fixture 4 | 5 | from ndv.views._qt._app import QtAppWrap 6 | 7 | 8 | @fixture(autouse=True) 9 | def init_provider() -> None: 10 | provider = QtAppWrap() 11 | provider.create_app() 12 | -------------------------------------------------------------------------------- /tests/views/_qt/test_array_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from unittest.mock import Mock 5 | 6 | from pytest import fixture 7 | from qtpy.QtWidgets import QWidget 8 | 9 | from ndv.models._viewer_model import ArrayViewerModel 10 | from ndv.views._app import get_histogram_canvas_class 11 | from ndv.views._qt._array_view import PlayButton, QtArrayView 12 | 13 | if TYPE_CHECKING: 14 | from pytestqt.qtbot import QtBot 15 | 16 | 17 | @fixture 18 | def viewer(qtbot: QtBot) -> QtArrayView: 19 | viewer = QtArrayView(QWidget(), ArrayViewerModel()) 20 | viewer.add_lut_view(None) 21 | viewer.create_sliders({0: range(10), 1: range(64), 2: range(128)}) 22 | qtbot.addWidget(viewer.frontend_widget()) 23 | return viewer 24 | 25 | 26 | def test_array_options(viewer: QtArrayView) -> None: 27 | qwdg = viewer._qwidget 28 | qwdg.show() 29 | qlut = viewer._luts[None]._qwidget 30 | dims_wdg = viewer._qwidget.dims_sliders 31 | assert dims_wdg._sliders 32 | play_btn = dims_wdg._layout.itemAtPosition(1, dims_wdg._rPLAY_BTN).widget() # type: ignore 33 | 34 | assert qwdg.ndims_btn.isVisible() 35 | viewer._viewer_model.show_3d_button = False 36 | assert not qwdg.ndims_btn.isVisible() 37 | 38 | assert qlut.histogram_btn.isVisible() 39 | viewer._viewer_model.show_histogram_button = False 40 | assert not qlut.histogram_btn.isVisible() 41 | 42 | assert qwdg.set_range_btn.isVisible() 43 | viewer._viewer_model.show_reset_zoom_button = False 44 | assert not qwdg.set_range_btn.isVisible() 45 | 46 | assert qwdg.channel_mode_combo.isVisible() 47 | viewer._viewer_model.show_channel_mode_selector = False 48 | assert not qwdg.channel_mode_combo.isVisible() 49 | 50 | assert qwdg.add_roi_btn.isVisible() 51 | viewer._viewer_model.show_roi_button = False 52 | assert not qwdg.add_roi_btn.isVisible() 53 | 54 | assert isinstance(play_btn, PlayButton) 55 | assert play_btn.isVisible() 56 | viewer._viewer_model.show_play_button = False 57 | assert not play_btn.isVisible() 58 | 59 | 60 | def test_histogram(viewer: QtArrayView) -> None: 61 | channel = None 62 | lut = viewer._luts[channel] 63 | 64 | # Ensure lut signal gets passed through the viewer with the channel as the arg 65 | histogram_mock = Mock() 66 | viewer.histogramRequested.connect(histogram_mock) 67 | lut._qwidget.histogram_btn.setChecked(True) 68 | histogram_mock.assert_called_once_with(channel) 69 | 70 | # Test adding the histogram widget puts it on the relevant lut 71 | assert lut.histogram is None 72 | histogram = get_histogram_canvas_class()() # will raise if not supported 73 | viewer.add_histogram(channel, histogram) 74 | assert lut.histogram is not None 75 | 76 | 77 | def test_play_btn(viewer: QtArrayView, qtbot: QtBot) -> None: 78 | """Test the play button functionality on the array view.""" 79 | dims_wdg = viewer._qwidget.dims_sliders 80 | assert dims_wdg._sliders 81 | play_btn = dims_wdg._layout.itemAtPosition(1, dims_wdg._rPLAY_BTN).widget() # type: ignore 82 | assert isinstance(play_btn, PlayButton) 83 | play_btn._show_fps_dialog() 84 | play_btn._popup.accept() 85 | with qtbot.waitSignal(dims_wdg.currentIndexChanged, timeout=1000): 86 | play_btn.click() 87 | play_btn.click() # stop it 88 | -------------------------------------------------------------------------------- /tests/views/_qt/test_lut_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from unittest.mock import MagicMock 5 | 6 | import cmap 7 | from pytest import fixture 8 | from qtpy.QtWidgets import QWidget 9 | 10 | from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel 11 | from ndv.views._qt._array_view import QLutView 12 | from ndv.views.bases._graphics._canvas import HistogramCanvas 13 | 14 | if TYPE_CHECKING: 15 | from pytestqt.qtbot import QtBot 16 | 17 | 18 | @fixture 19 | def model() -> LUTModel: 20 | return LUTModel() 21 | 22 | 23 | @fixture 24 | def view(model: LUTModel, qtbot: QtBot) -> QLutView: 25 | view = QLutView() 26 | qtbot.add_widget(view.frontend_widget()) 27 | # Set the model 28 | assert view.model is None 29 | view.model = model 30 | assert view.model is model 31 | return view 32 | 33 | 34 | def test_QLutView_update_model(model: LUTModel, view: QLutView) -> None: 35 | """Ensures the view updates when the model is changed.""" 36 | 37 | # Test modifying model.clims 38 | assert view._qwidget.auto_clim.isChecked() 39 | model.clims = ClimsManual(min=0, max=1) 40 | assert not view._qwidget.auto_clim.isChecked() 41 | model.clims = ClimsPercentile(min_percentile=0, max_percentile=100) 42 | assert view._qwidget.auto_clim.isChecked() 43 | model.clims = ClimsPercentile(min_percentile=1, max_percentile=99) 44 | assert view._qwidget.lower_tail.value() == 1 45 | assert view._qwidget.upper_tail.value() == 1 46 | 47 | # Test modifying model.visible 48 | assert view._qwidget.visible.isChecked() 49 | model.visible = False 50 | assert not view._qwidget.visible.isChecked() 51 | model.visible = True 52 | assert view._qwidget.visible.isChecked() 53 | 54 | # Test modifying model.cmap 55 | new_cmap = cmap.Colormap("red") 56 | assert view._qwidget.cmap.currentColormap() != new_cmap 57 | model.cmap = new_cmap 58 | assert view._qwidget.cmap.currentColormap() == new_cmap 59 | 60 | 61 | def test_QLutView_update_view(model: LUTModel, view: QLutView) -> None: 62 | """Ensures the model updates when the view is changed.""" 63 | 64 | new_visible = not model.visible 65 | view._qwidget.visible.setChecked(new_visible) 66 | assert view._qwidget.visible.isChecked() == new_visible 67 | 68 | new_cmap = view._qwidget.cmap.itemColormap(1) 69 | assert model.cmap != new_cmap 70 | assert new_cmap is not None 71 | view._qwidget.cmap.setCurrentIndex(1) 72 | assert model.cmap == new_cmap 73 | 74 | # Test toggling auto_clim 75 | assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100) 76 | view._qwidget.auto_clim.setChecked(False) 77 | mi, ma = view._qwidget.clims.value() 78 | assert model.clims == ClimsManual(min=mi, max=ma) 79 | view._qwidget.auto_clim.setChecked(True) 80 | assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100) 81 | 82 | # Test modifying tails changes percentiles 83 | view._qwidget.lower_tail.setValue(0.1) 84 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=100) 85 | view._qwidget.upper_tail.setValue(0.2) 86 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=99.8) 87 | 88 | # When gui clims change, autoscale should be disabled 89 | model.clims = ClimsMinMax() 90 | view._qwidget.clims.setValue((0, 1)) 91 | assert model.clims == ClimsManual(min=0, max=1) # type:ignore 92 | 93 | 94 | def test_QLutView_histogram_controls(model: LUTModel, view: QLutView) -> None: 95 | # Mock up a histogram 96 | hist_mock = MagicMock(spec=HistogramCanvas) 97 | hist_frontend = QWidget() 98 | hist_mock.frontend_widget.return_value = hist_frontend 99 | 100 | # Add the histogram and assert it was correctly added 101 | view._add_histogram(hist_mock) 102 | assert view.histogram is hist_mock 103 | 104 | # Assert histogram button toggles visibility 105 | # Note that the parent must be visible for child visibility to change 106 | old_visibility = view._qwidget.histogram_btn.isChecked() 107 | view._qwidget.setVisible(True) 108 | assert not view._qwidget.histogram_btn.isChecked() 109 | 110 | view._qwidget.histogram_btn.setChecked(True) 111 | assert view._qwidget.hist_log.isVisible() 112 | assert view._qwidget.hist_range.isVisible() 113 | assert hist_frontend.isVisible() 114 | 115 | view._qwidget.histogram_btn.setChecked(False) 116 | assert not view._qwidget.hist_log.isVisible() 117 | assert not view._qwidget.hist_range.isVisible() 118 | assert not hist_frontend.isVisible() 119 | view._qwidget.setVisible(old_visibility) 120 | 121 | # Assert toggling the log button alters the logarithmic base 122 | view._qwidget.hist_log.setChecked(True) 123 | hist_mock.set_log_base.assert_called_once_with(10) 124 | hist_mock.reset_mock() 125 | view._qwidget.hist_log.setChecked(False) 126 | hist_mock.set_log_base.assert_called_once_with(None) 127 | hist_mock.reset_mock() 128 | 129 | # Assert pressing the reset view button sets the histogram range 130 | view._qwidget.hist_range.click() 131 | hist_mock.set_range.assert_called_once_with() 132 | hist_mock.reset_mock() 133 | 134 | # Assert pressing the reset view button turns off log mode 135 | view._qwidget.hist_log.setChecked(True) 136 | view._qwidget.hist_range.click() 137 | assert not view._qwidget.hist_log.isChecked() 138 | hist_mock.reset_mock() 139 | -------------------------------------------------------------------------------- /tests/views/_vispy/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests pertaining to VisPy components""" 2 | 3 | from importlib.util import find_spec 4 | 5 | import pytest 6 | 7 | if not find_spec("vispy"): 8 | pytest.skip( 9 | "Skipping vispy tests as vispy is not installed", allow_module_level=True 10 | ) 11 | -------------------------------------------------------------------------------- /tests/views/_vispy/test_histogram.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import pytest 5 | from pytest import fixture 6 | from vispy.app.canvas import MouseEvent 7 | from vispy.scene.events import SceneMouseEvent 8 | 9 | from ndv._types import ( 10 | CursorType, 11 | MouseButton, 12 | MouseMoveEvent, 13 | MousePressEvent, 14 | MouseReleaseEvent, 15 | ) 16 | from ndv.models._lut_model import ClimsManual, LUTModel 17 | from ndv.views._vispy._histogram import VispyHistogramCanvas 18 | 19 | 20 | @fixture 21 | def model() -> LUTModel: 22 | return LUTModel( 23 | visible=True, 24 | cmap="red", 25 | gamma=2, 26 | ) 27 | 28 | 29 | @fixture 30 | def histogram() -> VispyHistogramCanvas: 31 | canvas = VispyHistogramCanvas() 32 | canvas.set_range(x=(0, 10), y=(0, 1)) 33 | return canvas 34 | 35 | 36 | @pytest.mark.usefixtures("any_app") 37 | def test_hscroll(histogram: VispyHistogramCanvas) -> None: 38 | old_rect = histogram.plot.camera.rect 39 | evt = SceneMouseEvent( 40 | MouseEvent(type="mouse_wheel", delta=[1, 0]), histogram.plot.camera.viewbox 41 | ) 42 | histogram.plot.camera.viewbox_mouse_event(evt) 43 | new_rect = histogram.plot.camera.rect 44 | assert new_rect.left < old_rect.left 45 | assert abs(new_rect.width - old_rect.width) <= 1e-6 46 | 47 | evt = SceneMouseEvent( 48 | MouseEvent(type="mouse_wheel", delta=[-1, 0]), histogram.plot.camera.viewbox 49 | ) 50 | histogram.plot.camera.viewbox_mouse_event(evt) 51 | new_rect = histogram.plot.camera.rect 52 | assert abs(new_rect.left - old_rect.left) <= 1e-6 53 | assert abs(new_rect.width - old_rect.width) <= 1e-6 54 | 55 | 56 | @pytest.mark.usefixtures("any_app") 57 | def test_highlight() -> None: 58 | # Set up a histogram 59 | histogram = VispyHistogramCanvas() 60 | assert not histogram._highlight.visible 61 | tform = histogram._highlight.transform 62 | assert np.allclose(tform.map(histogram._highlight.pos)[:, :2], ((0, 0), (0, 1))) 63 | 64 | # Add some data... 65 | values = np.random.randint(0, 100, (100)) 66 | bin_edges = np.linspace(0, 10, values.size + 1) 67 | histogram.set_data(values, bin_edges) 68 | # ...and ensure the scale is updated 69 | assert np.allclose( 70 | tform.map(histogram._highlight.pos)[:, :2], ((0, 0), (0, values.max() / 0.98)) 71 | ) 72 | 73 | # Highlight a value... 74 | histogram.highlight(5) 75 | # ...and ensure the highlight is shown in the right place 76 | assert histogram._highlight.visible 77 | assert np.allclose( 78 | tform.map(histogram._highlight.pos)[:, :2], ((5, 0), (5, values.max() / 0.98)) 79 | ) 80 | 81 | # Remove the highlight... 82 | histogram.highlight(None) 83 | # ...and ensure the highlight is hidden 84 | assert not histogram._highlight.visible 85 | 86 | histogram.close() 87 | 88 | 89 | @pytest.mark.usefixtures("any_app") 90 | def test_interaction(model: LUTModel, histogram: VispyHistogramCanvas) -> None: 91 | """Checks basic histogram functionality.""" 92 | histogram.model = model 93 | left, right = 0, 10 94 | histogram.set_clims((left, right)) 95 | 96 | def world_to_canvas(x: float, y: float) -> tuple[float, float]: 97 | return tuple(histogram.node_tform.imap((x, y))[:2]) 98 | 99 | # Test cursors 100 | x, y = world_to_canvas((left + right) / 2, 0.5) 101 | assert ( 102 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 103 | == CursorType.V_ARROW 104 | ) 105 | x, y = world_to_canvas(left, 0) 106 | assert ( 107 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 108 | == CursorType.H_ARROW 109 | ) 110 | x, y = world_to_canvas(right, 0) 111 | assert ( 112 | histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) 113 | == CursorType.H_ARROW 114 | ) 115 | 116 | # Select and move gamma 117 | x, y = world_to_canvas((left + right) / 2, 0.5) 118 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 119 | x, y = world_to_canvas((left + right) / 2, 0.75) 120 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 121 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 122 | assert model.gamma == -np.log2(0.75) 123 | 124 | # Double clicking gamma resets to 1. 125 | x, y = world_to_canvas((left + right) / 2, 0.75) 126 | histogram.on_mouse_double_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 127 | assert model.gamma == 1 128 | 129 | # Select and move the left clim 130 | x, y = world_to_canvas(left, 0) 131 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 132 | left = 1 133 | x, y = world_to_canvas(left, 0) 134 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 135 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 136 | assert model.clims == ClimsManual(min=left, max=right) 137 | 138 | # Select and move the right clim 139 | x, y = world_to_canvas(right, 0) 140 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 141 | right = 9 142 | x, y = world_to_canvas(right, 0) 143 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 144 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 145 | assert model.clims == ClimsManual(min=left, max=right) 146 | 147 | # Ensure the right clim cannot move beyond the left clim 148 | x, y = world_to_canvas(right, 0) 149 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 150 | right = 0 151 | x, y = world_to_canvas(right, 0) 152 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 153 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 154 | assert model.clims == ClimsManual(min=left, max=left) 155 | 156 | # Ensure right clim is chosen when overlapping 157 | x, y = world_to_canvas(left, 0) 158 | histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) 159 | right = 9 160 | x, y = world_to_canvas(right, 0) 161 | histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) 162 | histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) 163 | assert model.clims == ClimsManual(min=left, max=right) 164 | -------------------------------------------------------------------------------- /tests/views/_wx/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests pertaining to Qt components""" 2 | 3 | from importlib.util import find_spec 4 | 5 | import pytest 6 | 7 | if not find_spec("wx"): 8 | pytest.skip("Skipping wx tests as wx is not installed", allow_module_level=True) 9 | -------------------------------------------------------------------------------- /tests/views/_wx/test_lut_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import cmap 6 | import wx 7 | from pytest import fixture 8 | 9 | from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel 10 | from ndv.views._wx._array_view import WxLutView 11 | from ndv.views.bases._graphics._canvas import HistogramCanvas 12 | 13 | 14 | @fixture 15 | def model() -> LUTModel: 16 | return LUTModel() 17 | 18 | 19 | @fixture 20 | def view(wxapp: wx.App, model: LUTModel) -> WxLutView: 21 | # NB: wx.App necessary although unused 22 | frame = wx.Frame(None) 23 | view = WxLutView(frame) 24 | assert view.model is None 25 | view.model = model 26 | assert view.model is model 27 | return view 28 | 29 | 30 | def test_WxLutView_update_model(model: LUTModel, view: WxLutView) -> None: 31 | """Ensures the view updates when the model is changed.""" 32 | 33 | # Test modifying model.clims 34 | assert view._wxwidget.auto_clim.GetValue() 35 | model.clims = ClimsManual(min=0, max=1) 36 | assert not view._wxwidget.auto_clim.GetValue() 37 | model.clims = ClimsPercentile(min_percentile=0, max_percentile=100) 38 | assert view._wxwidget.auto_clim.GetValue() 39 | model.clims = ClimsPercentile(min_percentile=1, max_percentile=99) 40 | assert view._wxwidget.lower_tail.GetValue() == 1 41 | assert view._wxwidget.upper_tail.GetValue() == 1 42 | 43 | # Test modifying model.visible 44 | assert view._wxwidget.visible.GetValue() 45 | model.visible = False 46 | assert not view._wxwidget.visible.GetValue() 47 | model.visible = True 48 | assert view._wxwidget.visible.GetValue() 49 | 50 | # Test modifying model.cmap 51 | new_cmap = cmap.Colormap("red") 52 | assert view._wxwidget.cmap.GetValue() != new_cmap 53 | model.cmap = new_cmap 54 | assert view._wxwidget.cmap.GetValue() == new_cmap 55 | 56 | 57 | def test_WxLutView_update_view(wxapp: wx.App, model: LUTModel, view: WxLutView) -> None: 58 | """Ensures the model updates when the view is changed.""" 59 | 60 | def processEvent(evt: wx.PyEventBinder, wdg: wx.Control) -> None: 61 | ev = wx.PyCommandEvent(evt.typeId, wdg.GetId()) 62 | wx.PostEvent(wdg.GetEventHandler(), ev) 63 | # Borrowed from: 64 | # https://github.com/wxWidgets/Phoenix/blob/master/unittests/wtc.py#L41 65 | evtLoop = wxapp.GetTraits().CreateEventLoop() 66 | wx.EventLoopActivator(evtLoop) 67 | evtLoop.YieldFor(wx.EVT_CATEGORY_ALL) 68 | 69 | new_clims = (5, 6) 70 | assert model.clims != new_clims 71 | clim_wdg = view._wxwidget.clims 72 | clim_wdg.SetValue(*new_clims) 73 | processEvent(wx.EVT_SLIDER, clim_wdg) 74 | assert model.clims == ClimsManual(min=5, max=6) 75 | 76 | new_visible = not model.visible 77 | vis_wdg = view._wxwidget.visible 78 | vis_wdg.SetValue(new_visible) 79 | processEvent(wx.EVT_CHECKBOX, vis_wdg) 80 | assert model.visible == new_visible 81 | 82 | new_cmap = cmap.Colormap("red") 83 | assert model.cmap != new_cmap 84 | cmap_wdg = view._wxwidget.cmap 85 | cmap_wdg.SetValue(new_cmap.name) 86 | processEvent(wx.EVT_COMBOBOX, cmap_wdg) 87 | assert model.cmap == new_cmap 88 | 89 | # Test toggling auto_clim 90 | auto_wdg = view._wxwidget.auto_clim 91 | auto_wdg.SetValue(True) 92 | processEvent(wx.EVT_TOGGLEBUTTON, auto_wdg) 93 | assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100) 94 | auto_wdg.SetValue(False) 95 | processEvent(wx.EVT_TOGGLEBUTTON, auto_wdg) 96 | mi, ma = view._wxwidget.clims.GetValues() 97 | assert model.clims == ClimsManual(min=mi, max=ma) 98 | 99 | # Test modifying tails changes percentiles 100 | auto_wdg.SetValue(True) 101 | processEvent(wx.EVT_TOGGLEBUTTON, auto_wdg) 102 | assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100) 103 | lower_wdg = view._wxwidget.lower_tail 104 | lower_wdg.SetValue(0.1) 105 | processEvent(wx.EVT_SPINCTRLDOUBLE, lower_wdg) 106 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=100) 107 | upper_wdg = view._wxwidget.upper_tail 108 | upper_wdg.SetValue(0.2) 109 | processEvent(wx.EVT_SPINCTRLDOUBLE, upper_wdg) 110 | assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=99.8) 111 | 112 | # When gui clims change, autoscale should be disabled 113 | model.clims = ClimsMinMax() 114 | clim_wdg = view._wxwidget.clims 115 | clim_wdg.SetValue(0, 1) 116 | processEvent(wx.EVT_SLIDER, clim_wdg) 117 | assert model.clims == ClimsManual(min=0, max=1) 118 | 119 | 120 | def test_WxLutView_histogram_controls(wxapp: wx.App, view: WxLutView) -> None: 121 | def processEvent(evt: wx.PyEventBinder, wdg: wx.Control) -> None: 122 | ev = wx.PyCommandEvent(evt.typeId, wdg.GetId()) 123 | wx.PostEvent(wdg.GetEventHandler(), ev) 124 | # Borrowed from: 125 | # https://github.com/wxWidgets/Phoenix/blob/master/unittests/wtc.py#L41 126 | evtLoop = wxapp.GetTraits().CreateEventLoop() 127 | wx.EventLoopActivator(evtLoop) 128 | evtLoop.YieldFor(wx.EVT_CATEGORY_ALL) 129 | 130 | # Mock up a histogram 131 | hist_mock = MagicMock(spec=HistogramCanvas) 132 | # Note that containing the frontend widget within a frame prevents segfaults 133 | frame = wx.Frame(None) 134 | hist_frontend = wx.Window(frame) 135 | hist_mock.frontend_widget.return_value = hist_frontend 136 | 137 | # Add the histogram and assert it was correctly added 138 | view._add_histogram(hist_mock) 139 | assert view.histogram is hist_mock 140 | 141 | # Assert histogram button toggles visibility 142 | hist_btn = view._wxwidget.histogram_btn 143 | log_wdg = view._wxwidget.log_btn 144 | reset_wdg = view._wxwidget.set_hist_range_btn 145 | 146 | hist_btn.SetValue(True) 147 | processEvent(wx.EVT_TOGGLEBUTTON, hist_btn) 148 | assert log_wdg.IsShown() 149 | assert reset_wdg.IsShown() 150 | assert hist_frontend.IsShown() 151 | 152 | hist_btn.SetValue(False) 153 | processEvent(wx.EVT_TOGGLEBUTTON, hist_btn) 154 | assert not log_wdg.IsShown() 155 | assert not reset_wdg.IsShown() 156 | assert not hist_frontend.IsShown() 157 | 158 | # Assert toggling the log button alters the logarithmic base 159 | log_wdg.SetValue(True) 160 | processEvent(wx.EVT_TOGGLEBUTTON, log_wdg) 161 | hist_mock.set_log_base.assert_called_once_with(10) 162 | hist_mock.reset_mock() 163 | 164 | log_wdg.SetValue(False) 165 | processEvent(wx.EVT_TOGGLEBUTTON, log_wdg) 166 | hist_mock.set_log_base.assert_called_once_with(None) 167 | hist_mock.reset_mock() 168 | 169 | # Assert pressing the reset view button sets the histogram range 170 | processEvent(wx.EVT_BUTTON, reset_wdg) 171 | hist_mock.set_range.assert_called_once_with() 172 | hist_mock.reset_mock() 173 | 174 | # Assert pressing the reset view button turns off log mode 175 | log_wdg.SetValue(True) 176 | processEvent(wx.EVT_TOGGLEBUTTON, log_wdg) 177 | processEvent(wx.EVT_BUTTON, reset_wdg) 178 | assert not log_wdg.GetValue() 179 | hist_mock.reset_mock() 180 | --------------------------------------------------------------------------------