Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: return 3d support to v2 #83

Merged
merged 41 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fa9fb62
starting on base classes
tlambert03 Dec 11, 2024
23045d6
more removals
tlambert03 Dec 11, 2024
ea54a06
typing complete
tlambert03 Dec 11, 2024
06e6b08
histogram fixes
tlambert03 Dec 11, 2024
96ada85
remove protocols
tlambert03 Dec 11, 2024
ba857ac
make work with old viewer
tlambert03 Dec 11, 2024
0b986db
final?
tlambert03 Dec 11, 2024
20437f0
fix jupyter
tlambert03 Dec 11, 2024
8e22916
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Dec 11, 2024
b54237a
fix pygfx
tlambert03 Dec 11, 2024
c2ac039
Merge branch 'v2-bases-instead-of-protocols' of https://github.com/tl…
tlambert03 Dec 11, 2024
45ab81c
fix tests and typing
tlambert03 Dec 11, 2024
ce9453e
move stuff
tlambert03 Dec 11, 2024
ffa085c
fix circle
tlambert03 Dec 11, 2024
5a55ea7
revert
tlambert03 Dec 11, 2024
7b06611
wip
tlambert03 Dec 11, 2024
f4f591c
Merge branch 'v2-mvc' into v2-3d
tlambert03 Dec 14, 2024
ab06356
fix merge
tlambert03 Dec 14, 2024
f958cad
kinda working
tlambert03 Dec 14, 2024
8f257e9
remove prints
tlambert03 Dec 14, 2024
22292d5
fix data overwrite
tlambert03 Dec 14, 2024
0060a2b
add to jupyter
tlambert03 Dec 14, 2024
480e2ad
Merge branch 'v2-mvc' into v2-3d
tlambert03 Dec 16, 2024
ce4389d
Merge branch 'v2-mvc' into v2-3d
tlambert03 Dec 16, 2024
4659f90
Merge branch 'v2-mvc' into v2-3d
tlambert03 Dec 17, 2024
d05c4cf
Merge branch 'v2-mvc' into v2-3d
tlambert03 Dec 19, 2024
6f73d42
fix for wx
tlambert03 Dec 19, 2024
a0837e7
Merge branch 'main' into v2-3d
tlambert03 Jan 9, 2025
c7279b6
Merge branch 'main' into v2-3d
tlambert03 Jan 11, 2025
1668830
3d working on all frontends
tlambert03 Jan 11, 2025
3d5078c
fix tests
tlambert03 Jan 11, 2025
90e5842
add note
tlambert03 Jan 11, 2025
2737b70
add tests
tlambert03 Jan 11, 2025
f414852
remove unneeded change
tlambert03 Jan 11, 2025
a7551a3
skip wx test
tlambert03 Jan 11, 2025
954339d
Merge branch 'main' into v2-3d
tlambert03 Jan 12, 2025
8214245
Merge branch 'main' into v2-3d
tlambert03 Jan 16, 2025
d5af7e1
fix test
tlambert03 Jan 16, 2025
9028d63
make hist work when switching to 3d
tlambert03 Jan 16, 2025
35ae838
fix 3d button in jupyter
tlambert03 Jan 16, 2025
d714dec
update notebook
tlambert03 Jan 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions examples/notebook.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"cells": [
{
"cell_type": "code",
"execution_count": 5,
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "f2fe7ce4f36847ffaba8c042bf85b76d",
"model_id": "f93b58f35c5e44dc9f115d43f8a9b1a8",
"version_major": 2,
"version_minor": 0
},
Expand All @@ -22,7 +22,7 @@
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "92985e7df82f47cabfe3ab5ce9ab0498",
"model_id": "6c134f66cf2b4a2e883fc41f76eda14d",
"version_major": 2,
"version_minor": 0
},
Expand All @@ -38,8 +38,8 @@
"from ndv import data, imshow\n",
"\n",
"viewer = imshow(data.cells3d())\n",
"viewer.model.channel_mode = \"composite\"\n",
"viewer.model.current_index.update({0: 32})"
"viewer.display_model.channel_mode = \"composite\"\n",
"viewer.display_model.current_index.update({0: 32})"
]
},
{
Expand Down
13 changes: 6 additions & 7 deletions src/ndv/controllers/_array_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def __init__(
"When display_model is provided, kwargs are be ignored.",
stacklevel=2,
)
self._data_model = _ArrayDataDisplayModel(
data_wrapper=data, display=display_model or ArrayDisplayModel(**kwargs)
)

# mapping of channel keys to their respective controllers
# where None is the default channel
Expand All @@ -74,15 +77,11 @@ def __init__(
self._canvas = canvas_cls()

self._histogram: HistogramCanvas | None = None
self._view = frontend_cls(self._canvas.frontend_widget())
self._view = frontend_cls(self._canvas.frontend_widget(), self._data_model)

display_model = display_model or ArrayDisplayModel(**kwargs)
self._data_model = _ArrayDataDisplayModel(
data_wrapper=data, display=display_model
)
self._set_model_connected(self._data_model.display)
self._canvas.set_ndim(self.display_model.n_visible_axes)
self._view.set_visible_axes(self.display_model.visible_axes)
self._view.set_visible_axes(self._data_model.normed_visible_axes)

self._view.currentIndexChanged.connect(self._on_view_current_index_changed)
self._view.resetZoomClicked.connect(self._on_view_reset_zoom_clicked)
Expand Down Expand Up @@ -245,7 +244,7 @@ def _fully_synchronize_view(self) -> None:
self._update_hist_domain_for_dtype()

def _on_model_visible_axes_changed(self) -> None:
self._view.set_visible_axes(self.display_model.visible_axes)
self._view.set_visible_axes(self._data_model.normed_visible_axes)
self._update_visible_sliders()
self._clear_canvas()
self._update_canvas()
Expand Down
2 changes: 2 additions & 0 deletions src/ndv/models/_data_display_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def __post_init__(self) -> None:
self.dtype = self.data.dtype


# NOTE: nobody particularly likes this class. It does important stuff, but we're
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guilty as charged 🙈

# not yet sure where this logic belongs.
class _ArrayDataDisplayModel(NDVModel):
"""Utility class combining ArrayDisplayModel model with a DataWrapper.

Expand Down
18 changes: 18 additions & 0 deletions src/ndv/models/data_wrappers/_data_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ class DataWrapper(Generic[ArrayT], ABC):
PRIORITY: ClassVar[int] = 50
# These names will be checked when looking for a channel axis
COMMON_CHANNEL_NAMES: ClassVar[Container[str]] = ("channel", "ch", "c")
COMMON_Z_AXIS_NAMES: ClassVar[Container[str]] = ("z", "depth", "focus")

# Maximum dimension size consider when guessing the channel axis
MAX_CHANNELS = 16

Expand Down Expand Up @@ -172,6 +174,22 @@ def guess_channel_axis(self) -> Hashable | None:
# otherwise use the smallest dimension as the channel axis
return min(sizes, key=sizes.get) # type: ignore [arg-type]

def guess_z_axis(self) -> Hashable | None:
"""Return the (best guess) axis name for the z (3rd spatial) dimension."""
sizes = self.sizes()
ch = self.guess_channel_axis()
for dimkey in sizes:
if str(dimkey).lower() in self.COMMON_Z_AXIS_NAMES:
if (normed := self.normalized_axis_key(dimkey)) != ch:
return normed

# otherwise return the LAST axis that is neither in the last two dimensions
# or the channel axis guess
return next(
(self.normalized_axis_key(x) for x in reversed(self.dims[:-2]) if x != ch),
None,
)

def summary_info(self) -> str:
"""Return info label with information about the data."""
package = getattr(self._data, "__module__", "").split(".")[0]
Expand Down
30 changes: 20 additions & 10 deletions src/ndv/views/_jupyter/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from vispy.app.backends import _jupyter_rfb

from ndv._types import AxisKey
from ndv.models._data_display_model import _ArrayDataDisplayModel

# not entirely sure why it's necessary to specifically annotat signals as : PSignal
# i think it has to do with type variance?
Expand Down Expand Up @@ -110,9 +111,12 @@ def frontend_widget(self) -> Any:

class JupyterArrayView(ArrayView):
def __init__(
self, canvas_widget: _jupyter_rfb.CanvasBackend, **kwargs: Any
self,
canvas_widget: _jupyter_rfb.CanvasBackend,
data_model: _ArrayDataDisplayModel,
) -> None:
# WIDGETS
self._data_model = data_model
self._canvas_widget = canvas_widget
self._visible_axes: Sequence[AxisKey] = []

Expand Down Expand Up @@ -269,16 +273,22 @@ def set_visible_axes(self, axes: Sequence[AxisKey]) -> None:
self._ndims_btn.value = len(axes) == 3

def _on_ndims_toggled(self, change: dict[str, Any]) -> None:
was_3d = len(self._visible_axes) > 2
is_3d = change["new"]
if was_3d and not is_3d:
if len(self._visible_axes) > 2:
if change["new"]: # no change
return
self._visible_axes = self._visible_axes[-2:]
elif not was_3d and is_3d:
# FIXME
# HACCCCCKKK
# need a better way to add the next dimension in the GUI
new_ax = 0
self._visible_axes = (new_ax, *self._visible_axes)
else:
z_ax = None
if wrapper := self._data_model.data_wrapper:
z_ax = wrapper.guess_z_axis()
if z_ax is None:
# get the last slider that is not in visible axes
z_ax = next(
ax for ax in reversed(self._sliders) if ax not in self._visible_axes
)
self._visible_axes = (z_ax, *self._visible_axes)
# TODO: a future PR may decide to set this on the model directly...
# since we now have access to it.
self.visibleAxesChanged.emit()

def close(self) -> None:
Expand Down
31 changes: 20 additions & 11 deletions src/ndv/views/_qt/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from qtpy.QtGui import QIcon

from ndv._types import AxisKey
from ndv.models._data_display_model import _ArrayDataDisplayModel

SLIDER_STYLE = """
QSlider::groove:horizontal {
Expand Down Expand Up @@ -346,7 +347,10 @@ def __init__(self, canvas_widget: QWidget, parent: QWidget | None = None):


class QtArrayView(ArrayView):
def __init__(self, canvas_widget: QWidget) -> None:
def __init__(
self, canvas_widget: QWidget, data_model: _ArrayDataDisplayModel
) -> None:
self._data_model = data_model
self._qwidget = qwdg = _QArrayViewer(canvas_widget)
qwdg.histogram_btn.clicked.connect(self._on_add_histogram_clicked)

Expand Down Expand Up @@ -410,17 +414,22 @@ def set_current_index(self, value: Mapping[AxisKey, int | slice]) -> None:
"""Set the current value of the sliders."""
self._qwidget.dims_sliders.set_current_index(value)

def _on_ndims_toggled(self, checked: bool) -> None:
was_3d = len(self._visible_axes) > 2
is_3d = checked
if was_3d and not is_3d:
def _on_ndims_toggled(self, is_3d: bool) -> None:
if len(self._visible_axes) > 2:
if is_3d: # no change
return
self._visible_axes = self._visible_axes[-2:]
elif not was_3d and is_3d:
# FIXME
# HACCCCCKKK
# need a better way to add the next dimension in the GUI
new_ax = 0
self._visible_axes = (new_ax, *self._visible_axes)
else:
z_ax = None
if wrapper := self._data_model.data_wrapper:
z_ax = wrapper.guess_z_axis()
if z_ax is None:
# get the last slider that is not in visible axes
sld = reversed(self._qwidget.dims_sliders._sliders)
z_ax = next(ax for ax in sld if ax not in self._visible_axes)
self._visible_axes = (z_ax, *self._visible_axes)
# TODO: a future PR may decide to set this on the model directly...
# since we now have access to it.
self.visibleAxesChanged.emit()

def visible_axes(self) -> Sequence[AxisKey]:
Expand Down
81 changes: 62 additions & 19 deletions src/ndv/views/_wx/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import cmap

from ndv._types import AxisKey
from ndv.models._data_display_model import _ArrayDataDisplayModel


# mostly copied from _qt.qt_view._QLUTWidget
Expand Down Expand Up @@ -91,6 +92,11 @@ def set_colormap(self, cmap: cmap.Colormap) -> None:

def set_clims(self, clims: tuple[float, float]) -> None:
self._wxwidget.clims.SetValue(*clims)
# FIXME: this is a hack.
# it's required to make `set_auto_scale_without_signal` work as intended
# But it's not a complete solution. The general pattern of blocking signals
# in Wx needs to be re-evaluated.
wx.Yield()

def set_channel_visible(self, visible: bool) -> None:
self._wxwidget.visible.SetValue(visible)
Expand Down Expand Up @@ -194,41 +200,58 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None):

# Channel mode combo box
self.channel_mode_combo = wx.ComboBox(
self, choices=[x.value for x in ChannelMode], style=wx.CB_DROPDOWN
self,
choices=[ChannelMode.GRAYSCALE.value, ChannelMode.COMPOSITE.value],
style=wx.CB_DROPDOWN,
)

# Reset zoom button
self.reset_zoom_btn = wx.Button(self, label="Reset Zoom")

# Reset zoom button
self.ndims_btn = wx.ToggleButton(self, label="3D")

# LUT layout (simple vertical grouping for LUT widgets)
self.luts = wx.BoxSizer(wx.VERTICAL)

btns = wx.BoxSizer(wx.HORIZONTAL)
btns.Add(self.channel_mode_combo, 0, wx.RIGHT, 5)
btns.Add(self.reset_zoom_btn, 0, wx.RIGHT, 5)
btns.AddStretchSpacer()
btns.Add(self.channel_mode_combo, 0, wx.ALL, 5)
btns.Add(self.reset_zoom_btn, 0, wx.ALL, 5)
btns.Add(self.ndims_btn, 0, wx.ALL, 5)

# Layout for the panel
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self._data_info_label, 0, wx.EXPAND | wx.BOTTOM, 5)
main_sizer.Add(self._canvas, 1, wx.EXPAND | wx.ALL, 5)
main_sizer.Add(self._hover_info_label, 0, wx.EXPAND | wx.BOTTOM, 5)
main_sizer.Add(self.dims_sliders, 0, wx.EXPAND | wx.BOTTOM, 5)
main_sizer.Add(self.luts, 0, wx.EXPAND, 5)
main_sizer.Add(btns, 0, wx.EXPAND, 5)

self.SetSizer(main_sizer)
inner = wx.BoxSizer(wx.VERTICAL)
inner.Add(self._data_info_label, 0, wx.EXPAND | wx.BOTTOM, 5)
inner.Add(self._canvas, 1, wx.EXPAND | wx.ALL)
inner.Add(self._hover_info_label, 0, wx.EXPAND | wx.BOTTOM)
inner.Add(self.dims_sliders, 0, wx.EXPAND | wx.BOTTOM)
inner.Add(self.luts, 0, wx.EXPAND)
inner.Add(btns, 0, wx.EXPAND)

outer = wx.BoxSizer(wx.VERTICAL)
outer.Add(inner, 1, wx.ALL, 10)
self.SetSizer(outer)
self.SetInitialSize(wx.Size(600, 800))
self.Layout()


class WxArrayView(ArrayView):
def __init__(self, canvas_widget: wx.Window, parent: wx.Window = None) -> None:
def __init__(
self,
canvas_widget: wx.Window,
data_model: _ArrayDataDisplayModel,
parent: wx.Window = None,
) -> None:
self._data_model = data_model
self._wxwidget = wdg = _WxArrayViewer(canvas_widget, parent)
self._visible_axes: Sequence[AxisKey] = []

# TODO: use emit_fast
wdg.dims_sliders.currentIndexChanged.connect(self.currentIndexChanged.emit)
wdg.channel_mode_combo.Bind(wx.EVT_COMBOBOX, self._on_channel_mode_changed)
wdg.reset_zoom_btn.Bind(wx.EVT_BUTTON, self._on_reset_zoom_clicked)
wdg.ndims_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_ndims_toggled)

