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()