From 2960ac1eddf5a073da54dfbdc7722c1f22aabb41 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 12 Jan 2025 19:01:08 -0500 Subject: [PATCH 01/11] feat: support interactive usage with wx on ipython (#89) --- src/ndv/views/_app.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ndv/views/_app.py b/src/ndv/views/_app.py index 2e57533b..44989ca3 100644 --- a/src/ndv/views/_app.py +++ b/src/ndv/views/_app.py @@ -34,8 +34,8 @@ EXIT_ON_EXCEPTION = "NDV_EXIT_ON_EXCEPTION" """Whether to exit the application when an exception is raised. Default False.""" -IPYTHON_GUI_QT = "NDV_IPYTHON_GUI_QT" -"""Whether to use gui_qt magic when running in IPython. Default True.""" +IPYTHON_GUI_MAGIC = "NDV_IPYTHON_GUI_MAGIC" +"""Whether to use %gui magic when running in IPython. Default True.""" class GuiFrontend(str, Enum): @@ -114,9 +114,9 @@ def create_app() -> Any: if (qapp := QApplication.instance()) is None: # if we're running in IPython - # start the %gui qt magic if NDV_IPYTHON_GUI_QT!=0 + # start the %gui qt magic if NDV_IPYTHON_GUI_MAGIC!=0 if (ipy_shell := _ipython_shell()) and ( - os.getenv(IPYTHON_GUI_QT, "true").lower() not in ("0", "false", "no") + os.getenv(IPYTHON_GUI_MAGIC, "true").lower() not in ("0", "false", "no") ): ipy_shell.enable_gui("qt") # type: ignore [no-untyped-call] # otherwise create a new QApplication @@ -216,6 +216,12 @@ def create_app() -> Any: if (wxapp := wx.App.Get()) is None: wxapp = wx.App() + # if we're running in IPython + # start the %gui qt magic if NDV_IPYTHON_GUI_MAGIC!=0 + if (ipy_shell := _ipython_shell()) and ( + os.getenv(IPYTHON_GUI_MAGIC, "true").lower() not in ("0", "false", "no") + ): + ipy_shell.enable_gui("wx") # type: ignore [no-untyped-call] _install_excepthook() return wxapp @@ -224,9 +230,14 @@ def create_app() -> Any: def exec() -> None: import wx - app = wx.App.Get() or WxProvider.create_app() + app = cast("wx.App", wx.App.Get() or WxProvider.create_app()) + + if ipy_shell := _ipython_shell(): + # if we're already in an IPython session with %gui qt, don't block + if str(ipy_shell.active_eventloop).startswith("wx"): + return + app.MainLoop() - _install_excepthook() @staticmethod def array_view_class() -> type[ArrayView]: From 4ae0160570ffaf0c547ff784a9668949a01dac98 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 12 Jan 2025 19:21:13 -0500 Subject: [PATCH 02/11] fix: fix wx/pygfx width --- src/ndv/views/_wx/_array_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ndv/views/_wx/_array_view.py b/src/ndv/views/_wx/_array_view.py index e03c1377..71028c08 100644 --- a/src/ndv/views/_wx/_array_view.py +++ b/src/ndv/views/_wx/_array_view.py @@ -225,7 +225,7 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None): inner.Add(btns, 0, wx.EXPAND) outer = wx.BoxSizer(wx.VERTICAL) - outer.Add(inner, 1, wx.ALL, 10) + outer.Add(inner, 1, wx.EXPAND | wx.ALL, 10) self.SetSizer(outer) self.SetInitialSize(wx.Size(600, 800)) self.Layout() From 10f5d3ebd8c9f2b1b7281bfb827741e7f2257532 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 13 Jan 2025 08:41:12 -0500 Subject: [PATCH 03/11] docs: add pre-release banner (#90) * docs: add pre-release banner * add echo --- .github/workflows/deploy_docs.yml | 15 +++++++++++++-- docs/_overrides/main.html | 14 ++++++++++++++ mkdocs.yml | 4 ++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a8b919ce..83c14099 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -33,12 +33,23 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} run: | VERSION=$(git describe --abbrev=0 --tags) - echo "Deploy release docs to version $VERSION" - mike deploy --push --update-aliases $VERSION latest + # check if rc or beta release + if [[ $VERSION == *"rc"* ]] || [[ $VERSION == *"beta"* ]]; then + export DOCS_PRERELEASE=true + echo "Deploying pre-release docs" + mike deploy --push --update-aliases $VERSION rc + else + echo "Deploying release docs" + mike deploy --push --update-aliases $VERSION latest + fi + env: + DOCS_DEV: false - name: Deploy dev docs if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: mike deploy --push --update-aliases dev + env: + DOCS_DEV: true - name: Update default release docs run: mike set-default --push latest diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html index 0af326af..606a699c 100644 --- a/docs/_overrides/main.html +++ b/docs/_overrides/main.html @@ -1,5 +1,19 @@ {% extends "base.html" %} +{% set prebuild = 'pre-release' if config.extra.pre_release else 'dev' if config.extra.dev_build else '' %} + +{% block announce %} + {%- if prebuild -%} + +
+ You are currently viewing documentation for a {{prebuild}} build. + This may reference unreleased features. + For latest release, see + stable release docs. +
+ {%- endif -%} +{% endblock %} + {% block outdated %} You're not viewing the latest version. diff --git a/mkdocs.yml b/mkdocs.yml index dec0e283..2bf477fa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,6 +126,10 @@ hooks: extra: version: provider: mike + # either of these tags will enable the "viewing pre" announcement banner + # see _overrides/main.html + pre_release: !ENV ["DOCS_PRERELEASE", false] + dev_build: !ENV ["DOCS_DEV", false] social: - icon: fontawesome/brands/github link: https://github.com/pyapp-kit From 77c0219b082b26576e4c22370409faaee33e026c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:00:54 -0500 Subject: [PATCH 04/11] docs: more docs work (#93) --- README.md | 93 +++++++++++-------- docs/install.md | 23 +++++ docs/motivation.md | 65 +++++++++++++ mkdocs.yml | 13 +++ src/ndv/controllers/_array_viewer.py | 48 +++++----- src/ndv/controllers/_channel_controller.py | 2 +- src/ndv/data.py | 17 +++- src/ndv/models/__init__.py | 6 +- src/ndv/models/_data_display_model.py | 7 +- .../{data_wrappers => }/_data_wrapper.py | 0 src/ndv/models/data_wrappers/__init__.py | 3 - src/ndv/util.py | 4 +- src/ndv/v1/__init__.py | 9 +- src/ndv/v1/_old_viewer.py | 4 +- src/ndv/v1/_qt/_lut_control.py | 2 +- src/ndv/v1/{util.py => _util.py} | 0 src/ndv/views/_app.py | 2 +- src/ndv/views/_vispy/_array_canvas.py | 2 +- src/ndv/views/bases/__init__.py | 6 +- src/ndv/views/bases/_array_view.py | 3 +- .../bases/{graphics => _graphics}/__init__.py | 0 .../bases/{graphics => _graphics}/_canvas.py | 9 +- .../_canvas_elements.py | 0 .../{graphics => _graphics}/_mouseable.py | 0 tests/test_controller.py | 10 +- 25 files changed, 228 insertions(+), 100 deletions(-) create mode 100644 docs/motivation.md rename src/ndv/models/{data_wrappers => }/_data_wrapper.py (100%) delete mode 100644 src/ndv/models/data_wrappers/__init__.py rename src/ndv/v1/{util.py => _util.py} (100%) rename src/ndv/views/bases/{graphics => _graphics}/__init__.py (100%) rename src/ndv/views/bases/{graphics => _graphics}/_canvas.py (93%) rename src/ndv/views/bases/{graphics => _graphics}/_canvas_elements.py (100%) rename src/ndv/views/bases/{graphics => _graphics}/_mouseable.py (100%) diff --git a/README.md b/README.md index 382babb4..a05acfbe 100644 --- a/README.md +++ b/README.md @@ -11,33 +11,42 @@ Simple, fast-loading, asynchronous, n-dimensional array viewer, with minimal dep ```python import ndv -data = ndv.data.cells3d() # or *any* arraylike object +data = ndv.data.cells3d() # or any arraylike object ndv.imshow(data) ``` ![Montage](https://github.com/pyapp-kit/ndv/assets/1609449/712861f7-ddcb-4ecd-9a4c-ba5f0cc1ee2c) -As an alternative to `ndv.imshow()`, you can instantiate the `ndv.NDViewer` (`QWidget` subclass) directly +[`ndv.imshow()`](https://pyapp-kit.github.io/ndv/dev/reference/ndv/#ndv.imshow) +creates an instance of +[`ndv.ArrayViewer`](https://pyapp-kit.github.io/ndv/dev/reference/ndv/controllers/#ndv.controllers.ArrayViewer), +which you can also use directly: ```python -from qtpy.QtWidgets import QApplication -from ndv import NDViewer +import ndv -app = QApplication([]) -viewer = NDViewer(data) +viewer = ndv.ArrayViewer(data) viewer.show() -app.exec() +ndv.run_app() ``` +> [!TIP] +> To embed the viewer in a broader Qt or wxPython application, you can +> access the viewer's `widget` attribute and add it to your layout. + ## Features -- ⚑️ fast import and time-to-show -- ♾️ supports arbitrary number of data dimensions -- πŸ“¦ 2D/3D view canvas - -- 🎨 colormaps provided by [cmap](https://github.com/tlambert03/cmap) -- 🌠 supports [vispy](https://github.com/vispy/vispy) and [pygfx](https://github.com/pygfx/pygfx) backends -- πŸ¦† supports any numpy-like duck arrays, including (but not limited to): +- ⚑️ fast to import, fast to show +- πŸͺΆ minimal dependencies +- πŸ“¦ supports arbitrary number of dimensions +- πŸ₯‰ 2D/3D view canvas +- 🌠 supports [VisPy](https://github.com/vispy/vispy) or + [pygfx](https://github.com/pygfx/pygfx) backends +- πŸ› οΈ support [Qt](https://doc.qt.io), [wx](https://www.wxpython.org), or + [Jupyter](https://jupyter.org) GUI frontends +- 🎨 colormaps provided by [cmap](https://cmap-docs.readthedocs.io/) +- 🏷️ supports named dimensions and categorical coordinate values (WIP) +- πŸ¦† supports most array types, including: - `numpy.ndarray` - `cupy.ndarray` - `dask.array.Array` @@ -49,48 +58,52 @@ app.exec() - `xarray.DataArray` (supports named dimensions) - `zarr` (supports named dimensions) -See examples for each of these array types in [examples](https://github.com/pyapp-kit/ndv/tree/main/examples) +See examples for each of these array types in +[examples](https://github.com/pyapp-kit/ndv/tree/main/examples) > [!NOTE] -> *You can add support for any custom storage class by subclassing `ndv.DataWrapper` -> and implementing a couple methods. -> (This doesn't require modifying ndv, but contributions of new wrappers are welcome!)* +> *You can add support for any custom storage class by subclassing +> `ndv.DataWrapper` and [implementing a couple +> methods](https://github.com/pyapp-kit/ndv/blob/main/examples/custom_store.py). +> (This doesn't require modifying ndv, but contributions of new wrappers are +> welcome!)* ## Installation -To just get started using Qt and vispy: +Because ndv supports many combinations of GUI and graphics frameworks, +you must install it along with additional dependencies for your desired backend. + +See the [installation guide](https://pyapp-kit.github.io/ndv/dev/install/) for +complete details. + +To just get started quickly using Qt and vispy: ```python pip install ndv[qt] ``` -For Jupyter, without requiring Qt, you can use: +For Jupyter support, with no Qt requirement: ```python pip install ndv[jupyter] ``` -If you'd like more control over the backend, you can install the optional dependencies directly. - -The only required dependencies are `numpy` and `superqt[cmap,iconify]`. -You will also need a Qt backend (PyQt or PySide) and one of either -[vispy](https://github.com/vispy/vispy) or [pygfx](https://github.com/pygfx/pygfx), -which can be installed through extras `ndv[,]`: +## Documentation -> [!TIP] -> If you have both vispy and pygfx installed, `ndv` will default to using vispy, -> but you can override this with the environment variable -> `NDV_CANVAS_BACKEND=pygfx` or `NDV_CANVAS_BACKEND=vispy` +For more information, and complete API reference, see the +[documentation](https://pyapp-kit.github.io/ndv/). ## Motivation -This package arose from the need for a way to *quickly* view multi-dimensional arrays with -zero tolerance for long import times and/or excessive dependency lists. I want something that I can -use to view any of the many multi-dimensional array types, out of the box, with no assumptions -about dimensionality. I want it to work reasonably well with remote, asynchronously loaded data. -I also want it to take advantage of things like named dimensions and categorical coordinate values -when available. For now, it's a Qt-only widget, since that's where the need arose, but I can -imagine a jupyter widget in the future (likely as a remote frame buffer for vispy/pygfx). - -I do not intend for this to grow into full-fledged application, or wrap a complete scene graph, -though point and ROI selection would be welcome additions. +This package arose from the need for a way to *quickly* view multi-dimensional +arrays with zero tolerance for long import times and/or excessive dependency +lists. I want something that I can use to view any of the many multi-dimensional +array types, out of the box, with no assumptions about dimensionality. I want it +to work reasonably well with remote, asynchronously loaded data. I also want it +to take advantage of things like named dimensions and categorical coordinate +values when available. For now, it's a Qt-only widget, since that's where the +need arose, but I can imagine a jupyter widget in the future (likely as a remote +frame buffer for vispy/pygfx). + +I do not intend for this to grow into full-fledged application, or wrap a +complete scene graph, though point and ROI selection would be welcome additions. diff --git a/docs/install.md b/docs/install.md index 6e2465d4..12de9cc8 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,3 +13,26 @@ and GUI libraries you want to use:
+ +## Framework selection + +If you have multiple GUI or graphics libraries installed, you can control which +ones `ndv` uses with environment variables. The following variables are +supported: + +- `NDV_CANVAS_BACKEND`: Set to `"vispy"` or `"pygfx"` to choose the graphics library. +- `NDV_GUI_FRONTEND`: Set to `"qt"`, `"wx"`, or `"jupyter"` to choose the GUI library. + +!!! info "Defaults" + + **GUI:** + + `ndv` tries to be aware of the GUI library you are using. So it will use + `jupyter` if you are in a Jupyter notebook, `qt` if a `QApplication` is + already running, and `wx` if a `wx.App` is already running. Finally, it + will check available libraries in the order of `qt`, `wx`, `jupyter`. + + **Graphics:** + + If you have both VisPy and pygfx installed, `ndv` will (currently) default + to using vispy. diff --git a/docs/motivation.md b/docs/motivation.md new file mode 100644 index 00000000..ea78f18f --- /dev/null +++ b/docs/motivation.md @@ -0,0 +1,65 @@ +# Motivation and Scope + +It can be informative to know what problems the developers were trying to solve +when creating a library, and under what constraints. `ndv` was created by a +former [napari](https://napari.org) core developer and collaborators out of a +desire to quickly view multi-dimensional arrays with minimal import times +minimal dependencies. The original need was for a component in a broader +(microscope control) application, where a fast and minimal viewer was needed to +display data. + +## Goals + +- [x] **N-dimensional viewer**: The current focus is on viewing multi-dimensional + arrays (and currently just a single array at a time), with sliders + controlling slicing from an arbitrary number of dimensions. 2D and 3D + volumetric views are supported. +- [x] **Minimal dependencies**: `ndv` should have as few dependencies as + possible (both direct and indirect). Installing `napari[all]==0.5.5` into a + clean environment brings a total of 126 dependencies. `ndv[qt]==0.2.0` has + 29, and we aim to keep that low. +- [x] **Quick to import**: `ndv` should import and show a viewer in a reasonable + amount of time. "Reasonable" is of course relative and subjective, but we + aim for less than 1 second on a modern laptop (currently at <100ms). +- [x] **Broad GUI Compatibility**: A common feature request for `napari` is to support + Jupyter notebooks. `ndv` [can work with](./install.md#framework-selection) Qt, + wxPython, *and* Jupyter. +- [x] **Flexible Graphics Providers**: `ndv` works with VisPy in a classical OpenGL + context, but has an abstracting layer that allows for other graphics engines. + We currently also support `pygfx`, a WGPU-based graphics engine. +- [x] **Model/View architecture**: `ndv` should have a clear separation between the + data model and the view. The model should be serializable and easily + transferable between different views. +- [x] **Asynchronous first**: `ndv` should be asynchronous by default: meaning + that the data request/response process happens in the background, and the + GUI remains responsive. (Optimization of remote, multi-resolution data is on + the roadmap, but not currently implemented). + +## Scope and Roadmap + +We *do* want to support the following features: + +- [ ] **Multiple data sources**: We want to allow for multiple data sources to be + displayed in the same viewer, with flexible coordinate transforms. +- [ ] **Non-image data**: We would like to support non-image data, such as points + segmentation masks, and meshes. +- [ ] **Multi-resolution (pyramid) data**: We would like to support multi-resolution + data, to allow for fast rendering of large datasets based on the current view. +- [ ] **Frustum culling**: We would like to support frustum culling to allow for + efficient rendering of large datasets. +- [ ] **Ortho-viewer**: `ndv`'s good model/view separation should allow for + easy creation of an orth viewer (e.g. synchronized `XY`, `XZ`, `YZ` views). + +## Non-Goals + +We *do not* plan to support the following features in the near future +(if ever): + +- **Image Processing**: General image processing is out of scope. We aim to + provide a viewer, not a full image processing library. +- **Interactive segmentation and painting**: While extensible mouse event handling + *is* in scope, we don't intend to implement painting or interactive + segmentation tools. +- **Plugins**: We don't intend to support a plugin architecture. We aim to keep + the core library as small as possible, and encourage users to build on top + of it with their own tools. diff --git a/mkdocs.yml b/mkdocs.yml index 2bf477fa..183f06f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ validation: nav: - Home: index.md - install.md + - motivation.md # This is populated by scripts/gen_ref_nav.py - API reference: reference/ @@ -68,6 +69,9 @@ extra_javascript: markdown_extensions: - admonition + - pymdownx.details + - pymdownx.keys + - pymdownx.tilde - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg @@ -76,12 +80,21 @@ markdown_extensions: - pymdownx.highlight: pygments_lang_class: true line_spans: __span + - pymdownx.tasklist: + custom_checkbox: true - pymdownx.inlinehilite - pymdownx.superfences + - toc: + permalink: "#" plugins: - autorefs - search + - minify: + minify_html: true + minify_js: true + minify_css: true + cache_safe: true - gen-files: scripts: - scripts/gen_ref_nav.py diff --git a/src/ndv/controllers/_array_viewer.py b/src/ndv/controllers/_array_viewer.py index 7fff6841..0043c101 100644 --- a/src/ndv/controllers/_array_viewer.py +++ b/src/ndv/controllers/_array_viewer.py @@ -6,10 +6,8 @@ import numpy as np from ndv.controllers._channel_controller import ChannelController -from ndv.models._array_display_model import ArrayDisplayModel, ChannelMode +from ndv.models import ArrayDisplayModel, ChannelMode, DataWrapper, LUTModel from ndv.models._data_display_model import _ArrayDataDisplayModel -from ndv.models._lut_model import LUTModel -from ndv.models.data_wrappers import DataWrapper from ndv.views import _app if TYPE_CHECKING: @@ -19,25 +17,27 @@ from ndv._types import MouseMoveEvent from ndv.models._array_display_model import ArrayDisplayModelKwargs - from ndv.views.bases import ArrayView, HistogramCanvas + from ndv.views.bases import HistogramCanvas LutKey: TypeAlias = int | None -# primary "Controller" (and public API) for viewing an array - - class ArrayViewer: """Viewer dedicated to displaying a single n-dimensional array. - This wraps a model, view, and controller into a single object, and defines the + This wraps a model and sview into a single object, and defines the public API. - !!! note + !!! tip "See also" + + [**`ndv.imshow`**][ndv.imshow] - a convenience function that constructs and + shows an `ArrayViewer`. + + !!! note "Future plans" - In the future, `ndv` would like to support multiple, layered data sources. - We reserve the name `Viewer` for more fully featured viewer. `ArrayViewer` - assumes you're viewing a single array. + In the future, `ndv` would like to support multiple, layered data sources with + coordinate transforms. We reserve the name `Viewer` for a more fully featured + viewer. `ArrayViewer` assumes you're viewing a single array. Parameters ---------- @@ -94,16 +94,22 @@ def __init__( # -------------- public attributes and methods ------------------------- - @property - def view(self) -> ArrayView: - """Return the front-end view object. + # @property + # def view(self) -> ArrayView: + # return self._view + + def widget(self) -> Any: + """Return the native front-end widget. + + !!! Warning - To access the actual native widget, use `self.view.frontend_widget()`. - If you directly access the frontend widget, you're on your own :) no guarantees - can be made about synchronization with the model. However, it is exposed for - experimental and custom use cases. + If you directly manipulate the frontend widget, you're on your own :smile:. + No guarantees can be made about synchronization with the model. It is + exposed for embedding in an application, and for experimentation and custom + use cases. Please [open an + issue](https://github.com/pyapp-kit/ndv/issues/new) if you have questions. """ - return self._view + return self._view.frontend_widget() @property def display_model(self) -> ArrayDisplayModel: @@ -223,7 +229,7 @@ def _set_model_connected( def _fully_synchronize_view(self) -> None: """Fully re-synchronize the view with the model.""" display_model = self._data_model.display - with self.view.currentIndexChanged.blocked(): + with self._view.currentIndexChanged.blocked(): self._view.create_sliders(self._data_model.normed_data_coords) self._view.set_channel_mode(display_model.channel_mode) if self.data is not None: diff --git a/src/ndv/controllers/_channel_controller.py b/src/ndv/controllers/_channel_controller.py index 91c5bf51..c7442e11 100644 --- a/src/ndv/controllers/_channel_controller.py +++ b/src/ndv/controllers/_channel_controller.py @@ -11,7 +11,7 @@ from ndv.models._lut_model import LUTModel from ndv.views.bases import LutView - from ndv.views.bases.graphics._canvas_elements import ImageHandle + from ndv.views.bases._graphics._canvas_elements import ImageHandle LutKey = int | None diff --git a/src/ndv/data.py b/src/ndv/data.py index 29c32a50..59d649e6 100644 --- a/src/ndv/data.py +++ b/src/ndv/data.py @@ -54,7 +54,10 @@ def nd_sine_wave( def cells3d() -> np.ndarray: - """Load cells3d from scikit-image `(60, 2, 256, 256)` uint16.""" + """Load cells3d from scikit-image `(60, 2, 256, 256)` uint16. + + Requires `imageio and tifffile` to be installed. + """ try: from imageio.v2 import volread except ImportError as e: @@ -72,12 +75,18 @@ def cells3d() -> np.ndarray: def cat() -> np.ndarray: - """Load RGB cat data `(300, 451, 3)`, uint8.""" + """Load RGB cat data `(300, 451, 3)`, uint8. + + Requires [imageio](https://pypi.org/project/imageio/) to be installed. + """ return _imread("imageio:chelsea.png") def astronaut() -> np.ndarray: - """Load RGB data `(512, 512, 3)`, uint8.""" + """Load RGB data `(512, 512, 3)`, uint8. + + Requires [imageio](https://pypi.org/project/imageio/) to be installed. + """ return _imread("imageio:astronaut.png") @@ -99,6 +108,8 @@ def cosem_dataset( Search for available options at: + Requires [tensorstore](https://pypi.org/project/tensorstore/) to be installed. + Parameters ---------- uri : str, optional diff --git a/src/ndv/models/__init__.py b/src/ndv/models/__init__.py index 7205be25..7a823415 100644 --- a/src/ndv/models/__init__.py +++ b/src/ndv/models/__init__.py @@ -1,7 +1,7 @@ """Models for `ndv`.""" -from ._array_display_model import ArrayDisplayModel +from ._array_display_model import ArrayDisplayModel, ChannelMode +from ._data_wrapper import DataWrapper from ._lut_model import LUTModel -from .data_wrappers._data_wrapper import DataWrapper -__all__ = ["ArrayDisplayModel", "DataWrapper", "LUTModel"] +__all__ = ["ArrayDisplayModel", "ChannelMode", "DataWrapper", "LUTModel"] diff --git a/src/ndv/models/_data_display_model.py b/src/ndv/models/_data_display_model.py index 58b72909..591e23d5 100644 --- a/src/ndv/models/_data_display_model.py +++ b/src/ndv/models/_data_display_model.py @@ -6,10 +6,9 @@ import numpy as np from pydantic import Field -from ndv.models._array_display_model import ArrayDisplayModel, ChannelMode -from ndv.models._base_model import NDVModel - -from .data_wrappers import DataWrapper +from ._array_display_model import ArrayDisplayModel, ChannelMode +from ._base_model import NDVModel +from ._data_wrapper import DataWrapper __all__ = ["DataRequest", "DataResponse", "_ArrayDataDisplayModel"] diff --git a/src/ndv/models/data_wrappers/_data_wrapper.py b/src/ndv/models/_data_wrapper.py similarity index 100% rename from src/ndv/models/data_wrappers/_data_wrapper.py rename to src/ndv/models/_data_wrapper.py diff --git a/src/ndv/models/data_wrappers/__init__.py b/src/ndv/models/data_wrappers/__init__.py deleted file mode 100644 index 30df9865..00000000 --- a/src/ndv/models/data_wrappers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._data_wrapper import DataWrapper - -__all__ = ["DataWrapper"] diff --git a/src/ndv/util.py b/src/ndv/util.py index 84bd9aa2..0658aab6 100644 --- a/src/ndv/util.py +++ b/src/ndv/util.py @@ -11,7 +11,7 @@ from typing import Any, Unpack from .models._array_display_model import ArrayDisplayModel, ArrayDisplayModelKwargs - from .models.data_wrappers import DataWrapper + from .models._data_wrapper import DataWrapper @overload @@ -41,7 +41,7 @@ def imshow( Returns ------- - ViewerController + ArrayViewer The viewer window. """ viewer = ArrayViewer(data, display_model, **kwargs) diff --git a/src/ndv/v1/__init__.py b/src/ndv/v1/__init__.py index f6dc683d..fa84c2bb 100644 --- a/src/ndv/v1/__init__.py +++ b/src/ndv/v1/__init__.py @@ -1,10 +1,13 @@ -"""Here temporarily to allow access to the original (legacy) version of NDViewer. +"""Here to allow access to the original (legacy) version of NDViewer. -This module should not be used for new code. +!!! warning + + This module should not be used for new code. It will be removed in a future + release. """ from ._old_data_wrapper import DataWrapper from ._old_viewer import NDViewer -from .util import imshow +from ._util import imshow __all__ = ["DataWrapper", "NDViewer", "imshow"] diff --git a/src/ndv/v1/_old_viewer.py b/src/ndv/v1/_old_viewer.py index 15ee3b34..365286bd 100755 --- a/src/ndv/v1/_old_viewer.py +++ b/src/ndv/v1/_old_viewer.py @@ -33,8 +33,8 @@ from qtpy.QtCore import QObject from qtpy.QtGui import QCloseEvent, QKeyEvent - from ndv.views.bases.graphics._canvas import ArrayCanvas - from ndv.views.bases.graphics._canvas_elements import ( + from ndv.views.bases._graphics._canvas import ArrayCanvas + from ndv.views.bases._graphics._canvas_elements import ( CanvasElement, ImageHandle, RoiHandle, diff --git a/src/ndv/v1/_qt/_lut_control.py b/src/ndv/v1/_qt/_lut_control.py index 24d14f6d..2655ebcf 100644 --- a/src/ndv/v1/_qt/_lut_control.py +++ b/src/ndv/v1/_qt/_lut_control.py @@ -16,7 +16,7 @@ import cmap - from ndv.views.bases.graphics._canvas_elements import ImageHandle + from ndv.views.bases._graphics._canvas_elements import ImageHandle class CmapCombo(QColormapComboBox): diff --git a/src/ndv/v1/util.py b/src/ndv/v1/_util.py similarity index 100% rename from src/ndv/v1/util.py rename to src/ndv/v1/_util.py diff --git a/src/ndv/views/_app.py b/src/ndv/views/_app.py index 44989ca3..2632979a 100644 --- a/src/ndv/views/_app.py +++ b/src/ndv/views/_app.py @@ -19,7 +19,7 @@ from IPython.core.interactiveshell import InteractiveShell from ndv.views.bases import ArrayCanvas, ArrayView, HistogramCanvas - from ndv.views.bases.graphics._mouseable import Mouseable + from ndv.views.bases._graphics._mouseable import Mouseable GUI_ENV_VAR = "NDV_GUI_FRONTEND" diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index e4496ce6..55b1b8fd 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -18,7 +18,7 @@ from ndv.views._app import filter_mouse_events from ndv.views._vispy._utils import supports_float_textures from ndv.views.bases import ArrayCanvas -from ndv.views.bases.graphics._canvas_elements import ( +from ndv.views.bases._graphics._canvas_elements import ( CanvasElement, ImageHandle, RoiHandle, diff --git a/src/ndv/views/bases/__init__.py b/src/ndv/views/bases/__init__.py index d21a215f..3e269bfb 100644 --- a/src/ndv/views/bases/__init__.py +++ b/src/ndv/views/bases/__init__.py @@ -1,11 +1,11 @@ """Abstract base classes for views and viewable objects.""" from ._array_view import ArrayView +from ._graphics._canvas import ArrayCanvas, HistogramCanvas +from ._graphics._canvas_elements import CanvasElement, ImageHandle, RoiHandle +from ._graphics._mouseable import Mouseable from ._lut_view import LutView from ._view_base import Viewable -from .graphics._canvas import ArrayCanvas, HistogramCanvas -from .graphics._canvas_elements import CanvasElement, ImageHandle, RoiHandle -from .graphics._mouseable import Mouseable __all__ = [ "ArrayCanvas", diff --git a/src/ndv/views/bases/_array_view.py b/src/ndv/views/bases/_array_view.py index 21b5cf70..997cce05 100644 --- a/src/ndv/views/bases/_array_view.py +++ b/src/ndv/views/bases/_array_view.py @@ -7,7 +7,8 @@ from psygnal import Signal from ndv.models._array_display_model import ChannelMode -from ndv.views.bases._view_base import Viewable + +from ._view_base import Viewable if TYPE_CHECKING: from collections.abc import Container, Hashable, Mapping, Sequence diff --git a/src/ndv/views/bases/graphics/__init__.py b/src/ndv/views/bases/_graphics/__init__.py similarity index 100% rename from src/ndv/views/bases/graphics/__init__.py rename to src/ndv/views/bases/_graphics/__init__.py diff --git a/src/ndv/views/bases/graphics/_canvas.py b/src/ndv/views/bases/_graphics/_canvas.py similarity index 93% rename from src/ndv/views/bases/graphics/_canvas.py rename to src/ndv/views/bases/_graphics/_canvas.py index 70cc77dc..6e1e1e2e 100644 --- a/src/ndv/views/bases/graphics/_canvas.py +++ b/src/ndv/views/bases/_graphics/_canvas.py @@ -5,7 +5,8 @@ import numpy as np -from ndv.views.bases import LutView, Viewable +from ndv.views.bases._lut_view import LutView +from ndv.views.bases._view_base import Viewable from ._mouseable import Mouseable @@ -15,11 +16,7 @@ import cmap import numpy as np - from ndv.views.bases.graphics._canvas_elements import ( - CanvasElement, - ImageHandle, - RoiHandle, - ) + from ._canvas_elements import CanvasElement, ImageHandle, RoiHandle class GraphicsCanvas(Viewable, Mouseable): diff --git a/src/ndv/views/bases/graphics/_canvas_elements.py b/src/ndv/views/bases/_graphics/_canvas_elements.py similarity index 100% rename from src/ndv/views/bases/graphics/_canvas_elements.py rename to src/ndv/views/bases/_graphics/_canvas_elements.py diff --git a/src/ndv/views/bases/graphics/_mouseable.py b/src/ndv/views/bases/_graphics/_mouseable.py similarity index 100% rename from src/ndv/views/bases/graphics/_mouseable.py rename to src/ndv/views/bases/_graphics/_mouseable.py diff --git a/tests/test_controller.py b/tests/test_controller.py index b30625e3..24881987 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -14,8 +14,8 @@ from ndv.models._lut_model import LUTModel from ndv.views import _app, gui_frontend from ndv.views.bases import ArrayView, LutView -from ndv.views.bases.graphics._canvas import ArrayCanvas, HistogramCanvas -from ndv.views.bases.graphics._canvas_elements import ImageHandle +from ndv.views.bases._graphics._canvas import ArrayCanvas, HistogramCanvas +from ndv.views.bases._graphics._canvas_elements import ImageHandle if TYPE_CHECKING: from ndv.controllers._channel_controller import ChannelController @@ -53,7 +53,7 @@ def test_controller() -> None: SHAPE = (10, 4, 10, 10) ctrl = ArrayViewer() model = ctrl.display_model - mock_view = ctrl.view + mock_view = ctrl._view mock_view.create_sliders.assert_not_called() data = np.empty(SHAPE) @@ -117,7 +117,7 @@ def test_canvas() -> None: ctrl = ArrayViewer() mock_canvas = ctrl._canvas - mock_view = ctrl.view + mock_view = ctrl._view ctrl.data = data # clicking the reset zoom button calls set_range on the canvas @@ -135,7 +135,7 @@ def test_canvas() -> None: @_patch_views def test_histogram_controller() -> None: ctrl = ArrayViewer() - mock_view = ctrl.view + mock_view = ctrl._view ctrl.data = np.zeros((10, 4, 10, 10)).astype(np.uint8) From d2611800123e16d813bd9c4255f877afadedc140 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:22:46 -0500 Subject: [PATCH 05/11] docs: fetch gh-pages --- .github/workflows/deploy_docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 83c14099..9326c2f0 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -28,6 +28,7 @@ jobs: run: | git config user.name github-actions[bot] git config user.email github-actions[bot]@users.noreply.github.com + git fetch origin gh-pages --depth=1 - name: Deploy release docs if: ${{ startsWith(github.ref, 'refs/tags/') }} From 3815c342a7b5b98819d40d0ee5b31a1fee74e57b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:30:07 -0500 Subject: [PATCH 06/11] docs: don't show both banners --- .github/workflows/deploy_docs.yml | 1 + docs/_overrides/main.html | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 9326c2f0..7ed057cb 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,6 +29,7 @@ jobs: git config user.name github-actions[bot] git config user.email github-actions[bot]@users.noreply.github.com git fetch origin gh-pages --depth=1 + git pull origin gh-pages --ff - name: Deploy release docs if: ${{ startsWith(github.ref, 'refs/tags/') }} diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html index 606a699c..cbda97f9 100644 --- a/docs/_overrides/main.html +++ b/docs/_overrides/main.html @@ -15,8 +15,12 @@ {% endblock %} {% block outdated %} - You're not viewing the latest version. -
- Click here to go to latest. - + {%- if not prebuild -%} +
+ You're not viewing the latest version. + + Click here to go to latest. + +
+ {%- endif -%} {% endblock %} From 7e550763c8afc16adc442d493cbb3b8cd11c16ae Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:31:54 -0500 Subject: [PATCH 07/11] undo last --- .github/workflows/deploy_docs.yml | 1 - docs/_overrides/main.html | 12 ++++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 7ed057cb..9326c2f0 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,7 +29,6 @@ jobs: git config user.name github-actions[bot] git config user.email github-actions[bot]@users.noreply.github.com git fetch origin gh-pages --depth=1 - git pull origin gh-pages --ff - name: Deploy release docs if: ${{ startsWith(github.ref, 'refs/tags/') }} diff --git a/docs/_overrides/main.html b/docs/_overrides/main.html index cbda97f9..b248a76e 100644 --- a/docs/_overrides/main.html +++ b/docs/_overrides/main.html @@ -15,12 +15,8 @@ {% endblock %} {% block outdated %} - {%- if not prebuild -%} -
- You're not viewing the latest version. - - Click here to go to latest. - -
- {%- endif -%} + You're not viewing the latest stable version. + + Click here to go to last release. + {% endblock %} From c91204e214bc3843a9cafc20485f317fe9163d25 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:33:18 -0500 Subject: [PATCH 08/11] docs: add ruff to docs deps --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index fa2da5a9..fb716767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ docs = [ "mkdocstrings-python==1.13.0", "mkdocs-spellcheck[codespell]==1.1.0", "mike==2.1.3", + "ruff", ] dev = [ "ndv[test,vispy,pygfx,pyqt,jupyter]", From 1a5913c8b4e33a7da09837a1801634396bcac030 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:36:22 -0500 Subject: [PATCH 09/11] docs: update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a05acfbe..142eefcf 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,10 @@ To just get started quickly using Qt and vispy: pip install ndv[qt] ``` -For Jupyter support, with no Qt requirement: +For Jupyter with vispy, (no Qt or wxPython): ```python -pip install ndv[jupyter] +pip install ndv[jup] ``` ## Documentation From 75e62e221c28d5c3ef9e3eddb15797dc6f36d793 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 12:37:59 -0500 Subject: [PATCH 10/11] docs: Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 142eefcf..14b3a23e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ ndv.run_app() > [!TIP] > To embed the viewer in a broader Qt or wxPython application, you can -> access the viewer's `widget` attribute and add it to your layout. +> access the viewer's +> [`widget`](https://pyapp-kit.github.io/ndv/dev/reference/ndv/controllers/#ndv.controllers.ArrayViewer.widget) +> attribute and add it to your layout. ## Features From dc59f7b7f8704b598f3676f0b4b545e3385bd950 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 15 Jan 2025 19:04:35 -0500 Subject: [PATCH 11/11] refactor: remove `LUTModel.autoscale` and make `LUTModel.clims` more powerful (#94) * refactor: rename clim methods to clims and introduce ClimPolicy for contrast limits * update clims after visibility change * update naming * allow Nonw * fix typing * typing again --- mkdocs.yml | 3 + src/ndv/controllers/_channel_controller.py | 78 ++++----- src/ndv/models/__init__.py | 15 +- src/ndv/models/_base_model.py | 5 +- src/ndv/models/_lut_model.py | 156 +++++++++++++++--- src/ndv/views/_pygfx/_array_canvas.py | 2 +- src/ndv/views/_qt/_array_view.py | 2 + src/ndv/views/_vispy/_array_canvas.py | 2 +- .../views/bases/_graphics/_canvas_elements.py | 2 +- 9 files changed, 196 insertions(+), 69 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 183f06f7..d747f0a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,9 @@ plugins: python: import: - https://docs.python.org/3/objects.inv + - https://docs.pydantic.dev/latest/objects.inv + - https://cmap-docs.readthedocs.io/objects.inv + - https://psygnal.readthedocs.io/en/latest/objects.inv options: docstring_section_style: list docstring_style: "numpy" diff --git a/src/ndv/controllers/_channel_controller.py b/src/ndv/controllers/_channel_controller.py index c7442e11..c5261536 100644 --- a/src/ndv/controllers/_channel_controller.py +++ b/src/ndv/controllers/_channel_controller.py @@ -3,6 +3,8 @@ from contextlib import suppress from typing import TYPE_CHECKING +from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsType + if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -37,7 +39,6 @@ def __init__(self, key: LutKey, model: LUTModel, views: Sequence[LutView]) -> No # connect model changes to view callbacks that update the view self.lut_model.events.cmap.connect(self._on_model_cmap_changed) self.lut_model.events.clims.connect(self._on_model_clims_changed) - self.lut_model.events.autoscale.connect(self._on_model_autoscale_changed) self.lut_model.events.visible.connect(self._on_model_visible_changed) self.lut_model.events.gamma.connect(self._on_model_gamma_changed) @@ -52,12 +53,15 @@ def add_lut_view(self, view: LutView) -> None: view.gammaChanged.connect(self._on_view_lut_gamma_changed) self._update_view_from_model(view) - def _on_model_clims_changed(self, clims: tuple[float, float]) -> None: + def _on_model_clims_changed(self, clims: ClimsType) -> None: """The contrast limits in the model have changed.""" - for v in self.lut_views: - v.set_clims_without_signal(clims) + is_autoscale = not clims.is_manual for handle in self.handles: - handle.set_clims(clims) + min_max = clims.calc_clims(handle.data()) + handle.set_clims(min_max) + for v in self.lut_views: + v.set_clims_without_signal(min_max) + v.set_auto_scale_without_signal(is_autoscale) def _on_model_gamma_changed(self, gamma: float) -> None: """The gamma value in the model has changed.""" @@ -66,15 +70,6 @@ def _on_model_gamma_changed(self, gamma: float) -> None: for handle in self.handles: handle.set_gamma(gamma) - def _on_model_autoscale_changed(self, autoscale: bool) -> None: - """The autoscale setting in the model has changed.""" - for view in self.lut_views: - view.set_auto_scale_without_signal(autoscale) - if autoscale: - for handle in self.handles: - d = handle.data() - handle.set_clims((d.min(), d.max())) - def _on_model_cmap_changed(self, cmap: cmap.Colormap) -> None: """The colormap in the model has changed.""" for view in self.lut_views: @@ -94,10 +89,11 @@ def _update_view_from_model(self, *views: LutView) -> None: _views: Iterable[LutView] = views or self.lut_views for view in _views: view.set_colormap_without_signal(self.lut_model.cmap) - if self.lut_model.clims: - view.set_clims_without_signal(self.lut_model.clims) - # TODO: handle more complex autoscale types - view.set_auto_scale_without_signal(bool(self.lut_model.autoscale)) + if self.lut_model.clims and (clims := self.lut_model.clims.cached_clims): + view.set_clims_without_signal(clims) + + is_autoscale = not self.lut_model.clims.is_manual + view.set_auto_scale_without_signal(is_autoscale) view.set_channel_visible_without_signal(True) name = str(self.key) if self.key is not None else "" view.set_channel_name(name) @@ -105,23 +101,23 @@ def _update_view_from_model(self, *views: LutView) -> None: def _on_view_lut_visible_changed(self, visible: bool, key: LutKey = None) -> None: """The visibility checkbox in the LUT widget has changed.""" for handle in self.handles: + previous = handle.visible() handle.set_visible(visible) + if previous != visible: + self._update_clims(handle) def _on_view_lut_autoscale_changed( self, autoscale: bool, key: LutKey = None ) -> None: """The autoscale checkbox in the LUT widget has changed.""" - self.lut_model.autoscale = autoscale + if autoscale: + self.lut_model.clims = ClimsMinMax() + elif cached := self.lut_model.clims.cached_clims: + self.lut_model.clims = ClimsManual(min=cached[0], max=cached[1]) + for view in self.lut_views: view.set_auto_scale_without_signal(autoscale) - if autoscale: - # TODO: or should we have a global min/max across all handles for this key? - for handle in self.handles: - data = handle.data() - # update the model with the new clim values - self.lut_model.clims = (data.min(), data.max()) - def _on_view_lut_cmap_changed( self, cmap: cmap.Colormap, key: LutKey = None ) -> None: @@ -132,9 +128,7 @@ def _on_view_lut_cmap_changed( def _on_view_lut_clims_changed(self, clims: tuple[float, float]) -> None: """The contrast limits slider in the LUT widget has changed.""" - self.lut_model.clims = clims - # when the clims are manually adjusted in the view, we turn off autoscale - self.lut_model.autoscale = False + self.lut_model.clims = ClimsManual(min=clims[0], max=clims[1]) def _on_view_lut_gamma_changed(self, gamma: float) -> None: """The gamma slider in the LUT widget has changed.""" @@ -147,26 +141,22 @@ def update_texture_data(self, data: np.ndarray) -> None: # for multiple handles, we'll just update the first one if not (handles := self.handles): return - handles[0].set_data(data) - # if this image handle is visible and autoscale is on, then we need - # to update the clim values - if self.lut_model.autoscale: - self.lut_model.clims = (data.min(), data.max()) - # lut_view.setClims((data.min(), data.max())) - # technically... the LutView may also emit a signal that the - # controller listens to, and then updates the image handle - # but this next line is more direct - # self._handles[None].clim = (data.min(), data.max()) + handle = handles[0] + handle.set_data(data) + if handle.visible(): + self._update_clims(handle) def add_handle(self, handle: ImageHandle) -> None: """Add an image texture handle to the controller.""" self.handles.append(handle) handle.set_cmap(self.lut_model.cmap) - if self.lut_model.autoscale: - data = handle.data() - self.lut_model.clims = (data.min(), data.max()) - if self.lut_model.clims: - handle.set_clims(self.lut_model.clims) + self._update_clims(handle) + + def _update_clims(self, handle: ImageHandle) -> None: + min_max = self.lut_model.clims.calc_clims(handle.data()) + handle.set_clims(min_max) + for view in self.lut_views: + view.set_clims_without_signal(min_max) def get_value_at_index(self, idx: tuple[int, ...]) -> float | None: """Get the value of the data at the given index.""" diff --git a/src/ndv/models/__init__.py b/src/ndv/models/__init__.py index 7a823415..5c65c9c4 100644 --- a/src/ndv/models/__init__.py +++ b/src/ndv/models/__init__.py @@ -1,7 +1,18 @@ """Models for `ndv`.""" from ._array_display_model import ArrayDisplayModel, ChannelMode +from ._base_model import NDVModel from ._data_wrapper import DataWrapper -from ._lut_model import LUTModel +from ._lut_model import ClimPolicy, ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel -__all__ = ["ArrayDisplayModel", "ChannelMode", "DataWrapper", "LUTModel"] +__all__ = [ + "ArrayDisplayModel", + "ChannelMode", + "ClimPolicy", + "ClimsManual", + "ClimsMinMax", + "ClimsPercentile", + "DataWrapper", + "LUTModel", + "NDVModel", +] diff --git a/src/ndv/models/_base_model.py b/src/ndv/models/_base_model.py index 310ef498..4afef2e4 100644 --- a/src/ndv/models/_base_model.py +++ b/src/ndv/models/_base_model.py @@ -5,7 +5,10 @@ class NDVModel(BaseModel): - """Base evented model for NDV models.""" + """Base evented model for NDV models. + + Uses [pydantic.BaseModel][] and [psygnal.SignalGroupDescriptor][]. + """ model_config = ConfigDict( validate_assignment=True, diff --git a/src/ndv/models/_lut_model.py b/src/ndv/models/_lut_model.py index ae50109d..53a2cb16 100644 --- a/src/ndv/models/_lut_model.py +++ b/src/ndv/models/_lut_model.py @@ -1,8 +1,18 @@ -from typing import Any, Callable, Optional, Union +from abc import ABC, abstractmethod +from typing import Annotated, Any, Callable, Literal, Optional, Union +import numpy as np import numpy.typing as npt +from annotated_types import Gt, Interval from cmap import Colormap -from pydantic import Field, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + field_validator, + model_validator, +) from typing_extensions import TypeAlias from ._base_model import NDVModel @@ -12,6 +22,112 @@ ] +class ClimPolicy(BaseModel, ABC): + """ABC for contrast limit policies.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + _cached_clims: Optional[tuple[float, float]] = PrivateAttr(None) + + @abstractmethod + def get_limits(self, image: npt.NDArray) -> tuple[float, float]: + """Return the contrast limits for the given image.""" + + def calc_clims(self, image: npt.NDArray) -> tuple[float, float]: + self._cached_clims = value = self.get_limits(image) + return value + + @property + def cached_clims(self) -> Optional[tuple[float, float]]: + """Return the last calculated clims.""" + return self._cached_clims + + @property + def is_manual(self) -> bool: + return self.__class__ == ClimsManual + + +class ClimsManual(ClimPolicy): + """Manually specified contrast limits. + + Attributes + ---------- + min: float + The minimum contrast limit. + max: float + The maximum contrast limit. + """ + + clim_type: Literal["manual"] = "manual" + min: float + max: float + + def get_limits(self, data: npt.NDArray) -> tuple[float, float]: + return self.min, self.max + + +class ClimsMinMax(ClimPolicy): + """Autoscale contrast limits based on the minimum and maximum values in the data.""" + + clim_type: Literal["minmax"] = "minmax" + + def get_limits(self, data: npt.NDArray) -> tuple[float, float]: + return (np.nanmin(data), np.nanmax(data)) + + +class ClimsPercentile(ClimPolicy): + """Autoscale contrast limits based on percentiles of the data. + + Attributes + ---------- + min_percentile: float + The lower percentile for the contrast limits. + max_percentile: float + The upper percentile for the contrast limits. + """ + + clim_type: Literal["percentile"] = "percentile" + min_percentile: Annotated[float, Interval(ge=0, le=100)] = 0 + max_percentile: Annotated[float, Interval(ge=0, le=100)] = 100 + + def get_limits(self, data: npt.NDArray) -> tuple[float, float]: + return tuple(np.nanpercentile(data, [self.min_percentile, self.max_percentile])) + + +class ClimsStdDev(ClimPolicy): + """Automatically set contrast limits based on standard deviations from the mean. + + Attributes + ---------- + n_stdev: float + Number of standard deviations to use. + center: Optional[float] + Center value for the standard deviation calculation. If None, the mean is + used. + """ + + clim_type: Literal["stddev"] = "stddev" + n_stdev: Annotated[float, Gt(0)] = 2 # number of standard deviations + center: Optional[float] = None # None means center around the mean + + def get_limits(self, data: npt.NDArray) -> tuple[float, float]: + center = np.nanmean(data) if self.center is None else self.center + diff = self.n_stdev * np.nanstd(data) + return center - diff, center + diff + + +# we can add this, but it needs to have a proper pydantic serialization method +# similar to ReducerType +# class CustomClims(ClimPolicy): +# type_: Literal["custom"] = "custom" +# func: Callable[[npt.ArrayLike], tuple[float, float]] + +# def get_limits(self, data: npt.NDArray) -> tuple[float, float]: +# return self.func(data) + + +ClimsType = Union[ClimsManual, ClimsPercentile, ClimsStdDev, ClimsMinMax] + + class LUTModel(NDVModel): """Representation of how to display a channel of an array. @@ -21,31 +137,18 @@ class LUTModel(NDVModel): Whether to display this channel. NOTE: This has implications for data retrieval, as we may not want to request channels that are not visible. See current_index above. - cmap : Colormap + cmap : cmap.Colormap Colormap to use for this channel. - clims : tuple[float, float] | None - Contrast limits for this channel. - TODO: What does `None` imply? Autoscale? + clims : Union[ManualClims, PercentileClims, StdDevClims, MinMaxClims] + Method for determining the contrast limits for this channel. gamma : float Gamma correction for this channel. By default, 1.0. - autoscale : bool | tuple[float, float] - Whether/how to autoscale the colormap. - If `False`, then autoscaling is disabled. - If `True` or `(0, 1)` then autoscale using the min/max of the data. - If a tuple, then the first element is the lower quantile and the second element - is the upper quantile. - If a callable, then it should be a function that takes an array and returns a - tuple of (min, max) values to use for scaling. - - NaN values should be ignored (n.b. nanmax is slower and should only be used if - necessary). """ visible: bool = True cmap: Colormap = Field(default_factory=lambda: Colormap("gray")) - clims: Optional[tuple[float, float]] = None + clims: ClimsType = Field(discriminator="clim_type", default_factory=ClimsMinMax) gamma: float = 1.0 - autoscale: AutoscaleType = Field(default=True, union_mode="left_to_right") @model_validator(mode="before") def _validate_model(cls, v: Any) -> Any: @@ -53,3 +156,18 @@ def _validate_model(cls, v: Any) -> Any: if isinstance(v, (str, Colormap)): return {"cmap": v} return v + + @field_validator("clims", mode="before") + @classmethod + def _validate_clims(cls, v: ClimsType) -> ClimsType: + if v is None or ( + isinstance(v, dict) + and v.get("min_percentile") == 0 + and v.get("max_percentile") == 100 + ): + return ClimsMinMax() + if isinstance(v, (tuple, list, np.ndarray)): + if len(v) == 2: + return ClimsManual(min=v[0], max=v[1]) + raise ValueError("Clims sequence must have exactly 2 elements.") + return v diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py index 4f884145..3cc0a471 100755 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ b/src/ndv/views/_pygfx/_array_canvas.py @@ -65,7 +65,7 @@ def selected(self) -> bool: def set_selected(self, selected: bool) -> None: raise NotImplementedError("Images cannot be selected") - def clim(self) -> Any: + def clims(self) -> Any: return self._material.clim def set_clims(self, clims: tuple[float, float]) -> None: diff --git a/src/ndv/views/_qt/_array_view.py b/src/ndv/views/_qt/_array_view.py index 281df4cf..6652a8f9 100644 --- a/src/ndv/views/_qt/_array_view.py +++ b/src/ndv/views/_qt/_array_view.py @@ -144,6 +144,8 @@ def set_colormap(self, cmap: cmap.Colormap) -> None: self._qwidget.cmap.setCurrentColormap(cmap) def set_clims(self, clims: tuple[float, float]) -> None: + if not isinstance(clims, tuple): + breakpoint() self._qwidget.clims.setValue(clims) def set_gamma(self, gamma: float) -> None: diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py index 55b1b8fd..35c12ed8 100755 --- a/src/ndv/views/_vispy/_array_canvas.py +++ b/src/ndv/views/_vispy/_array_canvas.py @@ -288,7 +288,7 @@ def selected(self) -> bool: def set_selected(self, selected: bool) -> None: raise NotImplementedError("Images cannot be selected") - def clim(self) -> Any: + def clims(self) -> Any: return self._visual.clim def set_clims(self, clims: tuple[float, float]) -> None: diff --git a/src/ndv/views/bases/_graphics/_canvas_elements.py b/src/ndv/views/bases/_graphics/_canvas_elements.py index 4e7ced55..c4add83e 100644 --- a/src/ndv/views/bases/_graphics/_canvas_elements.py +++ b/src/ndv/views/bases/_graphics/_canvas_elements.py @@ -66,7 +66,7 @@ def data(self) -> np.ndarray: ... @abstractmethod def set_data(self, data: np.ndarray) -> None: ... @abstractmethod - def clim(self) -> Any: ... + def clims(self) -> tuple[float, float]: ... @abstractmethod def set_clims(self, clims: tuple[float, float]) -> None: ... @abstractmethod