Skip to content

Commit

Permalink
Merge pull request #70 from Carifio24/volume
Browse files Browse the repository at this point in the history
Add exporter for volume viewer
  • Loading branch information
Carifio24 authored May 24, 2024
2 parents bc04b25 + c36c00b commit 0a9b6af
Show file tree
Hide file tree
Showing 23 changed files with 633 additions and 111 deletions.
10 changes: 9 additions & 1 deletion glue_plotly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
107 changes: 107 additions & 0 deletions glue_plotly/common/base_3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import re

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 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(
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)
),
# 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,
z=depth / width * viewer_state.z_stretch),
aspectmode='manual'
)
)
2 changes: 1 addition & 1 deletion glue_plotly/common/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 2 additions & 89 deletions glue_plotly/common/scatter3d.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,13 @@
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
from numpy import clip
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):
Expand Down Expand Up @@ -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)
Expand Down
119 changes: 119 additions & 0 deletions glue_plotly/common/volume.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from glue_plotly.utils import rgba_components
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

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):
# 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 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


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
if precomputed is not None and parent_label in precomputed:
data = precomputed[parent_label]
else:
data = parent.compute_fixed_resolution_buffer(
target_data=viewer_state.reference_data,
bounds=bounds,
target_cid=layer_state.attribute
)

if subset_layer:
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

# 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)
replacement = min_value - 1
replaced = nan_to_num(values, replacement)
return replaced


def colorscale(layer_state, size=10):
color = color_info(layer_state)
r, g, b, a = rgba_components(color)
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]


def opacity_scale(layer_state):
return [[0, 0], [1, 1]]


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

state = layer if isinstance(layer, State) else layer
return state.vmin


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

state = layer if isinstance(layer, State) else layer
return state.vmax


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],
value=clipped_values,
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
)]
2 changes: 2 additions & 0 deletions glue_plotly/html_exporters/qt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
from . import profile # noqa
from . import table # noqa
from . import dendrogram # noqa
from . import volume # noqa
from .options_state import * # noqa
Loading

0 comments on commit 0a9b6af

Please sign in to comment.