Vtk Slicer#
This app demos advanced 3D visualisation using Panel and PyVista. A VTKVolume renders the volume with three orthogonal slices, and each slice is also shown as a rasterized HoloViews image that follows the slice controls.
from functools import partial
import holoviews as hv
import numpy as np
import panel as pn
from holoviews.operation.datashader import rasterize
from pyvista import examples # Import after datashader to avoid segfault on some systems
from scipy.ndimage import zoom
pn.extension('vtk', sizing_mode="stretch_width")
hv.opts.defaults(hv.opts.Image(responsive=True, tools=['hover']))
Declare callbacks#
The slice functions are pure: they take the slice index, the colormapper, the smoothing settings and the volume, and return an image.
def hook_reset_range(plot, elem, lbrt):
# Keep the reset tool and the visible range in sync with the slice bounds when the dataset changes.
bkplot = plot.handles['plot']
x_range = lbrt[0], lbrt[2]
y_range = lbrt[1], lbrt[3]
old_x_range_reset = bkplot.x_range.reset_start, bkplot.x_range.reset_end
old_y_range_reset = bkplot.y_range.reset_start, bkplot.y_range.reset_end
if x_range != old_x_range_reset or y_range != old_y_range_reset:
bkplot.x_range.reset_start, bkplot.x_range.reset_end = x_range
bkplot.x_range.start, bkplot.x_range.end = x_range
bkplot.y_range.reset_start, bkplot.y_range.reset_end = y_range
bkplot.y_range.start, bkplot.y_range.end = y_range
def image_slice(dims, array, lbrt, mapper, smooth_level, order):
array = np.asarray(array)
low = mapper['low'] if mapper else array.min()
high = mapper['high'] if mapper else array.max()
cmap = mapper['palette'] if mapper else 'fire'
smoothed = zoom(array, zoom=smooth_level, order=order)
img = hv.Image(smoothed, bounds=lbrt, kdims=dims, vdims='Intensity')
reset_fun = partial(hook_reset_range, lbrt=lbrt)
return img.opts(clim=(low, high), cmap=cmap, hooks=[reset_fun])
def volume_array(vol):
return vol.active_scalars.reshape(vol.dimensions, order='F')
def image_slice_i(si, vol, **kwargs):
lbrt = vol.bounds[2], vol.bounds[4], vol.bounds[3], vol.bounds[5]
return image_slice(['y','z'], volume_array(vol)[si,:,::-1].T, lbrt, **kwargs)
def image_slice_j(sj, vol, **kwargs):
lbrt = vol.bounds[0], vol.bounds[4], vol.bounds[1], vol.bounds[5]
return image_slice(['x','z'], volume_array(vol)[:,sj,::-1].T, lbrt, **kwargs)
def image_slice_k(sk, vol, **kwargs):
lbrt = vol.bounds[0], vol.bounds[2], vol.bounds[1], vol.bounds[3]
return image_slice(['x', 'y'], volume_array(vol)[:,::-1,sk].T, lbrt, **kwargs)
Declare Panel#
The volume binds to the dataset selector, and the slice widgets are built from the volume’s own parameters, so their bounds follow the loaded dataset.
head = examples.download_head()
brain = examples.download_brain()
dataset_selection = pn.ui.Select(label='Dataset', value=head, options={'Head': head, 'Brain': brain})
volume = pn.ui.VTKVolume(
dataset_selection, sizing_mode='stretch_both', min_height=400,
display_slices=True, orientation_widget=True, render_background="#222222",
colormap='Rainbow Desaturated'
)
parallel_projection = pn.ui.Switch(label='Parallel Projection', value=False)
def set_projection(parallel):
volume.camera = {**(volume.camera or {}), 'parallelProjection': parallel}
pn.bind(set_projection, parallel_projection, watch=True)
smooth_level = pn.ui.IntSlider(label='Smooth level', value=5, start=1, end=10)
order = pn.ui.Select(label='Order', value=1, options=[1, 2, 3])
common = dict(
vol=volume.param.object,
mapper=volume.param.mapper,
smooth_level=smooth_level,
order=order,
)
dmap_i = rasterize(hv.DynamicMap(pn.bind(image_slice_i, si=volume.param.slice_i, **common)))
dmap_j = rasterize(hv.DynamicMap(pn.bind(image_slice_j, sj=volume.param.slice_j, **common)))
dmap_k = rasterize(hv.DynamicMap(pn.bind(image_slice_k, sk=volume.param.slice_k, **common)))
controller = pn.ui.Column(
dataset_selection,
parallel_projection,
pn.ui.ColorPicker.from_param(volume.param.render_background),
pn.ui.Switch.from_param(volume.param.display_volume),
pn.ui.Switch.from_param(volume.param.display_slices),
pn.ui.IntSlider.from_param(volume.param.slice_i),
pn.ui.IntSlider.from_param(volume.param.slice_j),
pn.ui.IntSlider.from_param(volume.param.slice_k),
pn.ui.Switch.from_param(volume.param.rescale),
smooth_level,
order,
)
controller
Lay out the app#
A responsive grid shows the 3D view and the three slices in a 2x2 arrangement on wide screens and stacks them on narrow ones.
def card(title, obj):
return pn.ui.Grid(
pn.ui.Paper(
pn.ui.Typography(title, variant="subtitle1", margin=(0, 10)),
obj, sizing_mode='stretch_width', height=450,
),
size={"xs": 12, "md": 6},
)
grid = pn.ui.Grid(
card('3D View', volume),
card('Slice I', pn.ui.HoloViews(dmap_i, sizing_mode='stretch_both')),
card('Slice J', pn.ui.HoloViews(dmap_j, sizing_mode='stretch_both')),
card('Slice K', pn.ui.HoloViews(dmap_k, sizing_mode='stretch_both')),
container=True, spacing=2,
)
page = pn.ui.Page(
title='VTK Slicer',
sidebar=[controller],
main=[grid],
dark_theme=True,
)
page
Served App#
if pn.state.served:
page.servable()