├── .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 | [](https://github.com/pyapp-kit/ndv/raw/main/LICENSE)
4 | [](https://pypi.org/project/ndv)
5 | [](https://python.org)
6 | [](https://github.com/pyapp-kit/ndv/actions/workflows/ci.yml)
7 | [](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 | 
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 |
--------------------------------------------------------------------------------