def _on_channel_mode_changed(self, event: wx.CommandEvent) -> None:
mode = self._wxwidget.channel_mode_combo.GetValue()
Expand All @@ -237,6 +260,32 @@ def _on_channel_mode_changed(self, event: wx.CommandEvent) -> None:
def _on_reset_zoom_clicked(self, event: wx.CommandEvent) -> None:
self.resetZoomClicked.emit()

def _on_ndims_toggled(self, event: wx.CommandEvent) -> None:
is_3d = self._wxwidget.ndims_btn.GetValue()
if len(self._visible_axes) > 2:
if is_3d: # no change
return
self._visible_axes = self._visible_axes[-2:]
else:
z_ax = None
if wrapper := self._data_model.data_wrapper:
z_ax = wrapper.guess_z_axis()
if z_ax is None:
# get the last slider that is not in visible axes
sld = reversed(self._wxwidget.dims_sliders._sliders)
z_ax = next(ax for ax in sld if ax not in self._visible_axes)
self._visible_axes = (z_ax, *self._visible_axes)
# TODO: a future PR may decide to set this on the model directly...
# since we now have access to it.
self.visibleAxesChanged.emit()

def visible_axes(self) -> Sequence[AxisKey]:
return self._visible_axes # no widget to control this yet

def set_visible_axes(self, axes: Sequence[AxisKey]) -> None:
self._visible_axes = tuple(axes)
self._wxwidget.ndims_btn.SetValue(len(axes) == 3)

