UNPKG

diglettk

Version:

A medical imaging toolkit, built on top of vtk.js

634 lines (566 loc) 18.3 kB
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray"; import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData"; import vtkPlane from "@kitware/vtk.js/Common/DataModel/Plane"; import vtkVolume from "@kitware/vtk.js/Rendering/Core/Volume"; import vtkVolumeMapper from "@kitware/vtk.js/Rendering/Core/VolumeMapper"; import vtkPiecewiseGaussianWidget from "@kitware/vtk.js/Interaction/Widgets/PiecewiseGaussianWidget"; import vtkImageCroppingWidget from "@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget"; import vtkImageCropFilter from "@kitware/vtk.js/Filters/General/ImageCropFilter"; import vtkWidgetManager from "@kitware/vtk.js/Widgets/Core/WidgetManager"; import vtkPlaneSource from "@kitware/vtk.js/Filters/Sources/PlaneSource"; import vtkMapper from "@kitware/vtk.js/Rendering/Core/Mapper"; import vtkActor from "@kitware/vtk.js/Rendering/Core/Actor"; import vtkSphereSource from "@kitware/vtk.js/Filters/Sources/SphereSource"; import vtkSTLReader from "@kitware/vtk.js/IO/Geometry/STLReader"; import vtkXMLPolyDataReader from "@kitware/vtk.js/IO/XML/XMLPolyDataReader"; import { vec3, quat, mat4 } from "gl-matrix"; import vtkGenericRenderWindow from "@kitware/vtk.js/Rendering/Misc/GenericRenderWindow"; const BUFFER_HEADER_SIZE = 100; /** * Build vtk volume (vtkImageData) * @param {Object} header * @param {TypedArray} data * @returns {vtkImageData} */ export function buildVtkVolume(header, data) { const dims = [ header.volume.cols, header.volume.rows, header.volume.imageIds.length ]; const numScalars = dims[0] * dims[1] * dims[2]; if (numScalars < 1 || dims[1] < 2 || dims[1] < 2 || dims[2] < 2) { return; } const volume = vtkImageData.newInstance(); const origin = header.volume.imagePosition; const spacing = header.volume.pixelSpacing.concat( header.volume.sliceThickness // TODO check ); volume.setDimensions(dims); volume.setOrigin(origin); volume.setSpacing(spacing); const scalars = vtkDataArray.newInstance({ name: "Scalars", values: data, numberOfComponents: 1 }); volume.getPointData().setScalars(scalars); volume.modified(); return volume; } /** * Fit camera to window * @param {vtkGenericRenderWindow} genericRenderWindow * @param {"x" | "y" | "z"} dir */ export function fitToWindow(genericRenderWindow, dir) { const bounds = genericRenderWindow.getRenderer().computeVisiblePropBounds(); const dim = [ (bounds[1] - bounds[0]) / 2, (bounds[3] - bounds[2]) / 2, (bounds[5] - bounds[4]) / 2 ]; const w = genericRenderWindow.getContainer().clientWidth; const h = genericRenderWindow.getContainer().clientHeight; const r = w / h; let x; let y; if (dir === "x") { x = dim[1]; y = dim[2]; } else if (dir === "y") { x = dim[0]; y = dim[2]; } else if (dir === "z") { x = dim[0]; y = dim[1]; } if (r >= x / y) { // use width genericRenderWindow .getRenderer() .getActiveCamera() .setParallelScale(y + 1); } else { // use height genericRenderWindow .getRenderer() .getActiveCamera() .setParallelScale(x / r + 1); } } /** * Utility function to read, parse, load and render a dcm serie with larvitar (tested with larvitar 1.2.7) */ let larvitarInitialized = false; export function loadDemoSerieWithLarvitar(name, lrv, cb) { let demoFiles = []; let counter = 0; let demoFileList = getDemoFileNames(); function getDemoFileNames() { let NOF = { knee: 24, thorax: 364, abdomen: 147 }; let numberOfFiles = NOF[name]; let demoFileList = []; for (let i = 1; i < numberOfFiles; i++) { let filename = `${name} (${i})`; if (name == "abdomen") filename += ".dcm"; demoFileList.push(filename); } return demoFileList; } async function createFile(fileName, cb) { let response = await fetch("./demo/" + fileName); let data = await response.blob(); let file = new File([data], fileName); demoFiles.push(file); counter++; if (counter == demoFileList.length) { cb(); } } if (!larvitarInitialized) { // init all larvitar lrv.initLarvitarStore(); lrv.initializeImageLoader(); lrv.initializeCSTools(); lrv.larvitar_store.addViewport("viewer"); larvitarInitialized = true; } // load dicom and render demoFileList.forEach(function (demoFile) { createFile(demoFile, () => { larvitar.resetLarvitarManager(); larvitar.readFiles(demoFiles).then(seriesStack => { // return the first series of the study let seriesId = Object.keys(seriesStack)[0]; let serie = seriesStack[seriesId]; // hack to avoid load and cache (render + timeout) lrv.renderImage(serie, "viewer"); cb(serie); }); }); }); } /** * Function to create synthetic image data with correct dimensions * Can be use for debug * @private * @param {Array} dims - Array[int] */ // eslint-disable-next-line no-unused-vars function createSyntheticImageData(dims) { const imageData = vtkImageData.newInstance(); const newArray = new Uint8Array(dims[0] * dims[1] * dims[2]); const s = 0.1; imageData.setSpacing(s, s, s); imageData.setExtent(0, 127, 0, 127, 0, 127); let i = 0; for (let z = 0; z < dims[2]; z++) { for (let y = 0; y < dims[1]; y++) { for (let x = 0; x < dims[0]; x++) { newArray[i++] = (256 * (i % (dims[0] * dims[1]))) / (dims[0] * dims[1]); } } } const da = vtkDataArray.newInstance({ numberOfComponents: 1, values: newArray }); da.setName("scalars"); imageData.getPointData().setScalars(da); return imageData; } /** * RGB string from RGB numeric values * @param {*} rgb * @returns {string} In the form rgb(128, 128, 128) */ export function createRGBStringFromRGBValues(rgb) { if (rgb.length !== 3) { return "rgb(0, 0, 0)"; } return `rgb(${(rgb[0] * 255).toString()}, ${(rgb[1] * 255).toString()}, ${( rgb[2] * 255 ).toString()})`; } /** * Convert angles DEG to RAD * @param {Number} degrees * @returns {Number} */ export function degrees2radians(degrees) { return (degrees * Math.PI) / 180; } /** * Compute the volume center * @param {vtkVolumeMapper} volumeMapper * @returns {Array} In the form [x,y,z] */ export function getVolumeCenter(volumeMapper) { const bounds = volumeMapper.getBounds(); return [ (bounds[0] + bounds[1]) / 2.0, (bounds[2] + bounds[3]) / 2.0, (bounds[4] + bounds[5]) / 2.0 ]; } /** * Compute image center and width (wwwl) * @param {vtkImageData} volume * @returns {Object} {windowCenter, windowWidth} */ export function getVOI(volume) { // Note: This controls window/level // TODO: Make this work reactively with onModified... const rgbTransferFunction = volume.getProperty().getRGBTransferFunction(0); const range = rgbTransferFunction.getMappingRange(); const windowWidth = range[0] + range[1]; const windowCenter = range[0] + windowWidth / 2; return { windowCenter, windowWidth }; } /** * Planes are of type `{position:[x,y,z], normal:[x,y,z]}` * returns an [x,y,z] array, or NaN if they do not intersect. * @private */ export const getPlaneIntersection = (plane1, plane2, plane3) => { try { let line = vtkPlane.intersectWithPlane( plane1.position, plane1.normal, plane2.position, plane2.normal ); if (line.intersection) { const { l0, l1 } = line; const intersectionLocation = vtkPlane.intersectWithLine( l0, l1, plane3.position, plane3.normal ); if (intersectionLocation.intersection) { return intersectionLocation.x; } } } catch (err) { console.log("some issue calculating the plane intersection", err); } return NaN; }; /** * * @param {*} contentData * @returns {vtkVolume} the volume actor */ export function createVolumeActor(contentData) { const volumeActor = vtkVolume.newInstance(); const volumeMapper = vtkVolumeMapper.newInstance(); volumeMapper.setSampleDistance(1); volumeActor.setMapper(volumeMapper); volumeMapper.setInputData(contentData); // set a default wwwl const dataRange = contentData.getPointData().getScalars().getRange(); // FIXME: custom range mapping const rgbTransferFunction = volumeActor .getProperty() .getRGBTransferFunction(0); rgbTransferFunction.setMappingRange(dataRange[0], dataRange[1]); // update slice min/max values for interface // Crate imageMapper for I,J,K planes // const dataRange = data // .getPointData() // .getScalars() // .getRange(); // const extent = data.getExtent(); // this.window = { // min: 0, // max: dataRange[1] * 2, // value: dataRange[1] // }; // this.level = { // min: -dataRange[1], // max: dataRange[1], // value: (dataRange[0] + dataRange[1]) / 2 // }; // this.updateColorLevel(); // this.updateColorWindow(); // TODO: find the volume center and set that as the slice intersection point. // TODO: Refactor the MPR slice to set the focal point instead of defaulting to volume center return volumeActor; } /** * Get info about webgl context (GPU) * @returns {Object} - {vendor, renderer} or {error} */ export function getVideoCardInfo() { const gl = document.createElement("canvas").getContext("webgl"); if (!gl) { return { error: "no webgl" }; } const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); return debugInfo ? { vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL), renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) } : { error: "no WEBGL_debug_renderer_info" }; } /** * * @param {*} imageData * @param {*} ijkPlanes * @returns {Array} array of vtkPlanes */ export function getCroppingPlanes(imageData, ijkPlanes) { const rotation = quat.create(); mat4.getRotation(rotation, imageData.getIndexToWorld()); const rotateVec = vec => { const out = [0, 0, 0]; vec3.transformQuat(out, vec, rotation); return out; }; const [iMin, iMax, jMin, jMax, kMin, kMax] = ijkPlanes; const origin = imageData.indexToWorld([iMin, jMin, kMin]); // opposite corner from origin const corner = imageData.indexToWorld([iMax, jMax, kMax]); return [ // X min/max vtkPlane.newInstance({ normal: rotateVec([1, 0, 0]), origin }), vtkPlane.newInstance({ normal: rotateVec([-1, 0, 0]), origin: corner }), // Y min/max vtkPlane.newInstance({ normal: rotateVec([0, 1, 0]), origin }), vtkPlane.newInstance({ normal: rotateVec([0, -1, 0]), origin: corner }), // X min/max vtkPlane.newInstance({ normal: rotateVec([0, 0, 1]), origin }), vtkPlane.newInstance({ normal: rotateVec([0, 0, -1]), origin: corner }) ]; } /** * Rescale abs range to relative range values (eg 0-1) * @param {*} actor * @param {*} absoluteRange * @returns {*} wwwl object */ export function getRelativeRange(actor, absoluteRange) { const dataArray = actor .getMapper() .getInputData() .getPointData() .getScalars(); const range = dataArray.getRange(); let rel_ww = absoluteRange[0] / (range[1] - range[0]); let rel_wl = (absoluteRange[1] - range[0]) / range[1]; return { ww: rel_ww, wl: rel_wl }; } /** * Rescale relative range to abs range values (eg hist min-max) * @param {*} actor * @param {*} relativeRange * @returns {*} wwwl object */ export function getAbsoluteRange(actor, relativeRange) { const dataArray = actor .getMapper() .getInputData() .getPointData() .getScalars(); const range = dataArray.getRange(); let abs_ww = relativeRange[0] * (range[1] - range[0]); let abs_wl = relativeRange[1] * range[1] + range[0]; return { ww: abs_ww, wl: abs_wl }; } /** * Set camera lookat point * @param {Array} center - As [x,y,z] */ export function setCamera(camera, center) { camera.zoom(1.5); camera.elevation(70); camera.setViewUp(0, 0, 1); camera.setFocalPoint(center[0], center[1], center[2]); camera.setPosition(center[0], center[1] - 2000, center[2]); camera.setThickness(10000); camera.setParallelProjection(true); } /** * Set actor appearance properties * @param {*} actor */ export function setActorProperties(actor) { actor.getProperty().setScalarOpacityUnitDistance(0, 30.0); actor.getProperty().setInterpolationTypeToLinear(); actor.getProperty().setUseGradientOpacity(0, true); actor.getProperty().setGradientOpacityMinimumValue(0, 2); actor.getProperty().setGradientOpacityMinimumOpacity(0, 0.0); actor.getProperty().setGradientOpacityMaximumValue(0, 20); actor.getProperty().setGradientOpacityMaximumOpacity(0, 2.0); actor.getProperty().setShade(true); actor.getProperty().setAmbient(0.3); actor.getProperty().setDiffuse(0.7); actor.getProperty().setSpecular(0.3); actor.getProperty().setSpecularPower(0.8); } /** * Append a vtkPiecewiseGaussianWidget into the target element * @private * @param {HTMLElement} widgetContainer - The target element to place the widget * @returns {vtkPiecewiseGaussianWidget} */ export function setupPGwidget(PGwidgetElement) { let containerWidth = PGwidgetElement ? PGwidgetElement.offsetWidth - 5 : 300; let containerHeight = PGwidgetElement ? PGwidgetElement.offsetHeight - 5 : 100; const PGwidget = vtkPiecewiseGaussianWidget.newInstance({ numberOfBins: 256, size: [containerWidth, containerHeight] }); // TODO expose style PGwidget.updateStyle({ backgroundColor: "rgba(255, 255, 255, 0.6)", histogramColor: "rgba(50, 50, 50, 0.8)", strokeColor: "rgb(0, 0, 0)", activeColor: "rgb(255, 255, 255)", handleColor: "rgb(50, 150, 50)", buttonDisableFillColor: "rgba(255, 255, 255, 0.5)", buttonDisableStrokeColor: "rgba(0, 0, 0, 0.5)", buttonStrokeColor: "rgba(0, 0, 0, 1)", buttonFillColor: "rgba(255, 255, 255, 1)", strokeWidth: 1, activeStrokeWidth: 1.5, buttonStrokeWidth: 1, handleWidth: 1, iconSize: 0, // Can be 0 if you want to remove buttons (dblClick for (+) / rightClick for (-)) padding: 1 }); // to hide widget PGwidget.setContainer(PGwidgetElement); // Set to null to hide // resize callback window.addEventListener("resize", evt => { PGwidget.setSize( PGwidgetElement.offsetWidth - 5, PGwidgetElement.offsetHeight - 5 ); PGwidget.render(); }); return PGwidget; } /** * Initialize a crop widget */ export function setupCropWidget(renderer, volumeMapper) { let image = volumeMapper.getInputData(); console.log(image.getBounds()); // setup widget manager and widget const widgetManager = vtkWidgetManager.newInstance(); widgetManager.setRenderer(renderer); const widget = vtkImageCroppingWidget.newInstance(); widget.copyImageDataDescription(image); const viewWidget = widgetManager.addWidget(widget); widgetManager.enablePicking(); const cropState = widget.getWidgetState().getCroppingPlanes(); cropState.onModified(e => { const planes = getCroppingPlanes(image, cropState.getPlanes()); volumeMapper.removeAllClippingPlanes(); planes.forEach(plane => { volumeMapper.addClippingPlane(plane); }); volumeMapper.modified(); }); widget.set({ faceHandlesEnabled: true, edgeHandlesEnabled: true, cornerHandlesEnabled: true }); return { widget, widgetManager }; // or viewWidget ? } /** * Create a plane to perform picking * @param {*} camera * @param {*} actor * @returns {Object} - {plane: vtkPlane, planeActor: vtkActor} */ export function setupPickingPlane(camera, actor) { const plane = vtkPlaneSource.newInstance({ xResolution: 1000, yResolution: 1000 }); plane.setPoint1(0, 0, 1000); plane.setPoint2(1000, 0, 0); plane.setCenter(actor.getCenter()); plane.setNormal(camera.getDirectionOfProjection()); const mapper = vtkMapper.newInstance(); mapper.setInputConnection(plane.getOutputPort()); const planeActor = vtkActor.newInstance(); planeActor.setMapper(mapper); planeActor.getProperty().setOpacity(0.01); // with opacity = 0 it is ignored by picking return { plane, planeActor }; } /** * Add a sphere in a specific point (useful for debugging) */ export function addSphereInPoint(point, renderer) { const sphere = vtkSphereSource.newInstance(); sphere.setCenter(point); sphere.setRadius(0.01); const sphereMapper = vtkMapper.newInstance(); sphereMapper.setInputData(sphere.getOutputData()); const sphereActor = vtkActor.newInstance(); sphereActor.setMapper(sphereMapper); sphereActor.getProperty().setColor(1.0, 0.0, 0.0); renderer.addActor(sphereActor); } /** * Create a surface actor from buffer data * @param {ArrayBuffer} buffer - The surface data buffer * @param {String} fileType - Optional file type ('stl' or 'vtp') * @returns {Object} - {actor: vtkActor, mapper: vtkMapper} */ export function createSurfaceActor(buffer, fileType) { let reader; // Determine file type and create appropriate reader if (fileType) { // Use explicit file type if provided if (fileType.toLowerCase() === 'stl') { reader = vtkSTLReader.newInstance(); reader.parseAsArrayBuffer(buffer); } else if (fileType.toLowerCase() === 'vtp') { reader = vtkXMLPolyDataReader.newInstance(); reader.parseAsArrayBuffer(buffer); } else { console.error(`DTK: Unsupported file type: ${fileType}. Supported types are 'stl' and 'vtp'.`); return null; } } else { // Try to detect file type from buffer content const uint8Array = new Uint8Array(buffer, 0, BUFFER_HEADER_SIZE); const headerString = String.fromCharCode.apply(null, uint8Array); if (headerString.includes('<?xml') && headerString.includes('PolyData')) { // VTP file (XML-based) reader = vtkXMLPolyDataReader.newInstance(); reader.parseAsArrayBuffer(buffer); } else { // Default to STL reader for binary or ASCII STL files reader = vtkSTLReader.newInstance(); reader.parseAsArrayBuffer(buffer); } } const mapper = vtkMapper.newInstance({ scalarVisibility: false }); const actor = vtkActor.newInstance(); actor.setMapper(mapper); mapper.setInputConnection(reader.getOutputPort()); return { actor, mapper }; }