From 2101f87a51a6d09558ae81025e796c9c517227e2 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 20 May 2024 19:13:52 -0400 Subject: [PATCH 01/12] Start work on adding exporter for vispy volume viewer. --- glue_plotly/__init__.py | 10 ++- glue_plotly/common/base_3d.py | 93 ++++++++++++++++++++++ glue_plotly/common/scatter3d.py | 91 +-------------------- glue_plotly/common/volume.py | 40 ++++++++++ glue_plotly/html_exporters/qt/__init__.py | 1 + glue_plotly/html_exporters/qt/scatter3d.py | 3 +- glue_plotly/html_exporters/qt/volume.py | 53 ++++++++++++ 7 files changed, 200 insertions(+), 91 deletions(-) create mode 100644 glue_plotly/common/base_3d.py create mode 100644 glue_plotly/common/volume.py create mode 100644 glue_plotly/html_exporters/qt/volume.py diff --git a/glue_plotly/__init__.py b/glue_plotly/__init__.py index 5c907a8..d8411c3 100644 --- a/glue_plotly/__init__.py +++ b/glue_plotly/__init__.py @@ -70,10 +70,18 @@ def setup_qt(): try: from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer + from glue_vispy_viewers.volume.volume_viewer import VispyVolumeViewer except ImportError: pass else: - VispyScatterViewer.subtools['save'] = VispyScatterViewer.subtools['save'] + ['save:plotly3d'] + VispyScatterViewer.subtools = { + **VispyScatterViewer.subtools, + "save": VispyScatterViewer.subtools["save"] + ["save:plotly3d"] + } + VispyVolumeViewer.subtools = { + **VispyVolumeViewer.subtools, + "save": VispyVolumeViewer.subtools["save"] + ["save:plotlyvolume"] + } def setup_jupyter(): diff --git a/glue_plotly/common/base_3d.py b/glue_plotly/common/base_3d.py new file mode 100644 index 0000000..6170646 --- /dev/null +++ b/glue_plotly/common/base_3d.py @@ -0,0 +1,93 @@ +from glue.config import settings +from glue_plotly.common import DEFAULT_FONT + + +def dimensions(viewer_state): + # when vispy viewer is in "native aspect ratio" mode, scale axes size by data + if viewer_state.native_aspect: + width = viewer_state.x_max - viewer_state.x_min + height = viewer_state.y_max - viewer_state.y_min + depth = viewer_state.z_max - viewer_state.z_min + + # otherwise, set all axes to be equal size + else: + width = 1200 # this 1200 size is arbitrary, could change to any width; just need to scale rest accordingly + height = 1200 + depth = 1200 + + return [width, height, depth] + + +def projection_type(viewer_state): + return "perspective" if viewer_state.perspective_view else "orthographic" + + +def axis(viewer_state, ax): + title = getattr(viewer_state, f'{ax}_att').label + range = [getattr(viewer_state, f'{ax}_min'), getattr(viewer_state, f'{ax}_max')] + return dict( + title=title, + titlefont=dict( + family=DEFAULT_FONT, + size=20, + color=settings.FOREGROUND_COLOR + ), + backgroundcolor=settings.BACKGROUND_COLOR, + showspikes=False, + linecolor=settings.FOREGROUND_COLOR, + tickcolor=settings.FOREGROUND_COLOR, + zeroline=False, + mirror=True, + ticks='outside', + showline=True, + showgrid=False, + showticklabels=True, + tickfont=dict( + family=DEFAULT_FONT, + size=12, + color=settings.FOREGROUND_COLOR), + range=range, + type='linear', + rangemode='normal', + visible=viewer_state.visible_axes + ) + + +def bbox_mask(viewer_state, x, y, z): + return (x >= viewer_state.x_min) & (x <= viewer_state.x_max) & \ + (y >= viewer_state.y_min) & (y <= viewer_state.y_max) & \ + (z >= viewer_state.z_min) & (z <= viewer_state.z_max) + + +def clipped_data(viewer_state, layer_state): + x = layer_state.layer[viewer_state.x_att] + y = layer_state.layer[viewer_state.y_att] + z = layer_state.layer[viewer_state.z_att] + + # Plotly doesn't show anything outside the bounding box + mask = bbox_mask(viewer_state, x, y, z) + + return x[mask], y[mask], z[mask], mask + + +def layout_config(viewer_state): + width, height, depth = dimensions(viewer_state) + return dict( + margin=dict(r=50, l=50, b=50, t=50), # noqa + width=1200, + paper_bgcolor=settings.BACKGROUND_COLOR, + scene=dict( + xaxis=axis(viewer_state, 'x'), + yaxis=axis(viewer_state, 'y'), + zaxis=axis(viewer_state, 'z'), + camera=dict( + projection=dict( + type=projection_type(viewer_state) + ) + ), + aspectratio=dict(x=1 * viewer_state.x_stretch, + y=height / width * viewer_state.y_stretch, + z=depth / width * viewer_state.z_stretch), + aspectmode='manual' + ) + ) diff --git a/glue_plotly/common/scatter3d.py b/glue_plotly/common/scatter3d.py index 83f7da0..e0d1747 100644 --- a/glue_plotly/common/scatter3d.py +++ b/glue_plotly/common/scatter3d.py @@ -1,5 +1,4 @@ import numpy as np -from glue.config import settings from glue.core import BaseData from glue.utils import ensure_numerical from matplotlib.colors import to_rgb @@ -7,71 +6,8 @@ from plotly.graph_objs import Cone, Scatter3d from uuid import uuid4 -from glue_plotly.common import DEFAULT_FONT, color_info - - -def dimensions(viewer_state): - # when vispy viewer is in "native aspect ratio" mode, scale axes size by data - if viewer_state.native_aspect: - width = viewer_state.x_max - viewer_state.x_min - height = viewer_state.y_max - viewer_state.y_min - depth = viewer_state.z_max - viewer_state.z_min - - # otherwise, set all axes to be equal size - else: - width = 1200 # this 1200 size is arbitrary, could change to any width; just need to scale rest accordingly - height = 1200 - depth = 1200 - - return [width, height, depth] - - -def projection_type(viewer_state): - return "perspective" if viewer_state.perspective_view else "orthographic" - - -def axis(viewer_state, ax): - title = getattr(viewer_state, f'{ax}_att').label - range = [getattr(viewer_state, f'{ax}_min'), getattr(viewer_state, f'{ax}_max')] - return dict( - title=title, - titlefont=dict( - family=DEFAULT_FONT, - size=20, - color=settings.FOREGROUND_COLOR - ), - backgroundcolor=settings.BACKGROUND_COLOR, - showspikes=False, - linecolor=settings.FOREGROUND_COLOR, - tickcolor=settings.FOREGROUND_COLOR, - zeroline=False, - mirror=True, - ticks='outside', - showline=True, - showgrid=False, - showticklabels=True, - tickfont=dict( - family=DEFAULT_FONT, - size=12, - color=settings.FOREGROUND_COLOR), - range=range, - type='linear', - rangemode='normal', - visible=viewer_state.visible_axes - ) - - -def clipped_data(viewer_state, layer_state): - x = layer_state.layer[viewer_state.x_att] - y = layer_state.layer[viewer_state.y_att] - z = layer_state.layer[viewer_state.z_att] - - # Plotly doesn't show anything outside the bounding box - mask = (x >= viewer_state.x_min) & (x <= viewer_state.x_max) & \ - (y >= viewer_state.y_min) & (y <= viewer_state.y_max) & \ - (z >= viewer_state.z_min) & (z <= viewer_state.z_max) - - return x[mask], y[mask], z[mask], mask +from glue_plotly.common import color_info +from glue_plotly.common.base_3d import clipped_data def size_info(layer_state, mask): @@ -157,29 +93,6 @@ def error_bar_info(layer_state, mask): return errs -def layout_config(viewer_state): - width, height, depth = dimensions(viewer_state) - return dict( - margin=dict(r=50, l=50, b=50, t=50), # noqa - width=1200, - paper_bgcolor=settings.BACKGROUND_COLOR, - scene=dict( - xaxis=axis(viewer_state, 'x'), - yaxis=axis(viewer_state, 'y'), - zaxis=axis(viewer_state, 'z'), - camera=dict( - projection=dict( - type=projection_type(viewer_state) - ) - ), - aspectratio=dict(x=1 * viewer_state.x_stretch, - y=height / width * viewer_state.y_stretch, - z=depth / width * viewer_state.z_stretch), - aspectmode='manual' - ) - ) - - def traces_for_layer(viewer_state, layer_state, hover_data=None, add_data_label=True): x, y, z, mask = clipped_data(viewer_state, layer_state) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py new file mode 100644 index 0000000..1b10b78 --- /dev/null +++ b/glue_plotly/common/volume.py @@ -0,0 +1,40 @@ +from glue_plotly.common import color_info +from glue_plotly.common.base_3d import bbox_mask + +import plotly.graph_objects as go + + +def positions(bounds): + return [ + [(b[1] - b[0]) * (i + 0.5) / b[2] for i in range(b[2])] for b in bounds + ] + + +def values(data_proxy, bounds): + return data_proxy.compute_fixed_resolution_buffer(bounds) + + +def colorscale(layer_state): + color = color_info(layer_state) + return [(0, color), (1, color)] + + +def traces_for_layer(layer, bounds): + + xyz = positions(bounds) + state = layer.state + mask = bbox_mask(state, *xyz) + clipped_xyz = [c[mask] for c in xyz] + clipped_values = values(layer._data_proxy, bounds)[mask] + return [go.Volume( + x=clipped_xyz[0], + y=clipped_xyz[1], + z=clipped_xyz[2], + value=clipped_values, + isomin=0, + isomax=1, + opacity=state.alpha, + surface_count=15 + )] + + diff --git a/glue_plotly/html_exporters/qt/__init__.py b/glue_plotly/html_exporters/qt/__init__.py index 880255c..08a907b 100644 --- a/glue_plotly/html_exporters/qt/__init__.py +++ b/glue_plotly/html_exporters/qt/__init__.py @@ -5,3 +5,4 @@ from . import profile # noqa from . import table # noqa from . import dendrogram # noqa +from . import volume # noqa diff --git a/glue_plotly/html_exporters/qt/scatter3d.py b/glue_plotly/html_exporters/qt/scatter3d.py index 8faffa2..f0b06e6 100644 --- a/glue_plotly/html_exporters/qt/scatter3d.py +++ b/glue_plotly/html_exporters/qt/scatter3d.py @@ -14,7 +14,8 @@ from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO from glue_plotly.common import data_count, layers_to_export -from glue_plotly.common.scatter3d import layout_config, traces_for_layer +from glue_plotly.common.base_3d import layout_config +from glue_plotly.common.scatter3d import traces_for_layer from ... import save_hover, export_dialog from plotly.offline import plot diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py new file mode 100644 index 0000000..e4cb01d --- /dev/null +++ b/glue_plotly/html_exporters/qt/volume.py @@ -0,0 +1,53 @@ +from qtpy import compat + +from glue.config import viewer_tool +from glue_plotly.common.common import data_count +from glue_qt.utils import messagebox_on_error +from glue_qt.utils.threading import Worker +from glue_qt.viewers.common.tool import Tool + +from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog +from glue_plotly.common import layers_to_export +from glue_plotly.common.base_3d import layout_config +from glue_plotly.common.volume import traces_for_layer + +from plotly.offline import plot + + +@viewer_tool +class PlotlyVolumeStaticExport(Tool): + icon = PLOTLY_LOGO + tool_id = 'save:plotlyvolume' + action_text = 'Save Plotly HTML page' + tool_tip = 'Save Plotly HTML page' + + @messagebox_on_error(PLOTLY_ERROR_MESSAGE) + def _export_to_plotly(self, filename): + + config = layout_config(self.viewer.state) + layout = go.Layout(**config) + fig = go.Figure(layout=layout) + + layers = layers_to_export(self.viewer) + bounds = self.viewer._vispy_widget._multivol._data_bounds + for layer in layers: + traces = traces_for_layer(layer, bounds) + + for trace in traces: + fig.add_trace(trace) + + plot(fig, filename=filename, auto_open=False) + + def activate(self): + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return + + worker = Worker(self._export_to_plotly, filename) + exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + worker.result.connect(exp_dialog.close) + worker.error.connect(exp_dialog.close) + worker.start() + exp_dialog.exec_() From 466cc532762732a51930c702823205d18eda2a94 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 20 May 2024 22:03:15 -0400 Subject: [PATCH 02/12] More work on volume exporter. Now have something that's kinda working. --- glue_plotly/common/common.py | 2 +- glue_plotly/common/volume.py | 52 ++++++++++++++++++++----- glue_plotly/html_exporters/qt/volume.py | 30 +++++++------- glue_plotly/utils.py | 4 ++ 4 files changed, 64 insertions(+), 24 deletions(-) diff --git a/glue_plotly/common/common.py b/glue_plotly/common/common.py index 21acd54..d47d70c 100644 --- a/glue_plotly/common/common.py +++ b/glue_plotly/common/common.py @@ -108,7 +108,7 @@ def sanitize(*arrays): def fixed_color(layer_state): layer_color = layer_state.color if layer_color == '0.35' or layer_color == '0.75': - layer_color = 'gray' + layer_color = '#808080' if is_rgba_hex(layer_color): layer_color = rgba_hex_to_rgb_hex(layer_color) return layer_color diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index 1b10b78..1b38e67 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -1,3 +1,7 @@ +from numpy import array, linspace, meshgrid, nan_to_num, nanmin + +from glue.core.subset_group import GroupedSubset + from glue_plotly.common import color_info from glue_plotly.common.base_3d import bbox_mask @@ -5,25 +9,51 @@ def positions(bounds): - return [ - [(b[1] - b[0]) * (i + 0.5) / b[2] for i in range(b[2])] for b in bounds - ] + coord_arrays = [linspace(b[0], b[1], num=b[2]) for b in reversed(bounds)] + return meshgrid(*coord_arrays) def values(data_proxy, bounds): - return data_proxy.compute_fixed_resolution_buffer(bounds) + values = data_proxy.compute_fixed_resolution_buffer(bounds) + min_value = nanmin(values) + replacement = min_value - 1 + replaced = nan_to_num(values, replacement) + return replaced def colorscale(layer_state): color = color_info(layer_state) - return [(0, color), (1, color)] + return [(0, "#000000"), (1, color)] + + +def opacity_scale(layer_state): + return [[0, 0], [1, 1]] + + +def isomin_for_layer(viewer_state, layer_state): + if isinstance(layer_state.layer, GroupedSubset): + for viewer_layer in viewer_state.layers: + if viewer_layer.layer is layer_state.layer.data: + return viewer_layer.vmin + + return layer_state.vmin + + +def isomax_for_layer(viewer_state, layer_state): + if isinstance(layer_state.layer, GroupedSubset): + for viewer_layer in viewer_state.layers: + if viewer_layer.layer is layer_state.layer.data: + return viewer_layer.vmax + + return layer_state.vmax -def traces_for_layer(layer, bounds): +def traces_for_layer(viewer_state, layer, bounds): xyz = positions(bounds) + print([c.shape for c in xyz]) state = layer.state - mask = bbox_mask(state, *xyz) + mask = bbox_mask(viewer_state, *xyz) clipped_xyz = [c[mask] for c in xyz] clipped_values = values(layer._data_proxy, bounds)[mask] return [go.Volume( @@ -31,10 +61,12 @@ def traces_for_layer(layer, bounds): y=clipped_xyz[1], z=clipped_xyz[2], value=clipped_values, - isomin=0, - isomax=1, + colorscale=colorscale(state), + opacityscale=opacity_scale(state), + isomin=isomin_for_layer(viewer_state, state), + isomax=isomax_for_layer(viewer_state, state), opacity=state.alpha, - surface_count=15 + surface_count=5 )] diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index e4cb01d..62b4cab 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -12,6 +12,7 @@ from glue_plotly.common.volume import traces_for_layer from plotly.offline import plot +import plotly.graph_objs as go @viewer_tool @@ -23,6 +24,15 @@ class PlotlyVolumeStaticExport(Tool): @messagebox_on_error(PLOTLY_ERROR_MESSAGE) def _export_to_plotly(self, filename): + pass + + + def activate(self): + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return config = layout_config(self.viewer.state) layout = go.Layout(**config) @@ -31,23 +41,17 @@ def _export_to_plotly(self, filename): layers = layers_to_export(self.viewer) bounds = self.viewer._vispy_widget._multivol._data_bounds for layer in layers: - traces = traces_for_layer(layer, bounds) + traces = traces_for_layer(self.viewer.state, layer, bounds) for trace in traces: fig.add_trace(trace) plot(fig, filename=filename, auto_open=False) - def activate(self): - - filename, _ = compat.getsavefilename( - parent=self.viewer, basedir="plot.html") - if not filename: - return + # worker = Worker(self._export_to_plotly, filename) + # exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + # worker.result.connect(exp_dialog.close) + # worker.error.connect(exp_dialog.close) + # worker.start() + # exp_dialog.exec_() - worker = Worker(self._export_to_plotly, filename) - exp_dialog = export_dialog.ExportDialog(parent=self.viewer) - worker.result.connect(exp_dialog.close) - worker.error.connect(exp_dialog.close) - worker.start() - exp_dialog.exec_() diff --git a/glue_plotly/utils.py b/glue_plotly/utils.py index 00ab11e..8abebdd 100644 --- a/glue_plotly/utils.py +++ b/glue_plotly/utils.py @@ -59,3 +59,7 @@ def is_rgb_hex(color): def rgba_hex_to_rgb_hex(color): return color[:-2] + + +def rgb_hex_to_rgba_hex(color, opacity=1): + return f"{color}{format(opacity, '02x')}" From adc6cb6fc1e9f3c6234f618c22461509f651fb38 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 22 May 2024 01:11:17 -0400 Subject: [PATCH 03/12] Update color utilities and correct orientation of fixed resolution buffer data. --- glue_plotly/common/volume.py | 11 ++++++----- glue_plotly/utils.py | 29 ++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index 1b38e67..e252d25 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -1,3 +1,4 @@ +from glue_plotly.utils import rgba_components from numpy import array, linspace, meshgrid, nan_to_num, nanmin from glue.core.subset_group import GroupedSubset @@ -15,15 +16,18 @@ def positions(bounds): def values(data_proxy, bounds): values = data_proxy.compute_fixed_resolution_buffer(bounds) + values = values.transpose(2, 1, 0) min_value = nanmin(values) replacement = min_value - 1 replaced = nan_to_num(values, replacement) return replaced -def colorscale(layer_state): +def colorscale(layer_state, size=10): color = color_info(layer_state) - return [(0, "#000000"), (1, color)] + r, g, b, a = rgba_components(color) + fractions = [i / size for i in range(size + 1)] + return [f"rgba({f*r},{f*g},{f*b},{f*a})" for f in fractions] def opacity_scale(layer_state): @@ -51,7 +55,6 @@ def isomax_for_layer(viewer_state, layer_state): def traces_for_layer(viewer_state, layer, bounds): xyz = positions(bounds) - print([c.shape for c in xyz]) state = layer.state mask = bbox_mask(viewer_state, *xyz) clipped_xyz = [c[mask] for c in xyz] @@ -68,5 +71,3 @@ def traces_for_layer(viewer_state, layer, bounds): opacity=state.alpha, surface_count=5 )] - - diff --git a/glue_plotly/utils.py b/glue_plotly/utils.py index 8abebdd..b735977 100644 --- a/glue_plotly/utils.py +++ b/glue_plotly/utils.py @@ -46,7 +46,7 @@ def rgba_string_to_values(rgba_str): if not m or len(m.groups()) != 4: raise ValueError("Invalid RGBA expression") r, g, b, a = m.groups() - return [r, g, b, a] + return [int(t) for t in (r, g, b, a)] def is_rgba_hex(color): @@ -54,12 +54,35 @@ def is_rgba_hex(color): def is_rgb_hex(color): - return color.starswith("#") and len(color) == 7 + return color.startswith("#") and len(color) == 7 def rgba_hex_to_rgb_hex(color): return color[:-2] +def hex_string(number): + return format(number, '02x') + + def rgb_hex_to_rgba_hex(color, opacity=1): - return f"{color}{format(opacity, '02x')}" + return f"{color}{hex_string(opacity)}" + + +def hex_to_components(color): + return [int(color[idx:idx+2], 16) for idx in range(1, len(color), 2)] + + +def rgba_components(color): + if is_rgb_hex(color): + color = rgb_hex_to_rgba_hex(color) + if is_rgba_hex(color): + return hex_to_components(color) + else: + return rgba_string_to_values(color) + + +def components_to_hex(r, g, b, a=None): + components = [hex_string(t) for t in (r, g, b, a) if t is not None] + return f"#{''.join(components)}" + From cf0b92a93fe74d636d099004ecbe84dd52691edb Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 22 May 2024 01:56:09 -0400 Subject: [PATCH 04/12] More orientation tweaking and add some comments. --- glue_plotly/common/volume.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index e252d25..31fb85b 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -10,13 +10,16 @@ def positions(bounds): + # The viewer bounds are in reverse order coord_arrays = [linspace(b[0], b[1], num=b[2]) for b in reversed(bounds)] return meshgrid(*coord_arrays) def values(data_proxy, bounds): values = data_proxy.compute_fixed_resolution_buffer(bounds) - values = values.transpose(2, 1, 0) + # This accounts for two transformations: the fact that the viewer bounds and in reverse order, + # plus a need to change R -> L handedness for Plotly + values = values.transpose(1, 2, 0) min_value = nanmin(values) replacement = min_value - 1 replaced = nan_to_num(values, replacement) @@ -26,7 +29,7 @@ def values(data_proxy, bounds): def colorscale(layer_state, size=10): color = color_info(layer_state) r, g, b, a = rgba_components(color) - fractions = [i / size for i in range(size + 1)] + fractions = [(i / size) ** 0.25 for i in range(size + 1)] return [f"rgba({f*r},{f*g},{f*b},{f*a})" for f in fractions] From b62d2918c7e88046d52b715848d4ccc949a7c40d Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 22 May 2024 15:09:35 -0400 Subject: [PATCH 05/12] Add dialog for selecting layer options for volume export. --- glue_plotly/common/volume.py | 6 +- glue_plotly/html_exporters/qt/volume.py | 13 +++- glue_plotly/volume_options.py | 52 +++++++++++++++ glue_plotly/volume_options.ui | 86 +++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 glue_plotly/volume_options.py create mode 100644 glue_plotly/volume_options.ui diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index 31fb85b..ed69f39 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -17,7 +17,7 @@ def positions(bounds): def values(data_proxy, bounds): values = data_proxy.compute_fixed_resolution_buffer(bounds) - # This accounts for two transformations: the fact that the viewer bounds and in reverse order, + # This accounts for two transformations: the fact that the viewer bounds are in reverse order, # plus a need to change R -> L handedness for Plotly values = values.transpose(1, 2, 0) min_value = nanmin(values) @@ -55,7 +55,7 @@ def isomax_for_layer(viewer_state, layer_state): return layer_state.vmax -def traces_for_layer(viewer_state, layer, bounds): +def traces_for_layer(viewer_state, layer, bounds, isosurface_count=5): xyz = positions(bounds) state = layer.state @@ -72,5 +72,5 @@ def traces_for_layer(viewer_state, layer, bounds): isomin=isomin_for_layer(viewer_state, state), isomax=isomax_for_layer(viewer_state, state), opacity=state.alpha, - surface_count=5 + surface_count=isosurface_count )] diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index 62b4cab..109015b 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -1,4 +1,5 @@ from qtpy import compat +from qtpy.QtWidgets import QDialog from glue.config import viewer_tool from glue_plotly.common.common import data_count @@ -6,7 +7,7 @@ from glue_qt.utils.threading import Worker from glue_qt.viewers.common.tool import Tool -from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog +from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog, volume_options from glue_plotly.common import layers_to_export from glue_plotly.common.base_3d import layout_config from glue_plotly.common.volume import traces_for_layer @@ -29,6 +30,11 @@ def _export_to_plotly(self, filename): def activate(self): + dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) + result = dialog.exec_() + if result == QDialog.Rejected: + return + filename, _ = compat.getsavefilename( parent=self.viewer, basedir="plot.html") if not filename: @@ -41,7 +47,10 @@ def activate(self): layers = layers_to_export(self.viewer) bounds = self.viewer._vispy_widget._multivol._data_bounds for layer in layers: - traces = traces_for_layer(self.viewer.state, layer, bounds) + options = dialog.state_dictionary[layer.layer.label] + count = options.get("isosurface_count", 5) + traces = traces_for_layer(self.viewer.state, layer, bounds, + isosurface_count=count) for trace in traces: fig.add_trace(trace) diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py new file mode 100644 index 0000000..1b5c661 --- /dev/null +++ b/glue_plotly/volume_options.py @@ -0,0 +1,52 @@ +import os + +from qtpy.QtWidgets import QDialog + +from echo import CallbackProperty, SelectionCallbackProperty +from echo.qt import autoconnect_callbacks_to_qt + +from glue.core.data_combo_helper import ComboHelper +from glue.core.state_objects import State +from glue_qt.utils import load_ui + + +class VolumeDialogState(State): + + layer = SelectionCallbackProperty() + isosurface_count = CallbackProperty(5) + + def __init__(self, layers): + super(VolumeDialogState, self).__init__() + + self.layers = layers + + self.layer_helper = ComboHelper(self, 'layer') + self.layer_helper.choices = [state.layer.label for state in self.layers] + + +class VolumeOptionsDialog(QDialog): + + def __init__(self, parent=None, viewer=None): + + super(VolumeOptionsDialog, self).__init__(parent=parent) + + self.viewer = viewer + layers = [layer for layer in self.viewer.layers if layer.enabled and layer.state.visible] + self.state = VolumeDialogState(layers) + self.ui = load_ui('volume_options.ui', self, directory=os.path.dirname(__file__)) + self._connections = autoconnect_callbacks_to_qt(self.state, self.ui) + + self.state_dictionary = { layer.layer.label: {} for layer in layers } + + self.ui.button_cancel.clicked.connect(self.reject) + self.ui.button_ok.clicked.connect(self.accept) + + self.state.add_callback('layer', self._on_layer_change) + self.state.add_callback('isosurface_count', self._on_isosurface_count_change) + + def _on_layer_change(self, layer): + count = self.state_dictionary[self.state.layer].get("isosurface_count", 5) + self.ui.valuetext_isosurface_count.setText(int(count)) + + def _on_isosurface_count_change(self, count): + self.state_dictionary[self.state.layer]["isosurface_count"] = int(count) diff --git a/glue_plotly/volume_options.ui b/glue_plotly/volume_options.ui new file mode 100644 index 0000000..6af8f73 --- /dev/null +++ b/glue_plotly/volume_options.ui @@ -0,0 +1,86 @@ + + + Dialog + + + + 0 + 0 + 318 + 411 + + + + Export 3D Volume + + + + + + The Plotly volume exporter uses isosurfaces to represent volumes. The number of isosurfaces used can be set differently for each layer. + + + true + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 110 + 20 + + + + + + + + Cancel + + + false + + + + + + + Export + + + true + + + + + + + + + + Isosurface count: + + + + + + + + From 3056ed76375fd2777ec6eae16b5e0b64e0e3d8f3 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Thu, 23 May 2024 01:36:59 -0400 Subject: [PATCH 06/12] Allow exporting volume subsets. --- glue_plotly/common/volume.py | 75 ++++++++++++++++++------- glue_plotly/html_exporters/qt/volume.py | 2 +- glue_plotly/volume_options.py | 2 +- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index ed69f39..efeeb0a 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -1,6 +1,7 @@ from glue_plotly.utils import rgba_components from numpy import array, linspace, meshgrid, nan_to_num, nanmin +from glue.core.state_objects import State from glue.core.subset_group import GroupedSubset from glue_plotly.common import color_info @@ -15,8 +16,37 @@ def positions(bounds): return meshgrid(*coord_arrays) -def values(data_proxy, bounds): - values = data_proxy.compute_fixed_resolution_buffer(bounds) +def parent_layer(viewer_or_state, subset): + data = subset.data + for layer in viewer_or_state.layers: + if layer.layer is data: + return layer + return None + + +# TODO: Can we write this function entirely in terms of the viewer and layer states? +# We probably can, if we don't on the data proxies +def values(viewer, layer, bounds, precomputed=None): + subset_layer = isinstance(layer.layer, GroupedSubset) + parent = layer.layer.data if subset_layer else layer.layer + parent_label = parent.label + parent_artist = parent_layer(viewer, layer.layer) if subset_layer else layer + if precomputed is not None and parent_label in precomputed: + data = precomputed[parent_label] + elif parent_artist is not None: + data = parent_artist._data_proxy.compute_fixed_resolution_buffer(bounds) + else: + data = parent.compute_fixed_resolution_buffer( + target_data=viewer.state.reference_data, + bounds=bounds, + target_cid=layer.state.attribute) + + if subset_layer: + subcube = layer._data_proxy.compute_fixed_resolution_buffer(bounds) + values = subcube * data + else: + values = data + # This accounts for two transformations: the fact that the viewer bounds are in reverse order, # plus a need to change R -> L handedness for Plotly values = values.transpose(1, 2, 0) @@ -37,31 +67,35 @@ def opacity_scale(layer_state): return [[0, 0], [1, 1]] -def isomin_for_layer(viewer_state, layer_state): - if isinstance(layer_state.layer, GroupedSubset): - for viewer_layer in viewer_state.layers: - if viewer_layer.layer is layer_state.layer.data: - return viewer_layer.vmin +def isomin_for_layer(viewer_or_state, layer): + if isinstance(layer.layer, GroupedSubset): + parent = parent_layer(viewer_or_state, layer.layer) + if parent is not None: + parent_state = parent if isinstance(parent, State) else parent.state + return parent_state.vmin - return layer_state.vmin + state = layer if isinstance(layer, State) else layer + return state.vmin -def isomax_for_layer(viewer_state, layer_state): - if isinstance(layer_state.layer, GroupedSubset): - for viewer_layer in viewer_state.layers: - if viewer_layer.layer is layer_state.layer.data: - return viewer_layer.vmax +def isomax_for_layer(viewer_or_state, layer): + if isinstance(layer.layer, GroupedSubset): + parent = parent_layer(viewer_or_state, layer.layer) + if parent is not None: + parent_state = parent if isinstance(parent, State) else parent.state + return parent_state.vmax - return layer_state.vmax + state = layer if isinstance(layer, State) else layer + return state.vmax -def traces_for_layer(viewer_state, layer, bounds, isosurface_count=5): +def traces_for_layer(viewer, layer, bounds, isosurface_count=5): xyz = positions(bounds) state = layer.state - mask = bbox_mask(viewer_state, *xyz) + mask = bbox_mask(viewer.state, *xyz) clipped_xyz = [c[mask] for c in xyz] - clipped_values = values(layer._data_proxy, bounds)[mask] + clipped_values = values(viewer, layer, bounds)[mask] return [go.Volume( x=clipped_xyz[0], y=clipped_xyz[1], @@ -69,8 +103,9 @@ def traces_for_layer(viewer_state, layer, bounds, isosurface_count=5): value=clipped_values, colorscale=colorscale(state), opacityscale=opacity_scale(state), - isomin=isomin_for_layer(viewer_state, state), - isomax=isomax_for_layer(viewer_state, state), + isomin=isomin_for_layer(viewer.state, state), + isomax=isomax_for_layer(viewer.state, state), opacity=state.alpha, - surface_count=isosurface_count + surface_count=isosurface_count, + showscale=False )] diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index 109015b..636b3e1 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -49,7 +49,7 @@ def activate(self): for layer in layers: options = dialog.state_dictionary[layer.layer.label] count = options.get("isosurface_count", 5) - traces = traces_for_layer(self.viewer.state, layer, bounds, + traces = traces_for_layer(self.viewer, layer, bounds, isosurface_count=count) for trace in traces: diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py index 1b5c661..f3779a2 100644 --- a/glue_plotly/volume_options.py +++ b/glue_plotly/volume_options.py @@ -46,7 +46,7 @@ def __init__(self, parent=None, viewer=None): def _on_layer_change(self, layer): count = self.state_dictionary[self.state.layer].get("isosurface_count", 5) - self.ui.valuetext_isosurface_count.setText(int(count)) + self.ui.valuetext_isosurface_count.setText(str(count)) def _on_isosurface_count_change(self, count): self.state_dictionary[self.state.layer]["isosurface_count"] = int(count) From 46aa9846fa0e88ca67b4405481582b904810a2a4 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Thu, 23 May 2024 01:50:36 -0400 Subject: [PATCH 07/12] Get values for export using viewer and layer states. --- glue_plotly/common/volume.py | 39 ++++++++++++------------- glue_plotly/html_exporters/qt/volume.py | 2 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index efeeb0a..2f80b12 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -24,25 +24,25 @@ def parent_layer(viewer_or_state, subset): return None -# TODO: Can we write this function entirely in terms of the viewer and layer states? -# We probably can, if we don't on the data proxies -def values(viewer, layer, bounds, precomputed=None): - subset_layer = isinstance(layer.layer, GroupedSubset) - parent = layer.layer.data if subset_layer else layer.layer +def values(viewer_state, layer_state, bounds, precomputed=None): + subset_layer = isinstance(layer_state.layer, GroupedSubset) + parent = layer_state.layer.data if subset_layer else layer_state.layer parent_label = parent.label - parent_artist = parent_layer(viewer, layer.layer) if subset_layer else layer if precomputed is not None and parent_label in precomputed: data = precomputed[parent_label] - elif parent_artist is not None: - data = parent_artist._data_proxy.compute_fixed_resolution_buffer(bounds) else: data = parent.compute_fixed_resolution_buffer( - target_data=viewer.state.reference_data, + target_data=viewer_state.reference_data, bounds=bounds, - target_cid=layer.state.attribute) + target_cid=layer_state.attribute + ) if subset_layer: - subcube = layer._data_proxy.compute_fixed_resolution_buffer(bounds) + subcube = parent.compute_fixed_resolution_buffer( + target_data=viewer_state.reference_data, + bounds=bounds, + subset_state=layer_state.layer.subset_state + ) values = subcube * data else: values = data @@ -89,23 +89,22 @@ def isomax_for_layer(viewer_or_state, layer): return state.vmax -def traces_for_layer(viewer, layer, bounds, isosurface_count=5): +def traces_for_layer(viewer_state, layer_state, bounds, isosurface_count=5): xyz = positions(bounds) - state = layer.state - mask = bbox_mask(viewer.state, *xyz) + mask = bbox_mask(viewer_state, *xyz) clipped_xyz = [c[mask] for c in xyz] - clipped_values = values(viewer, layer, bounds)[mask] + clipped_values = values(viewer_state, layer_state, bounds)[mask] return [go.Volume( x=clipped_xyz[0], y=clipped_xyz[1], z=clipped_xyz[2], value=clipped_values, - colorscale=colorscale(state), - opacityscale=opacity_scale(state), - isomin=isomin_for_layer(viewer.state, state), - isomax=isomax_for_layer(viewer.state, state), - opacity=state.alpha, + colorscale=colorscale(layer_state), + opacityscale=opacity_scale(layer_state), + isomin=isomin_for_layer(viewer_state, layer_state), + isomax=isomax_for_layer(viewer_state, layer_state), + opacity=layer_state.alpha, surface_count=isosurface_count, showscale=False )] diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index 636b3e1..caad001 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -49,7 +49,7 @@ def activate(self): for layer in layers: options = dialog.state_dictionary[layer.layer.label] count = options.get("isosurface_count", 5) - traces = traces_for_layer(self.viewer, layer, bounds, + traces = traces_for_layer(self.viewer.state, layer.state, bounds, isosurface_count=count) for trace in traces: From 993bec40685c95e2a6b50530451675ca577b448b Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Thu, 23 May 2024 14:35:21 -0400 Subject: [PATCH 08/12] Restore loading dialog and set camera up direction from vispy. --- glue_plotly/common/base_3d.py | 16 ++++++++- glue_plotly/common/volume.py | 15 +++++++-- glue_plotly/html_exporters/qt/volume.py | 43 ++++++++++++------------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/glue_plotly/common/base_3d.py b/glue_plotly/common/base_3d.py index 6170646..76ebe3e 100644 --- a/glue_plotly/common/base_3d.py +++ b/glue_plotly/common/base_3d.py @@ -1,3 +1,5 @@ +import re + from glue.config import settings from glue_plotly.common import DEFAULT_FONT @@ -70,6 +72,16 @@ def clipped_data(viewer_state, layer_state): return x[mask], y[mask], z[mask], mask +def plotly_up_from_vispy(vispy_up): + regex = re.compile("(\+|-)(x|y|z)") + up = {"x": 0, "y": 0, "z": 0} + m = regex.match(vispy_up) + if m is not None and len(m.groups()) == 2: + sign = 1 if m.group(1) == "+" else -1 + up[m.group(2)] = sign + return up + + def layout_config(viewer_state): width, height, depth = dimensions(viewer_state) return dict( @@ -83,7 +95,9 @@ def layout_config(viewer_state): camera=dict( projection=dict( type=projection_type(viewer_state) - ) + ), + # Currently there's no way to change this in glue + up=plotly_up_from_vispy("+z") ), aspectratio=dict(x=1 * viewer_state.x_stretch, y=height / width * viewer_state.y_stretch, diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index 2f80b12..ab43dd7 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -1,6 +1,7 @@ from glue_plotly.utils import rgba_components -from numpy import array, linspace, meshgrid, nan_to_num, nanmin +from numpy import linspace, meshgrid, nan_to_num, nanmin +from glue.core import BaseData from glue.core.state_objects import State from glue.core.subset_group import GroupedSubset @@ -76,7 +77,7 @@ def isomin_for_layer(viewer_or_state, layer): state = layer if isinstance(layer, State) else layer return state.vmin - + def isomax_for_layer(viewer_or_state, layer): if isinstance(layer.layer, GroupedSubset): @@ -89,13 +90,21 @@ def isomax_for_layer(viewer_or_state, layer): return state.vmax -def traces_for_layer(viewer_state, layer_state, bounds, isosurface_count=5): +def traces_for_layer(viewer_state, layer_state, bounds, + isosurface_count=5, add_data_label=True): xyz = positions(bounds) mask = bbox_mask(viewer_state, *xyz) clipped_xyz = [c[mask] for c in xyz] clipped_values = values(viewer_state, layer_state, bounds)[mask] + name = layer_state.layer.label + if add_data_label and not isinstance(layer_state.layer, BaseData): + name += " ({0})".format(layer_state.layer.data.label) + return [go.Volume( + name=name, + hoverinfo="skip", + hovertext=None, x=clipped_xyz[0], y=clipped_xyz[1], z=clipped_xyz[2], diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index caad001..f3fce1b 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -24,22 +24,7 @@ class PlotlyVolumeStaticExport(Tool): tool_tip = 'Save Plotly HTML page' @messagebox_on_error(PLOTLY_ERROR_MESSAGE) - def _export_to_plotly(self, filename): - pass - - - def activate(self): - - dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) - result = dialog.exec_() - if result == QDialog.Rejected: - return - - filename, _ = compat.getsavefilename( - parent=self.viewer, basedir="plot.html") - if not filename: - return - + def _export_to_plotly(self, filename, state_dictionary): config = layout_config(self.viewer.state) layout = go.Layout(**config) fig = go.Figure(layout=layout) @@ -47,7 +32,7 @@ def activate(self): layers = layers_to_export(self.viewer) bounds = self.viewer._vispy_widget._multivol._data_bounds for layer in layers: - options = dialog.state_dictionary[layer.layer.label] + options = state_dictionary[layer.layer.label] count = options.get("isosurface_count", 5) traces = traces_for_layer(self.viewer.state, layer.state, bounds, isosurface_count=count) @@ -57,10 +42,22 @@ def activate(self): plot(fig, filename=filename, auto_open=False) - # worker = Worker(self._export_to_plotly, filename) - # exp_dialog = export_dialog.ExportDialog(parent=self.viewer) - # worker.result.connect(exp_dialog.close) - # worker.error.connect(exp_dialog.close) - # worker.start() - # exp_dialog.exec_() + def activate(self): + + dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) + result = dialog.exec_() + if result == QDialog.Rejected: + return + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return + + worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) + exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + worker.result.connect(exp_dialog.close) + worker.error.connect(exp_dialog.close) + worker.start() + exp_dialog.exec_() From d19e11a6061d18d70a7fdf284ef401e8c5060b29 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Fri, 24 May 2024 15:20:18 -0400 Subject: [PATCH 09/12] Work on adding ability to export scatter layers as well. --- glue_plotly/__init__.py | 3 +- glue_plotly/common/base_3d.py | 2 +- glue_plotly/html_exporters/qt/__init__.py | 1 + .../html_exporters/qt/options_state.py | 27 +++++++ glue_plotly/html_exporters/qt/utils.py | 78 +++++++++++++++++++ glue_plotly/html_exporters/qt/volume.py | 53 +++++++------ glue_plotly/volume_options.py | 30 ++++--- glue_plotly/volume_options.ui | 20 ++--- 8 files changed, 163 insertions(+), 51 deletions(-) create mode 100644 glue_plotly/html_exporters/qt/options_state.py create mode 100644 glue_plotly/html_exporters/qt/utils.py diff --git a/glue_plotly/__init__.py b/glue_plotly/__init__.py index d8411c3..d96396f 100644 --- a/glue_plotly/__init__.py +++ b/glue_plotly/__init__.py @@ -15,7 +15,8 @@ def setup(): try: setup_qt() - except ImportError: + except ImportError as e: + print(e) pass try: diff --git a/glue_plotly/common/base_3d.py b/glue_plotly/common/base_3d.py index 76ebe3e..4f17c82 100644 --- a/glue_plotly/common/base_3d.py +++ b/glue_plotly/common/base_3d.py @@ -95,7 +95,7 @@ def layout_config(viewer_state): camera=dict( projection=dict( type=projection_type(viewer_state) - ), + ), # Currently there's no way to change this in glue up=plotly_up_from_vispy("+z") ), diff --git a/glue_plotly/html_exporters/qt/__init__.py b/glue_plotly/html_exporters/qt/__init__.py index 08a907b..c2e0302 100644 --- a/glue_plotly/html_exporters/qt/__init__.py +++ b/glue_plotly/html_exporters/qt/__init__.py @@ -6,3 +6,4 @@ from . import table # noqa from . import dendrogram # noqa from . import volume # noqa +from .options_state import * # noqa diff --git a/glue_plotly/html_exporters/qt/options_state.py b/glue_plotly/html_exporters/qt/options_state.py new file mode 100644 index 0000000..7231c3f --- /dev/null +++ b/glue_plotly/html_exporters/qt/options_state.py @@ -0,0 +1,27 @@ +from echo import CallbackProperty +from glue.config import DictRegistry +from glue.core.state_objects import State +from glue_vispy_viewers.volume.layer_state import VolumeLayerState + + +__all__ = ["qt_export_options", "VolumeExportOptionsState"] + + +class QtExportLayerOptionsRegistry(DictRegistry): + + def add(self, layer_state_cls, layer_options_state): + if not issubclass(layer_options_state, State): + raise ValueError("Layer options must be a glue State type") + self._members[layer_state_cls] = layer_options_state + + def __call__(self, layer_state_cls): + def adder(export_state_class): + self.add(layer_state_cls, export_state_class) + return adder + +qt_export_options = QtExportLayerOptionsRegistry() + + +@qt_export_options(VolumeLayerState) +class VolumeExportOptionsState(State): + isosurface_count = CallbackProperty(5) diff --git a/glue_plotly/html_exporters/qt/utils.py b/glue_plotly/html_exporters/qt/utils.py new file mode 100644 index 0000000..4d4e449 --- /dev/null +++ b/glue_plotly/html_exporters/qt/utils.py @@ -0,0 +1,78 @@ +from echo.qt import connect_checkable_button, connect_float_text + +from glue.core import Subset + +from qtpy.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QLineEdit +from qtpy.QtGui import QIntValidator, QDoubleValidator + + +def display_name(prop_name): + return prop_name.replace("_", " ").capitalize() + + +def layer_label(layer): + label = layer.layer.label + if isinstance(layer.layer, Subset): + label += f" ({layer.layer.data.label})" + return label + + + +def clear_layout(layout): + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + else: + clear_layout(item.layout()) + + +def widgets_for_property(instance, property, display_name=None): + value = getattr(instance, property) + t = type(value) + connections = [] + widgets = [] + display_name = display_name or property + if t is bool: + widget = QCheckBox() + widget.setChecked(value) + widget.setText(display_name) + connections.append(connect_checkable_button(instance, property, widget)) + widgets.append(widget) + elif t in [int, float]: + label = QLabel() + prompt = f"{display_name}:" + label.setText(prompt) + widget = QLineEdit() + validator = QIntValidator() if t is int else QDoubleValidator() + widget.setText(str(value)) + widget.setValidator(validator) + connections.append(connect_float_text(instance, property, widget)) + widgets.extend((label, widget)) + + return connections, widgets + + +def widgets_for_state(state): + connections = [] + widgets = [] + if state is not None: + for property in state.callback_properties(): + conns, wdgts = widgets_for_property(state, property, display_name(property)) + connections.extend(conns) + widgets.extend(wdgts) + + return connections, widgets + + +def update_layout_for_state(layout, state): + clear_layout(layout) + connections, widgets = widgets_for_state(state) + for widget in widgets: + row = QHBoxLayout() + row.addWidget(widget) + layout.addRow(row) + + return connections diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index f3fce1b..3ce4464 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -6,11 +6,13 @@ from glue_qt.utils import messagebox_on_error from glue_qt.utils.threading import Worker from glue_qt.viewers.common.tool import Tool +from glue_vispy_viewers.scatter.layer_artist import ScatterLayerArtist from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog, volume_options from glue_plotly.common import layers_to_export from glue_plotly.common.base_3d import layout_config -from glue_plotly.common.volume import traces_for_layer +from glue_plotly.common.scatter3d import traces_for_layer as scatter3d_traces_for_layer +from glue_plotly.common.volume import traces_for_layer as volume_traces_for_layer from plotly.offline import plot import plotly.graph_objs as go @@ -25,6 +27,20 @@ class PlotlyVolumeStaticExport(Tool): @messagebox_on_error(PLOTLY_ERROR_MESSAGE) def _export_to_plotly(self, filename, state_dictionary): + pass + + def activate(self): + + dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) + result = dialog.exec_() + if result == QDialog.Rejected: + return + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return + config = layout_config(self.viewer.state) layout = go.Layout(**config) fig = go.Figure(layout=layout) @@ -32,32 +48,23 @@ def _export_to_plotly(self, filename, state_dictionary): layers = layers_to_export(self.viewer) bounds = self.viewer._vispy_widget._multivol._data_bounds for layer in layers: - options = state_dictionary[layer.layer.label] - count = options.get("isosurface_count", 5) - traces = traces_for_layer(self.viewer.state, layer.state, bounds, + if isinstance(layer, ScatterLayerArtist): + traces = scatter3d_traces_for_layer(self.viewer.state, layer.state) + else: + options = dialog.state_dictionary[layer.layer.label] + count = options.isosurface_count + print(count) + traces = volume_traces_for_layer(self.viewer.state, layer.state, bounds, isosurface_count=count) for trace in traces: fig.add_trace(trace) plot(fig, filename=filename, auto_open=False) - - - def activate(self): - - dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) - result = dialog.exec_() - if result == QDialog.Rejected: - return - - filename, _ = compat.getsavefilename( - parent=self.viewer, basedir="plot.html") - if not filename: - return - worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) - exp_dialog = export_dialog.ExportDialog(parent=self.viewer) - worker.result.connect(exp_dialog.close) - worker.error.connect(exp_dialog.close) - worker.start() - exp_dialog.exec_() + # worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) + # exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + # worker.result.connect(exp_dialog.close) + # worker.error.connect(exp_dialog.close) + # worker.start() + # exp_dialog.exec_() diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py index f3779a2..1b04924 100644 --- a/glue_plotly/volume_options.py +++ b/glue_plotly/volume_options.py @@ -1,27 +1,29 @@ import os +from glue_plotly.html_exporters.qt.utils import update_layout_for_state from qtpy.QtWidgets import QDialog -from echo import CallbackProperty, SelectionCallbackProperty +from echo import SelectionCallbackProperty from echo.qt import autoconnect_callbacks_to_qt from glue.core.data_combo_helper import ComboHelper from glue.core.state_objects import State from glue_qt.utils import load_ui +from glue_plotly.html_exporters.qt.options_state import qt_export_options +from glue_plotly.html_exporters.qt.utils import layer_label + class VolumeDialogState(State): layer = SelectionCallbackProperty() - isosurface_count = CallbackProperty(5) def __init__(self, layers): super(VolumeDialogState, self).__init__() self.layers = layers - self.layer_helper = ComboHelper(self, 'layer') - self.layer_helper.choices = [state.layer.label for state in self.layers] + self.layer_helper.choices = [layer_label(state) for state in self.layers] class VolumeOptionsDialog(QDialog): @@ -36,17 +38,23 @@ def __init__(self, parent=None, viewer=None): self.ui = load_ui('volume_options.ui', self, directory=os.path.dirname(__file__)) self._connections = autoconnect_callbacks_to_qt(self.state, self.ui) - self.state_dictionary = { layer.layer.label: {} for layer in layers } + self.state_dictionary = { + layer_label(layer): self.state_for_layer(layer) + for layer in layers + } self.ui.button_cancel.clicked.connect(self.reject) self.ui.button_ok.clicked.connect(self.accept) self.state.add_callback('layer', self._on_layer_change) - self.state.add_callback('isosurface_count', self._on_isosurface_count_change) - def _on_layer_change(self, layer): - count = self.state_dictionary[self.state.layer].get("isosurface_count", 5) - self.ui.valuetext_isosurface_count.setText(str(count)) + self._on_layer_change(self.state.layer) - def _on_isosurface_count_change(self, count): - self.state_dictionary[self.state.layer]["isosurface_count"] = int(count) + def state_for_layer(self, layer): + t = qt_export_options.members.get(type(layer.state), None) + if t: + return t() + return None + + def _on_layer_change(self, layer): + update_layout_for_state(self.ui.layer_layout, self.state_dictionary.get(layer, None)) diff --git a/glue_plotly/volume_options.ui b/glue_plotly/volume_options.ui index 6af8f73..ba82682 100644 --- a/glue_plotly/volume_options.ui +++ b/glue_plotly/volume_options.ui @@ -14,6 +14,9 @@ Export 3D Volume + + + @@ -24,16 +27,7 @@ - - - - - - - - - @@ -72,12 +66,8 @@ - - - - Isosurface count: - - + + From f00f659593f31d9e752837883929950204e775c8 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Fri, 24 May 2024 16:30:16 -0400 Subject: [PATCH 10/12] Restore exporting dialog and retain layer connections. Codestyle fixes. --- glue_plotly/__init__.py | 3 +- glue_plotly/common/base_3d.py | 6 +- glue_plotly/common/volume.py | 8 +-- .../html_exporters/qt/options_state.py | 1 + glue_plotly/html_exporters/qt/utils.py | 5 +- glue_plotly/html_exporters/qt/volume.py | 56 +++++++++---------- glue_plotly/utils.py | 1 - .../viewers/common/tests/test_tools.py | 1 - glue_plotly/volume_options.py | 2 +- 9 files changed, 40 insertions(+), 43 deletions(-) diff --git a/glue_plotly/__init__.py b/glue_plotly/__init__.py index d96396f..d8411c3 100644 --- a/glue_plotly/__init__.py +++ b/glue_plotly/__init__.py @@ -15,8 +15,7 @@ def setup(): try: setup_qt() - except ImportError as e: - print(e) + except ImportError: pass try: diff --git a/glue_plotly/common/base_3d.py b/glue_plotly/common/base_3d.py index 4f17c82..cff3ea6 100644 --- a/glue_plotly/common/base_3d.py +++ b/glue_plotly/common/base_3d.py @@ -73,13 +73,13 @@ def clipped_data(viewer_state, layer_state): def plotly_up_from_vispy(vispy_up): - regex = re.compile("(\+|-)(x|y|z)") + regex = re.compile("(\\+|-)(x|y|z)") up = {"x": 0, "y": 0, "z": 0} m = regex.match(vispy_up) if m is not None and len(m.groups()) == 2: sign = 1 if m.group(1) == "+" else -1 up[m.group(2)] = sign - return up + return up def layout_config(viewer_state): @@ -95,7 +95,7 @@ def layout_config(viewer_state): camera=dict( projection=dict( type=projection_type(viewer_state) - ), + ), # Currently there's no way to change this in glue up=plotly_up_from_vispy("+z") ), diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py index ab43dd7..2e501c0 100644 --- a/glue_plotly/common/volume.py +++ b/glue_plotly/common/volume.py @@ -54,7 +54,7 @@ def values(viewer_state, layer_state, bounds, precomputed=None): min_value = nanmin(values) replacement = min_value - 1 replaced = nan_to_num(values, replacement) - return replaced + return replaced def colorscale(layer_state, size=10): @@ -75,9 +75,9 @@ def isomin_for_layer(viewer_or_state, layer): parent_state = parent if isinstance(parent, State) else parent.state return parent_state.vmin - state = layer if isinstance(layer, State) else layer + state = layer if isinstance(layer, State) else layer return state.vmin - + def isomax_for_layer(viewer_or_state, layer): if isinstance(layer.layer, GroupedSubset): @@ -86,7 +86,7 @@ def isomax_for_layer(viewer_or_state, layer): parent_state = parent if isinstance(parent, State) else parent.state return parent_state.vmax - state = layer if isinstance(layer, State) else layer + state = layer if isinstance(layer, State) else layer return state.vmax diff --git a/glue_plotly/html_exporters/qt/options_state.py b/glue_plotly/html_exporters/qt/options_state.py index 7231c3f..42841ee 100644 --- a/glue_plotly/html_exporters/qt/options_state.py +++ b/glue_plotly/html_exporters/qt/options_state.py @@ -19,6 +19,7 @@ def adder(export_state_class): self.add(layer_state_cls, export_state_class) return adder + qt_export_options = QtExportLayerOptionsRegistry() diff --git a/glue_plotly/html_exporters/qt/utils.py b/glue_plotly/html_exporters/qt/utils.py index 4d4e449..aea468d 100644 --- a/glue_plotly/html_exporters/qt/utils.py +++ b/glue_plotly/html_exporters/qt/utils.py @@ -17,7 +17,6 @@ def layer_label(layer): return label - def clear_layout(layout): if layout is not None: while layout.count(): @@ -34,7 +33,7 @@ def widgets_for_property(instance, property, display_name=None): t = type(value) connections = [] widgets = [] - display_name = display_name or property + display_name = display_name or property if t is bool: widget = QCheckBox() widget.setChecked(value) @@ -69,7 +68,7 @@ def widgets_for_state(state): def update_layout_for_state(layout, state): clear_layout(layout) - connections, widgets = widgets_for_state(state) + connections, widgets = widgets_for_state(state) for widget in widgets: row = QHBoxLayout() row.addWidget(widget) diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py index 3ce4464..09e0f27 100644 --- a/glue_plotly/html_exporters/qt/volume.py +++ b/glue_plotly/html_exporters/qt/volume.py @@ -2,14 +2,13 @@ from qtpy.QtWidgets import QDialog from glue.config import viewer_tool -from glue_plotly.common.common import data_count from glue_qt.utils import messagebox_on_error from glue_qt.utils.threading import Worker from glue_qt.viewers.common.tool import Tool from glue_vispy_viewers.scatter.layer_artist import ScatterLayerArtist from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog, volume_options -from glue_plotly.common import layers_to_export +from glue_plotly.common import data_count, layers_to_export from glue_plotly.common.base_3d import layout_config from glue_plotly.common.scatter3d import traces_for_layer as scatter3d_traces_for_layer from glue_plotly.common.volume import traces_for_layer as volume_traces_for_layer @@ -27,44 +26,45 @@ class PlotlyVolumeStaticExport(Tool): @messagebox_on_error(PLOTLY_ERROR_MESSAGE) def _export_to_plotly(self, filename, state_dictionary): - pass - - def activate(self): - - dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) - result = dialog.exec_() - if result == QDialog.Rejected: - return - - filename, _ = compat.getsavefilename( - parent=self.viewer, basedir="plot.html") - if not filename: - return config = layout_config(self.viewer.state) layout = go.Layout(**config) fig = go.Figure(layout=layout) layers = layers_to_export(self.viewer) + add_data_label = data_count(layers) > 1 bounds = self.viewer._vispy_widget._multivol._data_bounds for layer in layers: if isinstance(layer, ScatterLayerArtist): - traces = scatter3d_traces_for_layer(self.viewer.state, layer.state) + traces = scatter3d_traces_for_layer(self.viewer.state, layer.state, + add_data_label=add_data_label) else: - options = dialog.state_dictionary[layer.layer.label] - count = options.isosurface_count - print(count) + options = state_dictionary[layer.layer.label] + count = int(options.isosurface_count) traces = volume_traces_for_layer(self.viewer.state, layer.state, bounds, - isosurface_count=count) - + isosurface_count=count, + add_data_label=add_data_label) + for trace in traces: fig.add_trace(trace) plot(fig, filename=filename, auto_open=False) - - # worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) - # exp_dialog = export_dialog.ExportDialog(parent=self.viewer) - # worker.result.connect(exp_dialog.close) - # worker.error.connect(exp_dialog.close) - # worker.start() - # exp_dialog.exec_() + + def activate(self): + + dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) + result = dialog.exec_() + if result == QDialog.Rejected: + return + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return + + worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) + exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + worker.result.connect(exp_dialog.close) + worker.error.connect(exp_dialog.close) + worker.start() + exp_dialog.exec_() diff --git a/glue_plotly/utils.py b/glue_plotly/utils.py index b735977..6622cf7 100644 --- a/glue_plotly/utils.py +++ b/glue_plotly/utils.py @@ -85,4 +85,3 @@ def rgba_components(color): def components_to_hex(r, g, b, a=None): components = [hex_string(t) for t in (r, g, b, a) if t is not None] return f"#{''.join(components)}" - diff --git a/glue_plotly/viewers/common/tests/test_tools.py b/glue_plotly/viewers/common/tests/test_tools.py index 72c23f7..9ed8d2b 100644 --- a/glue_plotly/viewers/common/tests/test_tools.py +++ b/glue_plotly/viewers/common/tests/test_tools.py @@ -86,7 +86,6 @@ def test_home(self): self.viewer.state.y_max = 13 tool = self.get_tool('plotly:home') tool.activate() - print(self.viewer.state) assert self.viewer.state.x_min == xmin assert self.viewer.state.x_max == xmax assert self.viewer.state.y_min == ymin diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py index 1b04924..8e9a4fe 100644 --- a/glue_plotly/volume_options.py +++ b/glue_plotly/volume_options.py @@ -57,4 +57,4 @@ def state_for_layer(self, layer): return None def _on_layer_change(self, layer): - update_layout_for_state(self.ui.layer_layout, self.state_dictionary.get(layer, None)) + self._layer_connections = update_layout_for_state(self.ui.layer_layout, self.state_dictionary.get(layer, None)) From 8b0b614cd8ed7a56b87b3f16318ee47cf3ef4eb5 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Fri, 24 May 2024 16:57:47 -0400 Subject: [PATCH 11/12] Add basic tests for volume exporter. --- .../html_exporters/qt/tests/test_base.py | 3 ++ .../html_exporters/qt/tests/test_volume.py | 30 +++++++++++++++++++ glue_plotly/volume_options.py | 3 ++ 3 files changed, 36 insertions(+) create mode 100644 glue_plotly/html_exporters/qt/tests/test_volume.py diff --git a/glue_plotly/html_exporters/qt/tests/test_base.py b/glue_plotly/html_exporters/qt/tests/test_base.py index 3beb927..734ce4c 100644 --- a/glue_plotly/html_exporters/qt/tests/test_base.py +++ b/glue_plotly/html_exporters/qt/tests/test_base.py @@ -5,6 +5,8 @@ from glue_plotly.sort_components import SortComponentsDialog from qtpy.QtWidgets import QMessageBox +from glue_plotly.volume_options import VolumeOptionsDialog + class TestQtExporter: @@ -47,6 +49,7 @@ def export_figure(self, tmpdir, output_filename): fd.return_value = output_path, 'html' with patch.object(SaveHoverDialog, 'exec_', self.auto_accept_selectdialog()), \ patch.object(SortComponentsDialog, 'exec_', self.auto_accept_selectdialog()), \ + patch.object(VolumeOptionsDialog, 'exec_', self.auto_accept_messagebox()), \ patch.object(QMessageBox, 'exec_', self.auto_accept_messagebox()): self.tool.activate() return output_path diff --git a/glue_plotly/html_exporters/qt/tests/test_volume.py b/glue_plotly/html_exporters/qt/tests/test_volume.py new file mode 100644 index 0000000..bb2372b --- /dev/null +++ b/glue_plotly/html_exporters/qt/tests/test_volume.py @@ -0,0 +1,30 @@ +import os + +from glue.core import Data + +from pytest import importorskip + +importorskip('glue_qt') +importorskip('glue_vispy_viewers') + +from glue_vispy_viewers.volume.volume_viewer import VispyVolumeViewer # noqa: E402 + +from numpy import arange, ones # noqa: E402 + +from .test_base import TestQtExporter # noqa: E402 + + +class TestVolume(TestQtExporter): + + viewer_type = VispyVolumeViewer + tool_id = 'save:plotlyvolume' + + def make_data(self): + return Data(label='d1', + x=arange(24).reshape((2, 3, 4)), + y=ones((2, 3, 4)), + z=arange(100, 124).reshape((2, 3, 4))) + + def test_default(self, tmpdir): + output_path = self.export_figure(tmpdir, 'test.html') + assert os.path.exists(output_path) diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py index 8e9a4fe..9bf4543 100644 --- a/glue_plotly/volume_options.py +++ b/glue_plotly/volume_options.py @@ -14,6 +14,9 @@ from glue_plotly.html_exporters.qt.utils import layer_label +__all__ = ["VolumeOptionsDialog"] + + class VolumeDialogState(State): layer = SelectionCallbackProperty() From c36c00b327fee248bb043c517f5cb61dd9dc7f4d Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Fri, 24 May 2024 16:58:07 -0400 Subject: [PATCH 12/12] Tighten noqa comments in Qt tests. --- glue_plotly/html_exporters/qt/tests/test_dendrogram.py | 4 ++-- glue_plotly/html_exporters/qt/tests/test_histogram.py | 4 ++-- glue_plotly/html_exporters/qt/tests/test_image.py | 6 +++--- glue_plotly/html_exporters/qt/tests/test_profile.py | 4 ++-- glue_plotly/html_exporters/qt/tests/test_scatter2d.py | 4 ++-- glue_plotly/html_exporters/qt/tests/test_scatter3d.py | 4 ++-- glue_plotly/html_exporters/qt/tests/test_table.py | 6 +++--- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/glue_plotly/html_exporters/qt/tests/test_dendrogram.py b/glue_plotly/html_exporters/qt/tests/test_dendrogram.py index c074fd2..299dea9 100644 --- a/glue_plotly/html_exporters/qt/tests/test_dendrogram.py +++ b/glue_plotly/html_exporters/qt/tests/test_dendrogram.py @@ -6,9 +6,9 @@ importorskip('glue_qt.plugins.dendro_viewer.data_viewer') -from glue_qt.plugins.dendro_viewer.data_viewer import DendrogramViewer # noqa +from glue_qt.plugins.dendro_viewer.data_viewer import DendrogramViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestDendrogram(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_histogram.py b/glue_plotly/html_exporters/qt/tests/test_histogram.py index fce34cd..317a2a8 100644 --- a/glue_plotly/html_exporters/qt/tests/test_histogram.py +++ b/glue_plotly/html_exporters/qt/tests/test_histogram.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.histogram import HistogramViewer # noqa +from glue_qt.viewers.histogram import HistogramViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestHistogram(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_image.py b/glue_plotly/html_exporters/qt/tests/test_image.py index 51c5746..896f527 100644 --- a/glue_plotly/html_exporters/qt/tests/test_image.py +++ b/glue_plotly/html_exporters/qt/tests/test_image.py @@ -6,11 +6,11 @@ importorskip('glue_qt') -from glue_qt.viewers.image.data_viewer import ImageViewer # noqa +from glue_qt.viewers.image.data_viewer import ImageViewer # noqa: E402 -from numpy import arange, ones # noqa +from numpy import arange, ones # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestImage(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_profile.py b/glue_plotly/html_exporters/qt/tests/test_profile.py index d5e94fc..c7fe449 100644 --- a/glue_plotly/html_exporters/qt/tests/test_profile.py +++ b/glue_plotly/html_exporters/qt/tests/test_profile.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.profile import ProfileViewer # noqa +from glue_qt.viewers.profile import ProfileViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestProfile(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_scatter2d.py b/glue_plotly/html_exporters/qt/tests/test_scatter2d.py index 7b6303c..b7f3214 100644 --- a/glue_plotly/html_exporters/qt/tests/test_scatter2d.py +++ b/glue_plotly/html_exporters/qt/tests/test_scatter2d.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.scatter import ScatterViewer # noqa +from glue_qt.viewers.scatter import ScatterViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestScatter2D(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_scatter3d.py b/glue_plotly/html_exporters/qt/tests/test_scatter3d.py index 001b96b..6ae1559 100644 --- a/glue_plotly/html_exporters/qt/tests/test_scatter3d.py +++ b/glue_plotly/html_exporters/qt/tests/test_scatter3d.py @@ -6,9 +6,9 @@ pytest.importorskip('glue_vispy_viewers') -from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer # noqa +from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestScatter3D(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_table.py b/glue_plotly/html_exporters/qt/tests/test_table.py index c030b7b..9bd7116 100644 --- a/glue_plotly/html_exporters/qt/tests/test_table.py +++ b/glue_plotly/html_exporters/qt/tests/test_table.py @@ -6,10 +6,10 @@ importorskip('glue_qt') -from glue_qt.app import GlueApplication # noqa -from glue_qt.viewers.table import TableViewer # noqa +from glue_qt.app import GlueApplication # noqa: E402 +from glue_qt.viewers.table import TableViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestTable(TestQtExporter):