-
-
+
-
+
- 14
+ true
-
- padding: 0px
-
- ⇄
+ z axis
- -
-
+
-
+
+
+ -
+
+
+ -
+
diff --git a/glue_vispy_viewers/volume/jupyter/viewer_state_widget.py b/glue_vispy_viewers/volume/jupyter/viewer_state_widget.py
index 2aa3541c..7f3f091d 100644
--- a/glue_vispy_viewers/volume/jupyter/viewer_state_widget.py
+++ b/glue_vispy_viewers/volume/jupyter/viewer_state_widget.py
@@ -1,6 +1,8 @@
import ipyvuetify as v
import traitlets
+from glue.core.coordinate_helpers import world_axis
+from glue.viewers.image.state import AggregateSlice
from glue_jupyter.state_traitlets_helpers import GlueState
from glue_jupyter.vuetify_helpers import link_glue_choices
@@ -28,6 +30,8 @@ class Volume3DViewerStateWidget(v.VuetifyTemplate):
resolution_items = traitlets.List().tag(sync=True)
resolution_selected = traitlets.Int(allow_none=True).tag(sync=True)
+ sliders = traitlets.List().tag(sync=True)
+
def __init__(self, viewer_state):
super().__init__()
@@ -40,3 +44,49 @@ def __init__(self, viewer_state):
link_glue_choices(self, viewer_state, "y_att")
link_glue_choices(self, viewer_state, "z_att")
link_glue_choices(self, viewer_state, "resolution")
+
+ for prop in ['x_att', 'y_att', 'z_att', 'slices', 'reference_data']:
+ viewer_state.add_callback(prop, self._sync_sliders_from_state)
+
+ self._sync_sliders_from_state()
+
+ def _sync_sliders_from_state(self, *not_used):
+
+ if self.viewer_state.reference_data is None or self.viewer_state.slices is None:
+ return
+
+ if self.viewer_state.x_att is None or \
+ self.viewer_state.y_att is None or \
+ self.viewer_state.z_att is None:
+ return
+
+ data = self.viewer_state.reference_data
+
+ def used_on_axis(index):
+ return index in [self.viewer_state.x_att.axis,
+ self.viewer_state.y_att.axis,
+ self.viewer_state.z_att.axis]
+
+ new_slices = []
+ for i in range(data.ndim):
+ slice = self.viewer_state.slices[i]
+ if not used_on_axis(i) and isinstance(slice, AggregateSlice):
+ new_slices.append(slice.center)
+ else:
+ new_slices.append(slice)
+ self.viewer_state.slices = tuple(new_slices)
+
+ self.sliders = [{
+ 'index': i,
+ 'label': (data.world_component_ids[i].label if data.coords
+ else data.pixel_component_ids[i].label),
+ 'max': data.shape[i] - 1,
+ 'unit': (data.get_component(data.world_component_ids[i]).units if data.coords
+ else ''),
+ 'world_value': ("%0.4E" % world_axis(data.coords,
+ data,
+ pixel_axis=data.ndim - 1 - i,
+ world_axis=data.ndim - 1 - i
+ )[self.glue_state.slices[i]] if data.coords
+ else '')
+ } for i in range(data.ndim) if not used_on_axis(i)]
diff --git a/glue_vispy_viewers/volume/jupyter/viewer_state_widget.vue b/glue_vispy_viewers/volume/jupyter/viewer_state_widget.vue
index 482bf280..e0c5ecc9 100644
--- a/glue_vispy_viewers/volume/jupyter/viewer_state_widget.vue
+++ b/glue_vispy_viewers/volume/jupyter/viewer_state_widget.vue
@@ -31,6 +31,16 @@
+
+ {{ slider.label }}: {{ glue_state.slices[slider.index] }} {{ slider.world_value }}
+
+
diff --git a/glue_vispy_viewers/volume/layer_artist.py b/glue_vispy_viewers/volume/layer_artist.py
index 4ffb9e21..f2872688 100644
--- a/glue_vispy_viewers/volume/layer_artist.py
+++ b/glue_vispy_viewers/volume/layer_artist.py
@@ -6,6 +6,7 @@
from matplotlib.colors import ColorConverter
from glue.core.data import Subset, Data
+from glue.core.link_manager import equivalent_pixel_cids
from glue.core.exceptions import IncompatibleAttribute
from glue.core.fixed_resolution_buffer import ARRAY_CACHE, PIXEL_CACHE
from .colors import get_mpl_cmap, get_translucent_cmap
@@ -33,9 +34,15 @@ def viewer_state(self):
@property
def shape(self):
- x_axis = self.viewer_state.x_att.axis
- y_axis = self.viewer_state.y_att.axis
- z_axis = self.viewer_state.z_att.axis
+ order = equivalent_pixel_cids(self.viewer_state.reference_data,
+ self.layer_artist.layer)
+
+ try:
+ x_axis = order.index(self.viewer_state.x_att.axis)
+ y_axis = order.index(self.viewer_state.y_att.axis)
+ z_axis = order.index(self.viewer_state.z_att.axis)
+ except (AttributeError, ValueError):
+ return 0, 0, 0
if isinstance(self.layer_artist.layer, Subset):
full_shape = self.layer_artist.layer.data.shape
@@ -51,12 +58,46 @@ def compute_fixed_resolution_buffer(self, bounds=None):
if self.layer_artist is None or self.viewer_state is None:
return np.broadcast_to(0, shape)
- if isinstance(self.layer_artist.layer, Subset):
+ order = equivalent_pixel_cids(self.viewer_state.reference_data,
+ self.layer_artist.layer)
+ reference_axes = [self.viewer_state.x_att.axis,
+ self.viewer_state.y_att.axis,
+ self.viewer_state.z_att.axis]
+ if order is not None and not set(reference_axes) <= set(order):
+ self.layer_artist.disable('Layer data is not fully linked to x/y/z attributes')
+ return np.broadcast_to(0, shape)
+
+ # For this method, we make use of Data.compute_fixed_resolution_buffer,
+ # which requires us to specify bounds in the form (min, max, nsteps).
+ # We also allow view to be passed here (which is a normal Numpy view)
+ # and, if given, translate it to bounds. If neither are specified,
+ # we behave as if view was [slice(None), slice(None), slice(None)].
+
+ def slice_to_bound(slc, size):
+ min, max, step = slc.indices(size)
+ n = (max - min - 1) // step
+ max = min + step * n
+ return (min, max, n + 1)
+
+ full_view, permutation = self.viewer_state.numpy_slice_permutation
+
+ full_view[reference_axes[0]] = bounds[2]
+ full_view[reference_axes[1]] = bounds[1]
+ full_view[reference_axes[2]] = bounds[0]
+
+ layer = self.layer_artist.layer
+ for i in range(self.viewer_state.reference_data.ndim):
+ if isinstance(full_view[i], slice):
+ full_view[i] = slice_to_bound(full_view[i],
+ self.viewer_state.reference_data.shape[i])
+
+ if isinstance(layer, Subset):
try:
- subset_state = self.layer_artist.layer.subset_state
- result = self.layer_artist.layer.data.compute_fixed_resolution_buffer(
- target_data=self.layer_artist._viewer_state.reference_data,
- bounds=bounds, subset_state=subset_state,
+ subset_state = layer.subset_state
+ result = layer.data.compute_fixed_resolution_buffer(
+ full_view,
+ target_data=self.viewer_state.reference_data,
+ subset_state=subset_state,
cache_id=self.layer_artist.id)
except IncompatibleAttribute:
self.layer_artist.disable_incompatible_subset()
@@ -65,9 +106,10 @@ def compute_fixed_resolution_buffer(self, bounds=None):
self.layer_artist.enable()
else:
try:
- result = self.layer_artist.layer.compute_fixed_resolution_buffer(
- target_data=self.layer_artist._viewer_state.reference_data,
- bounds=bounds, target_cid=self.layer_artist.state.attribute,
+ result = layer.compute_fixed_resolution_buffer(
+ full_view,
+ target_data=self.viewer_state.reference_data,
+ target_cid=self.layer_artist.state.attribute,
cache_id=self.layer_artist.id)
except IncompatibleAttribute:
self.layer_artist.disable('Layer data is not fully linked to reference data')
@@ -75,6 +117,12 @@ def compute_fixed_resolution_buffer(self, bounds=None):
else:
self.layer_artist.enable()
+ if permutation:
+ try:
+ result = result.transpose(permutation)
+ except ValueError:
+ return np.broadcast_to(0, shape)
+
return result
@@ -128,9 +176,9 @@ def visual(self):
@property
def bbox(self):
- return (-0.5, self.layer.shape[2] - 0.5,
- -0.5, self.layer.shape[1] - 0.5,
- -0.5, self.layer.shape[0] - 0.5)
+ return (-0.5, self.layer.shape[self._viewer_state.x_att.axis] - 0.5,
+ -0.5, self.layer.shape[self._viewer_state.y_att.axis] - 0.5,
+ -0.5, self.layer.shape[self._viewer_state.z_att.axis] - 0.5)
@property
def shape(self):
@@ -243,7 +291,9 @@ def _update_volume(self, force=False, **kwargs):
if force or 'alpha' in changed:
self._update_alpha()
- if force or 'layer' in changed or 'attribute' in changed:
+ # TODO: Feel like we shouldn't need the axis atts here
+ if force or any(att in changed for att in
+ ('layer', 'attribute', 'slices', 'x_att', 'y_att', 'z_att')):
self._update_data()
if force or 'subset_mode' in changed:
diff --git a/glue_vispy_viewers/volume/qt/tests/test_volume_viewer.py b/glue_vispy_viewers/volume/qt/tests/test_volume_viewer.py
index 18e0849f..e2a7b8c3 100644
--- a/glue_vispy_viewers/volume/qt/tests/test_volume_viewer.py
+++ b/glue_vispy_viewers/volume/qt/tests/test_volume_viewer.py
@@ -1,6 +1,7 @@
import sys
import pytest
import numpy as np
+from string import ascii_lowercase
from glue.config import colormaps
from glue.core import DataCollection, Data
@@ -8,6 +9,7 @@
from glue.core.component import Component
from glue.core.link_helpers import LinkSame
+from ...layer_artist import DataProxy
from ..volume_viewer import VispyVolumeViewer
IS_WIN = sys.platform == 'win32'
@@ -19,7 +21,7 @@ def make_test_data(dimensions=(10, 10, 10)):
np.random.seed(12345)
- for letter in 'abc':
+ for letter in ascii_lowercase[:len(dimensions)]:
comp = Component(np.random.random(dimensions))
data.add_component(comp, letter)
@@ -255,3 +257,175 @@ def test_add_data_with_incompatible_subsets(tmpdir):
volume.add_data(data1)
ga.close()
+
+
+def test_add_higher_dimensional_layers():
+
+ # Check that we can load layers with > 3 dimensions
+
+ shape_4d = (10, 10, 10, 5)
+ data_4d = make_test_data(shape_4d)
+
+ shape_5d = (5, 5, 4, 4, 2)
+ data_5d = make_test_data(shape_5d)
+
+ dc = DataCollection([data_4d, data_5d])
+
+ ga = GlueApplication(dc)
+ ga.show()
+
+ # First add a 4D layer
+ volume = ga.new_data_viewer(VispyVolumeViewer)
+ volume.add_data(data_4d)
+
+ assert len(volume.layers) == 1
+
+ volume.state.x_att = data_4d.pixel_component_ids[0]
+ volume.state.y_att = data_4d.pixel_component_ids[2]
+ volume.state.z_att = data_4d.pixel_component_ids[3]
+
+ # Next add a 5D layer
+ volume2 = ga.new_data_viewer(VispyVolumeViewer)
+ volume2.add_data(data_5d)
+
+ assert len(volume2.layers) == 1
+
+ volume2.state.x_att = data_5d.pixel_component_ids[0]
+ volume2.state.y_att = data_5d.pixel_component_ids[4]
+ volume2.state.z_att = data_5d.pixel_component_ids[2]
+
+ ga.close()
+
+
+def test_3d_4d_layers():
+ shape_4d = (10, 10, 10, 5)
+ data_4d = make_test_data(shape_4d)
+
+ shape_3d = (15, 20, 25)
+ data_3d = make_test_data(shape_3d)
+
+ dc = DataCollection([data_4d, data_3d])
+
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[0], data_3d.pixel_component_ids[2]))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[1], data_3d.pixel_component_ids[0]))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[2], data_3d.pixel_component_ids[1]))
+
+ ga = GlueApplication(dc)
+ ga.show()
+
+ volume = ga.new_data_viewer(VispyVolumeViewer)
+ volume.add_data(data_4d)
+ volume.add_data(data_3d)
+ layer_3d = volume.layers[1]
+
+ volume.state.x_att = data_4d.pixel_component_ids[0]
+ volume.state.y_att = data_4d.pixel_component_ids[1]
+ volume.state.z_att = data_4d.pixel_component_ids[2]
+
+ assert layer_3d.enabled
+
+ volume.state.y_att = data_4d.pixel_component_ids[3]
+
+ assert not layer_3d.enabled
+
+ ga.close()
+
+
+def test_scatter_on_4d():
+ shape_4d = (10, 10, 10, 5)
+ data_4d = make_test_data(shape_4d)
+
+ data_scatter = Data(label="Scatter", x=[1, 2, 3], y=[2, 3, 4], z=[3, 4, 5])
+
+ dc = DataCollection([data_4d, data_scatter])
+
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[1], data_scatter.id['x']))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[2], data_scatter.id['y']))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[3], data_scatter.id['z']))
+
+ ga = GlueApplication(dc)
+ ga.show()
+
+ volume = ga.new_data_viewer(VispyVolumeViewer)
+ volume.add_data(data_4d)
+
+ volume.state.x_att = data_4d.pixel_component_ids[1]
+ volume.state.y_att = data_4d.pixel_component_ids[2]
+ volume.state.z_att = data_4d.pixel_component_ids[3]
+
+ volume.add_data(data_scatter)
+
+ layer_scatter = volume.layers[-1]
+
+ assert layer_scatter.enabled
+
+ volume.state.x_att = data_4d.pixel_component_ids[0]
+
+ assert not layer_scatter.enabled
+
+ volume.state.x_att = data_4d.pixel_component_ids[3]
+ volume.state.z_att = data_4d.pixel_component_ids[1]
+
+ assert layer_scatter.enabled
+
+ ga.close()
+
+
+def test_data_proxy_shape():
+ shape_4d = (5, 4, 2, 7)
+ data_4d = make_test_data(shape_4d)
+
+ shape_4d_2 = (6, 3, 7, 11)
+ data_4d_2 = make_test_data(shape_4d_2)
+
+ dc = DataCollection([data_4d, data_4d_2])
+
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[0], data_4d_2.pixel_component_ids[3]))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[1], data_4d_2.pixel_component_ids[0]))
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[2], data_4d_2.pixel_component_ids[1]))
+
+ ga = GlueApplication(dc)
+ ga.show()
+
+ volume = ga.new_data_viewer(VispyVolumeViewer)
+ volume.add_data(data_4d)
+ layer = volume.layers[0]
+
+ volume.state.x_att = data_4d.pixel_component_ids[0]
+ volume.state.y_att = data_4d.pixel_component_ids[1]
+ volume.state.z_att = data_4d.pixel_component_ids[2]
+
+ proxy = DataProxy(volume.state, layer.state)
+ assert proxy.shape == (2, 4, 5)
+
+ volume.state.x_att = data_4d.pixel_component_ids[3]
+ assert proxy.shape == (2, 4, 7)
+
+ volume.state.x_att = data_4d.pixel_component_ids[2]
+ volume.state.y_att = data_4d.pixel_component_ids[1]
+ volume.state.z_att = data_4d.pixel_component_ids[3]
+ assert proxy.shape == (7, 4, 2)
+
+ volume.add_data(data_4d_2)
+ layer2 = volume.layers[-1]
+
+ proxy2 = DataProxy(volume.state, layer2.state)
+ assert proxy2.shape == (0, 0, 0)
+
+ volume.state.x_att = data_4d.pixel_component_ids[0]
+ volume.state.y_att = data_4d.pixel_component_ids[1]
+ volume.state.z_att = data_4d.pixel_component_ids[2]
+ assert proxy2.shape == (0, 0, 0)
+
+ dc.add_link(LinkSame(data_4d.pixel_component_ids[3], data_4d_2.pixel_component_ids[2]))
+ assert proxy2.shape == (3, 6, 11)
+
+ volume.state.x_att = data_4d.pixel_component_ids[2]
+ volume.state.y_att = data_4d.pixel_component_ids[0]
+ volume.state.z_att = data_4d.pixel_component_ids[1]
+ assert proxy2.shape == (6, 11, 3)
+
+ volume.state.y_att = data_4d.pixel_component_ids[3]
+ assert proxy2.shape == (6, 7, 3)
+
+ ga.close()
diff --git a/glue_vispy_viewers/volume/viewer_state.py b/glue_vispy_viewers/volume/viewer_state.py
index 34365e97..d208c807 100644
--- a/glue_vispy_viewers/volume/viewer_state.py
+++ b/glue_vispy_viewers/volume/viewer_state.py
@@ -1,8 +1,11 @@
from glue.core.data import BaseData
-from echo import CallbackProperty, SelectionCallbackProperty
+from echo import CallbackProperty, SelectionCallbackProperty, delay_callback
+from numpy import argsort
from glue_vispy_viewers.common.viewer_state import Vispy3DViewerState
from glue.core.data_combo_helper import ManualDataComboHelper
+from glue.viewers.image.state import AggregateSlice
+
__all__ = ['Vispy3DVolumeViewerState']
@@ -10,6 +13,7 @@ class Vispy3DVolumeViewerState(Vispy3DViewerState):
downsample = CallbackProperty(True)
resolution = SelectionCallbackProperty(4)
+ slices = CallbackProperty(docstring='The current slice along all dimensions')
reference_data = SelectionCallbackProperty(docstring='The dataset that is used to define the '
'available pixel/world components, and '
'which defines the coordinate frame in '
@@ -20,8 +24,14 @@ def __init__(self, **kwargs):
super(Vispy3DVolumeViewerState, self).__init__()
self.ref_data_helper = ManualDataComboHelper(self, 'reference_data')
+ self.slices = ()
+
+ self.add_callback('layers', self._layers_changed, echo_old=True)
+ self.add_callback('x_att', self._on_xatt_changed, echo_old=True)
+ self.add_callback('y_att', self._on_yatt_changed, echo_old=True)
+ self.add_callback('z_att', self._on_zatt_changed, echo_old=True)
- self.add_callback('layers', self._layers_changed)
+ self._layers_changed(None, self.layers)
Vispy3DVolumeViewerState.resolution.set_choices(self, [2**i for i in range(4, 12)])
@@ -29,10 +39,12 @@ def __init__(self, **kwargs):
def _first_3d_data(self):
for layer_state in self.layers:
- if getattr(layer_state.layer, 'ndim', None) == 3:
+ if getattr(layer_state.layer, 'ndim', None) >= 3:
return layer_state.layer
- def _layers_changed(self, *args):
+ def _layers_changed(self, old_layers, new_layers):
+ if self.reference_data is not None and old_layers == new_layers:
+ return
self._update_combo_ref_data()
self._set_reference_data()
self._update_attributes()
@@ -42,10 +54,13 @@ def _update_combo_ref_data(self, *args):
def _set_reference_data(self, *args):
if self.reference_data is None:
+ self.slices = ()
for layer in self.layers:
if isinstance(layer.layer, BaseData):
self.reference_data = layer.layer
return
+ else:
+ self.slices = (0,) * self.reference_data.ndim
def _update_attributes(self, *args):
@@ -58,12 +73,42 @@ def _update_attributes(self, *args):
type(self).z_att.set_choices(self, [])
else:
+ pixel_ids = data.pixel_component_ids
+ with delay_callback(self, "x_att", "y_att", "z_att"):
+ type(self).x_att.set_choices(self, pixel_ids)
+ type(self).y_att.set_choices(self, pixel_ids)
+ type(self).z_att.set_choices(self, pixel_ids)
- z_cid, y_cid, x_cid = data.pixel_component_ids
+ def _set_up_attributes(self, *args):
+ data = self._first_3d_data()
+ if data is not None:
+ pixel_ids = data.pixel_component_ids
+ self.x_att = pixel_ids[2]
+ self.y_att = pixel_ids[1]
+ self.z_att = pixel_ids[0]
- type(self).x_att.set_choices(self, [x_cid])
- type(self).y_att.set_choices(self, [y_cid])
- type(self).z_att.set_choices(self, [z_cid])
+ @property
+ def numpy_slice_permutation(self):
+ if self.reference_data is None:
+ return None, None
+
+ slices = []
+ coord_att_axes = [self.x_att.axis, self.y_att.axis, self.z_att.axis]
+ for i in range(self.reference_data.ndim):
+ if i in coord_att_axes:
+ slices.append(slice(None))
+ else:
+ if isinstance(self.slices[i], AggregateSlice):
+ slices.append(self.slices[i].slice)
+ else:
+ slices.append(self.slices[i])
+
+ axes_order = argsort(coord_att_axes)
+ perm = [0] * len(axes_order)
+ for i, t in enumerate(axes_order):
+ perm[t] = i
+ perm = [perm[2], perm[1], perm[0]]
+ return slices, perm
@property
def clip_limits_relative(self):
@@ -73,10 +118,30 @@ def clip_limits_relative(self):
if data is None:
return [0., 1., 0., 1., 0., 1.]
else:
- nz, ny, nx = data.shape
+ nx = data.shape[self.x_att.axis]
+ ny = data.shape[self.y_att.axis]
+ nz = data.shape[self.z_att.axis]
return (self.x_min / nx,
self.x_max / nx,
self.y_min / ny,
self.y_max / ny,
self.z_min / nz,
self.z_max / nz)
+
+ def _on_xatt_changed(self, prev_att, new_att):
+ if self.y_att == new_att:
+ self.y_att = prev_att
+ elif self.z_att == new_att:
+ self.z_att = prev_att
+
+ def _on_yatt_changed(self, prev_att, new_att):
+ if self.x_att == new_att:
+ self.x_att = prev_att
+ elif self.z_att == new_att:
+ self.z_att = prev_att
+
+ def _on_zatt_changed(self, prev_att, new_att):
+ if self.x_att == new_att:
+ self.x_att = prev_att
+ elif self.y_att == new_att:
+ self.y_att = prev_att
diff --git a/glue_vispy_viewers/volume/volume_viewer.py b/glue_vispy_viewers/volume/volume_viewer.py
index f297f414..5f32042c 100644
--- a/glue_vispy_viewers/volume/volume_viewer.py
+++ b/glue_vispy_viewers/volume/volume_viewer.py
@@ -42,6 +42,9 @@ def setup_widget_and_callbacks(self):
self._vispy_widget.add_data_visual(multivol)
self._vispy_widget._multivol = multivol
+ self.state.add_callback('x_att', self._update_slice_transform)
+ self.state.add_callback('y_att', self._update_slice_transform)
+ self.state.add_callback('z_att', self._update_slice_transform)
self.state.add_callback('resolution', self._update_resolution)
self._update_resolution()
@@ -58,7 +61,7 @@ def _update_clip(self, force=False):
else:
self._vispy_widget._multivol.set_clip(False, [0, 0, 0, 1, 1, 1])
- def _update_slice_transform(self):
+ def _update_slice_transform(self, *args):
self._vispy_widget._multivol._update_slice_transform(self.state.x_min, self.state.x_max,
self.state.y_min, self.state.y_max,
self.state.z_min, self.state.z_max)
@@ -90,12 +93,12 @@ def add_data(self, data):
if first_layer_artist:
raise Exception("Can only add a scatter plot overlay once "
"a volume is present")
- elif data.ndim == 3:
+ elif data.ndim >= 3:
if not self._has_free_volume_layers:
self._warn_no_free_volume_layers()
return False
else:
- raise Exception("Data should be 1- or 3-dimensional ({0} dimensions "
+ raise Exception("Data should be 1- or >3-dimensional ({0} dimensions "
"found)".format(data.ndim))
added = super().add_data(data)
diff --git a/setup.cfg b/setup.cfg
index 611731d6..00761a92 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -38,11 +38,11 @@ test =
mock
pyqt =
qtpy
- glue-qt>=0.1.0
+ glue-qt>=0.4.0
PyQt6
pyside =
qtpy
- glue-qt>=0.1.0
+ glue-qt>=0.4.0
PySide6
jupyter =
glue-jupyter