ohif-viewer/extensions/vtk/src/OHIFVTKViewport.js
Igor Octaviano 42c22df1b6
Segmentation UI for VTKjs (#1685)
* Add single viewport configuration

* Multiple viewport configuration

* Improve performance by using independent set methods

* Add jump to slice command

* Add context configuration

* Cache panel visibility

* Fix sync between vtk and cornerstone

* Remove apis index

* Add approach

* Add loading to update volumes

* Fix broken configuration

* Bump vtk version

* Use loading label

* Update cy tests after vtk loading label changed

* Remove loading for segs
2020-05-06 18:17:44 +01:00

480 lines
14 KiB
JavaScript

import React, { Component } from 'react';
import { getImageData, loadImageData } from 'react-vtkjs-viewport';
import ConnectedVTKViewport from './ConnectedVTKViewport';
import LoadingIndicator from './LoadingIndicator.js';
import OHIF from '@ohif/core';
import PropTypes from 'prop-types';
import cornerstone from 'cornerstone-core';
import cornerstoneTools from 'cornerstone-tools';
import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray';
import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData';
import vtkVolume from 'vtk.js/Sources/Rendering/Core/Volume';
import vtkVolumeMapper from 'vtk.js/Sources/Rendering/Core/VolumeMapper';
const segmentationModule = cornerstoneTools.getModule('segmentation');
const { StackManager } = OHIF.utils;
// TODO: Figure out where we plan to put this long term
const volumeCache = {};
const labelmapCache = {};
/**
* Create a labelmap image with the same dimensions as our background volume.
*
* @param backgroundImageData vtkImageData
*/
/* TODO: Not currently used until we have drawing tools in vtkjs.
function createLabelMapImageData(backgroundImageData) {
// TODO => Need to do something like this if we start drawing a new segmentation
// On a vtkjs viewport.
const labelMapData = vtkImageData.newInstance(
backgroundImageData.get('spacing', 'origin', 'direction')
);
labelMapData.setDimensions(backgroundImageData.getDimensions());
labelMapData.computeTransforms();
const values = new Uint8Array(backgroundImageData.getNumberOfPoints());
const dataArray = vtkDataArray.newInstance({
numberOfComponents: 1, // labelmap with single component
values,
});
labelMapData.getPointData().setScalars(dataArray);
return labelMapData;
} */
class OHIFVTKViewport extends Component {
state = {
volumes: null,
paintFilterLabelMapImageData: null,
paintFilterBackgroundImageData: null
};
static propTypes = {
viewportData: PropTypes.shape({
studies: PropTypes.array,
displaySet: PropTypes.shape({
StudyInstanceUID: PropTypes.string,
displaySetInstanceUID: PropTypes.string,
sopClassUIDs: PropTypes.arrayOf(PropTypes.string),
SOPInstanceUID: PropTypes.string,
frameIndex: PropTypes.number,
}),
}),
viewportIndex: PropTypes.number,
children: PropTypes.node,
onScroll: PropTypes.func,
};
static defaultProps = {
onScroll: () => { },
};
static id = 'OHIFVTKViewport';
static init() {
console.log('OHIFVTKViewport init()');
}
static destroy() {
console.log('OHIFVTKViewport destroy()');
StackManager.clearStacks();
}
static getCornerstoneStack(
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPInstanceUID,
frameIndex
) {
// Create shortcut to displaySet
const study = studies.find(
study => study.StudyInstanceUID === StudyInstanceUID
);
const displaySet = study.displaySets.find(set => {
return set.displaySetInstanceUID === displaySetInstanceUID;
});
// Get stack from Stack Manager
const storedStack = StackManager.findOrCreateStack(study, displaySet);
// Clone the stack here so we don't mutate it
const stack = Object.assign({}, storedStack);
if (frameIndex !== undefined) {
stack.currentImageIdIndex = frameIndex;
} else if (SOPInstanceUID) {
const index = stack.imageIds.findIndex(imageId => {
const imageIdSOPInstanceUID = cornerstone.metaData.get(
'SOPInstanceUID',
imageId
);
return imageIdSOPInstanceUID === SOPInstanceUID;
});
if (index > -1) {
stack.currentImageIdIndex = index;
}
} else {
stack.currentImageIdIndex = 0;
}
return stack;
}
getViewportData = (
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPClassUID,
SOPInstanceUID,
frameIndex
) => {
const stack = OHIFVTKViewport.getCornerstoneStack(
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPClassUID,
SOPInstanceUID,
frameIndex
);
const imageDataObject = getImageData(stack.imageIds, displaySetInstanceUID);
let labelmapDataObject;
let labelmapColorLUT;
const firstImageId = stack.imageIds[0];
const { state } = segmentationModule;
const brushStackState = state.series[firstImageId];
if (brushStackState) {
const { activeLabelmapIndex } = brushStackState;
const labelmap3D = brushStackState.labelmaps3D[activeLabelmapIndex];
this.segmentsDefaultProperties = labelmap3D.segmentsHidden.map(isHidden => {
return { visible: !isHidden };
});
const vtkLabelmapID = `${firstImageId}_${activeLabelmapIndex}`;
if (labelmapCache[vtkLabelmapID]) {
labelmapDataObject = labelmapCache[vtkLabelmapID];
} else {
// TODO -> We need an imageId based getter in cornerstoneTools
const labelmapBuffer = labelmap3D.buffer;
// Create VTK Image Data with buffer as input
labelmapDataObject = vtkImageData.newInstance();
const dataArray = vtkDataArray.newInstance({
numberOfComponents: 1, // labelmap with single component
values: new Uint16Array(labelmapBuffer),
});
labelmapDataObject.getPointData().setScalars(dataArray);
labelmapDataObject.setDimensions(...imageDataObject.dimensions);
labelmapDataObject.setSpacing(
...imageDataObject.vtkImageData.getSpacing()
);
labelmapDataObject.setOrigin(
...imageDataObject.vtkImageData.getOrigin()
);
labelmapDataObject.setDirection(
...imageDataObject.vtkImageData.getDirection()
);
// Cache the labelmap volume.
labelmapCache[vtkLabelmapID] = labelmapDataObject;
}
labelmapColorLUT = state.colorLutTables[labelmap3D.colorLUTIndex];
}
return {
imageDataObject,
labelmapDataObject,
labelmapColorLUT,
};
};
/**
*
*
* @param {object} imageDataObject
* @param {object} imageDataObject.vtkImageData
* @param {object} imageDataObject.imageMetaData0
* @param {number} [imageDataObject.imageMetaData0.WindowWidth] - The volume's initial WindowWidth
* @param {number} [imageDataObject.imageMetaData0.WindowCenter] - The volume's initial WindowCenter
* @param {string} imageDataObject.imageMetaData0.Modality - CT, MR, PT, etc
* @param {string} displaySetInstanceUID
* @returns vtkVolumeActor
* @memberof OHIFVTKViewport
*/
getOrCreateVolume(imageDataObject, displaySetInstanceUID) {
if (volumeCache[displaySetInstanceUID]) {
return volumeCache[displaySetInstanceUID];
}
const { vtkImageData, imageMetaData0 } = imageDataObject;
// TODO -> Should update react-vtkjs-viewport and react-cornerstone-viewports
// internals to use naturalized DICOM JSON names.
const {
windowWidth: WindowWidth,
windowCenter: WindowCenter,
modality: Modality,
} = imageMetaData0;
const { lower, upper } = _getRangeFromWindowLevels(
WindowWidth,
WindowCenter,
Modality
);
const volumeActor = vtkVolume.newInstance();
const volumeMapper = vtkVolumeMapper.newInstance();
volumeActor.setMapper(volumeMapper);
volumeMapper.setInputData(vtkImageData);
volumeActor
.getProperty()
.getRGBTransferFunction(0)
.setRange(lower, upper);
const spacing = vtkImageData.getSpacing();
// Set the sample distance to half the mean length of one side. This is where the divide by 6 comes from.
// https://github.com/Kitware/VTK/blob/6b559c65bb90614fb02eb6d1b9e3f0fca3fe4b0b/Rendering/VolumeOpenGL2/vtkSmartVolumeMapper.cxx#L344
const sampleDistance = (spacing[0] + spacing[1] + spacing[2]) / 6;
volumeMapper.setSampleDistance(sampleDistance);
// Be generous to surpress warnings, as the logging really hurts performance.
// TODO: maybe we should auto adjust samples to 1000.
volumeMapper.setMaximumSamplesPerRay(4000);
volumeCache[displaySetInstanceUID] = volumeActor;
return volumeActor;
}
setStateFromProps() {
const { studies, displaySet } = this.props.viewportData;
const {
StudyInstanceUID,
displaySetInstanceUID,
sopClassUIDs,
SOPInstanceUID,
frameIndex,
} = displaySet;
if (sopClassUIDs.length > 1) {
console.warn(
'More than one SOPClassUID in the same series is not yet supported.'
);
}
const study = studies.find(
study => study.StudyInstanceUID === StudyInstanceUID
);
const dataDetails = {
studyDate: study.studyDate,
studyTime: study.studyTime,
studyDescription: study.studyDescription,
patientName: study.patientName,
patientId: study.patientId,
seriesNumber: String(displaySet.seriesNumber),
seriesDescription: displaySet.seriesDescription,
};
const {
imageDataObject,
labelmapDataObject,
labelmapColorLUT,
} = this.getViewportData(
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPInstanceUID,
frameIndex
);
this.imageDataObject = imageDataObject;
/* TODO: Not currently used until we have drawing tools in vtkjs.
if (!labelmap) {
labelmap = createLabelMapImageData(data);
} */
const volumeActor = this.getOrCreateVolume(
imageDataObject,
displaySetInstanceUID
);
this.setState(
{
percentComplete: 0,
dataDetails,
},
() => {
this.loadProgressively(imageDataObject);
// TODO: There must be a better way to do this.
// We do this so that if all the data is available the react-vtkjs-viewport
// Will render _something_ before the volumes are set and the volume
// Construction that happens in react-vtkjs-viewport locks up the CPU.
setTimeout(() => {
this.setState({
volumes: [volumeActor],
paintFilterLabelMapImageData: labelmapDataObject,
paintFilterBackgroundImageData: imageDataObject.vtkImageData,
labelmapColorLUT,
});
}, 200);
}
);
}
componentDidMount() {
this.setStateFromProps();
}
componentDidUpdate(prevProps, prevState) {
const { displaySet } = this.props.viewportData;
const prevDisplaySet = prevProps.viewportData.displaySet;
if (
displaySet.displaySetInstanceUID !==
prevDisplaySet.displaySetInstanceUID ||
displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID ||
displaySet.frameIndex !== prevDisplaySet.frameIndex
) {
this.setStateFromProps();
}
}
loadProgressively(imageDataObject) {
loadImageData(imageDataObject);
const { isLoading, insertPixelDataPromises } = imageDataObject;
const NumberOfFrames = insertPixelDataPromises.length;
if (!isLoading) {
this.setState({ isLoaded: true });
return;
}
insertPixelDataPromises.forEach(promise => {
promise.then(numberProcessed => {
const percentComplete = Math.floor(
(numberProcessed * 100) / NumberOfFrames
);
if (percentComplete !== this.state.percentComplete) {
this.setState({
percentComplete,
});
}
});
});
Promise.all(insertPixelDataPromises).then(() => {
this.setState({
isLoaded: true,
});
});
}
render() {
let childrenWithProps = null;
const { configuration } = segmentationModule;
// TODO: Does it make more sense to use Context?
if (this.props.children && this.props.children.length) {
childrenWithProps = this.props.children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportIndex: this.props.viewportIndex,
key: index,
})
);
});
}
const style = { width: '100%', height: '100%', position: 'relative' };
return (
<>
<div style={style}>
{!this.state.isLoaded && (
<LoadingIndicator percentComplete={this.state.percentComplete} />
)}
{this.state.volumes && (
<ConnectedVTKViewport
volumes={this.state.volumes}
paintFilterLabelMapImageData={
this.state.paintFilterLabelMapImageData
}
paintFilterBackgroundImageData={
this.state.paintFilterBackgroundImageData
}
viewportIndex={this.props.viewportIndex}
dataDetails={this.state.dataDetails}
labelmapRenderingOptions={{
colorLUT: this.state.labelmapColorLUT,
globalOpacity: configuration.fillAlpha,
visible: configuration.renderFill,
outlineThickness: configuration.outlineWidth,
renderOutline: configuration.renderOutline,
segmentsDefaultProperties: this.segmentsDefaultProperties,
onNewSegmentationRequested: () => {
this.setStateFromProps();
}
}}
onScroll={this.props.onScroll}
/>
)}
</div>
)}
{childrenWithProps}
</>
);
}
}
/**
* Takes window levels and converts them to a range (lower/upper)
* for use with VTK RGBTransferFunction
*
* @private
* @param {number} [width] - the width of our window
* @param {number} [center] - the center of our window
* @param {string} [Modality] - 'PT', 'CT', etc.
* @returns { lower, upper } - range
*/
function _getRangeFromWindowLevels(width, center, Modality = undefined) {
// For PET just set the range to 0-5 SUV
if (Modality === 'PT') {
return { lower: 0, upper: 5 };
}
const levelsAreNotNumbers = isNaN(center) || isNaN(width);
if (levelsAreNotNumbers) {
return { lower: 0, upper: 512 };
}
return {
lower: center - width / 2.0,
upper: center + width / 2.0,
};
}
export default OHIFVTKViewport;