VolumeViewer

Live example

## Volume Viewer

The Volume Viewer is a standalone single page web application that can be used to visualize and explore VTI files. The only requirement is the single HTML file without any web server. This application can be use to load vti file and use volume rendering to render them.
The current vti supported format is ascii, binary and binary+zlib compression.

Some sample files can be found here

Also using extra argument to the URL allow to view remote VTI like the links below:

Editing the opacity transfer function

With a mouse:

  • Double click:
    • create a gaussian at that given position and height with no bias.
  • Right click
    • delete the gaussian underneath.
  • Drag:
    • Bottom of a gaussian will adjust the width. From left to right will increase the width.
    • Middle of the gaussian will adjust the biais (x and y).
    • Top of the gaussian will adjust height.
    • Anywhere else will adjust its position.

With touch:

  • Double Tap:
    • One finger: create a gaussian at that given position and height with no bias.
    • Two fingers: delete the gaussian underneath.
  • Drag:
    • Bottom of a gaussian will adjust the width. From left to right will increase the width.
    • Middle of the gaussian will adjust the biais (x and y).
    • Top of the gaussian will adjust height.
    • Anywhere else will adjust its position.

Source

/* eslint-disable import/prefer-default-export */
/* eslint-disable import/no-extraneous-dependencies */

import '@kitware/vtk.js/favicon';

// Load the rendering pieces we want to use (for both WebGL and WebGPU)
import '@kitware/vtk.js/Rendering/Profiles/Volume';

import macro from '@kitware/vtk.js/macros';
import HttpDataAccessHelper from '@kitware/vtk.js/IO/Core/DataAccessHelper/HttpDataAccessHelper';
import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox';
import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow';
import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
import vtkVolumeController from '@kitware/vtk.js/Interaction/UI/VolumeController';
import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract';
import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume';
import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper';
import vtkXMLImageDataReader from '@kitware/vtk.js/IO/XML/XMLImageDataReader';
import vtkFPSMonitor from '@kitware/vtk.js/Interaction/UI/FPSMonitor';

// Force DataAccessHelper to have access to various data source
import '@kitware/vtk.js/IO/Core/DataAccessHelper/HtmlDataAccessHelper';
import '@kitware/vtk.js/IO/Core/DataAccessHelper/JSZipDataAccessHelper';

import style from './VolumeViewer.module.css';

let autoInit = true;
const userParams = vtkURLExtract.extractURLParameters();
const fpsMonitor = vtkFPSMonitor.newInstance();

// ----------------------------------------------------------------------------
// Add class to body if iOS device
// ----------------------------------------------------------------------------

const iOS = /iPad|iPhone|iPod/.test(window.navigator.platform);

if (iOS) {
document.querySelector('body').classList.add('is-ios-device');
}

// ----------------------------------------------------------------------------

function emptyContainer(container) {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
}

// ----------------------------------------------------------------------------

function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}

// ----------------------------------------------------------------------------

function createViewer(rootContainer, fileContents, options) {
const background = options.background
? options.background.split(',').map((s) => Number(s))
: [0, 0, 0];
const containerStyle = options.containerStyle;
const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
background,
rootContainer,
containerStyle,
});
const renderer = fullScreenRenderer.getRenderer();
const renderWindow = fullScreenRenderer.getRenderWindow();
renderWindow.getInteractor().setDesiredUpdateRate(30);

const vtiReader = vtkXMLImageDataReader.newInstance();
vtiReader.parseAsArrayBuffer(fileContents);

const source = vtiReader.getOutputData(0);
const mapper = vtkVolumeMapper.newInstance();
const actor = vtkVolume.newInstance();

const dataArray =
source.getPointData().getScalars() || source.getPointData().getArrays()[0];
const dataRange = dataArray.getRange();

const lookupTable = vtkColorTransferFunction.newInstance();
const piecewiseFunction = vtkPiecewiseFunction.newInstance();

// Pipeline handling
actor.setMapper(mapper);
mapper.setInputData(source);
renderer.addActor(actor);

// Configuration
const sampleDistance =
0.7 *
Math.sqrt(
source
.getSpacing()
.map((v) => v * v)
.reduce((a, b) => a + b, 0)
);
mapper.setSampleDistance(sampleDistance);
actor.getProperty().setRGBTransferFunction(0, lookupTable);
actor.getProperty().setScalarOpacity(0, piecewiseFunction);
// actor.getProperty().setInterpolationTypeToFastLinear();
actor.getProperty().setInterpolationTypeToLinear();