def frontend_widget(self) -> wx.Window:
return self._wxwidget

Expand Down Expand Up @@ -283,11 +332,5 @@ def set_visible(self, visible: bool) -> None:
else:
self._wxwidget.Hide()

def set_visible_axes(self, axes: Sequence[AxisKey]) -> None:
pass

def visible_axes(self) -> Sequence[AxisKey]:
return []

def close(self) -> None:
self._wxwidget.Close()
10 changes: 7 additions & 3 deletions src/ndv/views/bases/_array_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from abc import abstractmethod
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

Expand All @@ -13,10 +13,11 @@
from collections.abc import Container, Hashable, Mapping, Sequence

from ndv._types import AxisKey
from ndv.models._data_display_model import _ArrayDataDisplayModel
from ndv.views.bases import LutView


class ArrayView(Viewable):
class ArrayView(Viewable, ABC):
"""ABC for ND Array viewers widget.

Currently, this is the "main" widget that contains the array display and
Expand All @@ -30,8 +31,11 @@ class ArrayView(Viewable):
visibleAxesChanged = Signal()
channelModeChanged = Signal(ChannelMode)

# model: _ArrayDataDisplayModel is likely a temporary parameter
@abstractmethod
def __init__(self, canvas_widget: Any, **kwargs: Any) -> None: ...
def __init__(
self, canvas_widget: Any, model: _ArrayDataDisplayModel, **kwargs: Any
) -> None: ...
@abstractmethod
def create_sliders(self, coords: Mapping[int, Sequence]) -> None: ...
@abstractmethod
Expand Down