// For better looking volume rendering
// - distance in world coordinates a scalar opacity of 1.0
actor
.getProperty()
.setScalarOpacityUnitDistance(
0,
vtkBoundingBox.getDiagonalLength(source.getBounds()) /
Math.max(...source.getDimensions())
);
// - control how we emphasize surface boundaries
// => max should be around the average gradient magnitude for the
// volume or maybe average plus one std dev of the gradient magnitude
// (adjusted for spacing, this is a world coordinate gradient, not a
// pixel gradient)
// => max hack: (dataRange[1] - dataRange[0]) * 0.05
actor.getProperty().setGradientOpacityMinimumValue(0, 0);
actor
.getProperty()
.setGradientOpacityMaximumValue(0, (dataRange[1] - dataRange[0]) * 0.05);
// - Use shading based on gradient
actor.getProperty().setShade(true);
actor.getProperty().setUseGradientOpacity(0, true);
// - generic good default
actor.getProperty().setGradientOpacityMinimumOpacity(0, 0.0);
actor.getProperty().setGradientOpacityMaximumOpacity(0, 1.0);
actor.getProperty().setAmbient(0.2);
actor.getProperty().setDiffuse(0.7);
actor.getProperty().setSpecular(0.3);
actor.getProperty().setSpecularPower(8.0);

// Control UI
const controllerWidget = vtkVolumeController.newInstance({
size: [400, 150],
rescaleColorMap: true,
});
const isBackgroundDark = background[0] + background[1] + background[2] < 1.5;
controllerWidget.setContainer(rootContainer);
controllerWidget.setupContent(renderWindow, actor, isBackgroundDark);

// setUpContent above sets the size to the container.
// We need to set the size after that.
// controllerWidget.setExpanded(false);

fullScreenRenderer.setResizeCallback(({ width, height }) => {
// 2px padding + 2x1px boder + 5px edge = 14
if (width > 414) {
controllerWidget.setSize(400, 150);
} else {
controllerWidget.setSize(width - 14, 150);
}
controllerWidget.render();
fpsMonitor.update();
});

// First render
renderer.resetCamera();
renderWindow.render();

global.pipeline = {
actor,
renderer,
renderWindow,
lookupTable,
mapper,
source,
piecewiseFunction,
fullScreenRenderer,
};

if (userParams.fps) {
const fpsElm = fpsMonitor.getFpsMonitorContainer();
fpsElm.classList.add(style.fpsMonitor);
fpsMonitor.setRenderWindow(renderWindow);
fpsMonitor.setContainer(rootContainer);
fpsMonitor.update();
}
}

// ----------------------------------------------------------------------------

export function load(container, options) {
autoInit = false;
emptyContainer(container);

if (options.file) {
if (options.ext === 'vti') {
const reader = new FileReader();
reader.onload = function onLoad(e) {
createViewer(container, reader.result, options);
};
reader.readAsArrayBuffer(options.file);
} else {
console.error('Unkown file...');
}
} else if (options.fileURL) {
const progressContainer = document.createElement('div');
progressContainer.setAttribute('class', style.progress);
container.appendChild(progressContainer);

const progressCallback = (progressEvent) => {
if (progressEvent.lengthComputable) {
const percent = Math.floor(
(100 * progressEvent.loaded) / progressEvent.total
);
progressContainer.innerHTML = `Loading ${percent}%`;
} else {
progressContainer.innerHTML = macro.formatBytesToProperUnit(
progressEvent.loaded
);
}
};

HttpDataAccessHelper.fetchBinary(options.fileURL, {
progressCallback,
}).then((binary) => {
container.removeChild(progressContainer);
createViewer(container, binary, options);
});
}
}

export function initLocalFileLoader(container) {
const exampleContainer = document.querySelector('.content');
const rootBody = document.querySelector('body');
const myContainer = container || exampleContainer || rootBody;

const fileContainer = document.createElement('div');
fileContainer.innerHTML = `<div class="${style.bigFileDrop}"/><input type="file" accept=".vti" style="display: none;"/>`;
myContainer.appendChild(fileContainer);

const fileInput = fileContainer.querySelector('input');

function handleFile(e) {
preventDefaults(e);
const dataTransfer = e.dataTransfer;
const files = e.target.files || dataTransfer.files;
if (files.length === 1) {
myContainer.removeChild(fileContainer);
const ext = files[0].name.split('.').slice(-1)[0];
const options = { file: files[0], ext, ...userParams };
load(myContainer, options);
}
}

fileInput.addEventListener('change', handleFile);
fileContainer.addEventListener('drop', handleFile);
fileContainer.addEventListener('click', (e) => fileInput.click());
fileContainer.addEventListener('dragover', preventDefaults);
}

// Look at URL an see if we should load a file
// ?fileURL=https://data.kitware.com/api/v1/item/59cdbb588d777f31ac63de08/download
if (userParams.fileURL) {
const exampleContainer = document.querySelector('.content');
const rootBody = document.querySelector('body');
const myContainer = exampleContainer || rootBody;
load(myContainer, userParams);
}

const viewerContainers = document.querySelectorAll('.vtkjs-volume-viewer');
let nbViewers = viewerContainers.length;
while (nbViewers--) {
const viewerContainer = viewerContainers[nbViewers];
const fileURL = viewerContainer.dataset.url;
const options = {
containerStyle: { height: '100%' },
...userParams,
fileURL,
};
load(viewerContainer, options);
}

// Auto setup if no method get called within 100ms
setTimeout(() => {
if (autoInit) {
initLocalFileLoader();
}
}, 100);