UNPKG

diglettk

Version:

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

478 lines (426 loc) 13.9 kB
// Use modified MPRSlice interactor import vtkInteractorStyleMPRWindowLevel from "./vtk/vtkInteractorStyleMPRWindowLevel"; import vtkInteractorStyleMPRCrosshairs from "./vtk/vtkInteractorStyleMPRCrosshairs"; import vtkInteractorStyleMPRPanZoom from "./vtk/vtkInteractorStyleMPRPanZoom"; import vtkCoordinate from "@kitware/vtk.js/Rendering/Core/Coordinate"; import vtkMatrixBuilder from "@kitware/vtk.js/Common/Core/MatrixBuilder"; import { getPlaneIntersection, getVolumeCenter, createVolumeActor } from "./utils/utils"; import { MPRView } from "./mprView"; /** * Internal state of a single view * @typedef {Object} State * @property {Number[]} slicePlaneNormal - The slice plane normal as [x,y,z] * @property {Number[]} sliceViewUp - The up vector as [x,y,z] * @property {Number} slicePlaneXRotation - The x axis rotation in deg * @property {Number} slicePlaneYRotation - The y axis rotation in deg * @property {Number} viewRotation - The view rotation in deg * @property {Number} sliceThickness - The MIP slice thickness in px * @property {String} blendMode - The active blending mode ("MIP", "MinIP", "Average") * @property {Object} window - wwwl * @property {Number} window.ww - Window width * @property {Number} window.wl - Window level */ /** A manager for MPR views */ export class MPRManager { /** * Create a manager. * @param {Object} elements - The 3 target HTML elements {key1:{}, key2:{}, key3:{}}. * @param {HTMLElement} elements.element - The target HTML elements. * @param {String} elements.key - The target HTML elements. */ constructor(elements) { this.VERBOSE = false; // TODO setter this.syncWindowLevels = true; // TODO setter this._activeTool = null; // TODO input sanity check this.elements = elements; this.volume = null; this.sliceIntersection = [0, 0, 0]; this.mprViews = {}; this.initMPR(); } /** * wwwl * @type {Array} */ set wwwl([ww, wl]) { const lower = wl - ww / 2.0; const upper = wl + ww / 2.0; this.volume .getProperty() .getRGBTransferFunction(0) .setMappingRange(lower, upper); Object.keys(this.elements).forEach((key, i) => { this.mprViews[key].wwwl = [ww, wl]; }); } /** * Initialize the three MPR views * @private */ initMPR() { Object.keys(this.elements).forEach((key, i) => { try { this.mprViews[key] = new MPRView(key, i, this.elements[key].element); } catch (err) { console.error("Error creating MPRView", key); console.error(err); } }); if (this.VERBOSE) console.log("initialized"); } /** * Get initial State object * @returns {State} The initial internal state */ getInitialState() { // cycle on keys, and reduce extracting only useful properties // NOTE: initialize reduce with cloned object! let viewsState = Object.keys(this.mprViews).reduce((result, key) => { let { slicePlaneNormal, sliceViewUp, slicePlaneXRotation, slicePlaneYRotation, viewRotation, _sliceThickness, _blendMode, window } = result[key]; result[key] = { slicePlaneNormal, sliceViewUp, slicePlaneXRotation, slicePlaneYRotation, viewRotation, sliceThickness: _sliceThickness, blendMode: _blendMode, window }; return result; }, Object.assign({}, this.mprViews)); return { // interactorCenters: { top: [0, 0], left: [0, 0], front: [0, 0] }, interactorCenters: Object.keys(this.elements).reduce( (res, key) => ({ ...res, [key]: [0, 0] }), {} ), sliceIntersection: [...this.sliceIntersection], // clone views: viewsState }; } /** * Set the image to render * @param {State} state - The current manager state * @param {Array} image - The pixel data from DICOM serie */ setImage(state, image) { let actor = createVolumeActor(image); this.volume = actor; this.sliceIntersection = getVolumeCenter(actor.getMapper()); // update external state state.sliceIntersection = [...this.sliceIntersection]; Object.keys(this.elements).forEach(key => { this.mprViews[key].initView( actor, state, // on scroll callback (it's fired but too early) () => { this.onScrolled.call(this, state); }, // on initialized callback (fire when all is set) () => { this.onScrolled.call(this, state); } ); }); if (this._activeTool) { this.setTool(this._activeTool, state); } } /** * Set the active tool * @param {String} toolName - "level" or "crosshair" * @param {State} state - The current manager state */ setTool(toolName, state) { switch (toolName) { case "level": this.setLevelTool(state); break; case "crosshair": this.setCrosshairTool(state); break; case "zoom": this.setZoomTool(state); break; case "pan": this.setPanTool(state); break; } } /** * Set "pan" as active tool * @private * @param {State} state - The current manager state */ setPanTool(state) { Object.entries(state.views).forEach(([key]) => { const istyle = vtkInteractorStyleMPRPanZoom.newInstance({ leftButtonTool: "pan" }); istyle.setOnScroll(() => { this.onScrolled(state); }); // update interactor center istyle.setOnPanChanged(() => { this.updateInteractorCenters(state); }); this.mprViews[key].setInteractor(istyle); }); this._activeTool = "pan"; } /** * Set "zoom" as active tool * @private * @param {State} state - The current manager state */ setZoomTool(state) { Object.entries(state.views).forEach(([key]) => { const istyle = vtkInteractorStyleMPRPanZoom.newInstance({ leftButtonTool: "zoom" }); istyle.setOnScroll(() => { this.onScrolled(state); }); // update interactor center istyle.setOnZoomChanged(() => { this.updateInteractorCenters(state); }); this.mprViews[key].setInteractor(istyle); }); this._activeTool = "zoom"; } /** * Set "level" as active tool * @private * @param {State} state - The current manager state */ setLevelTool(state) { Object.entries(state.views).forEach(([key]) => { const istyle = vtkInteractorStyleMPRWindowLevel.newInstance(); istyle.setOnScroll(() => { this.onScrolled(state); }); istyle.setOnLevelsChanged(levels => { this.updateLevels({ ...levels, srcKey: key }, state); }); this.mprViews[key].setInteractor(istyle); }); this._activeTool = "level"; } /** * Set "crosshair" as active tool * @private * @param {State} state - The current manager state */ setCrosshairTool(state) { let self = this; Object.entries(state.views).forEach(([key]) => { const istyle = vtkInteractorStyleMPRCrosshairs.newInstance(); istyle.setOnScroll(() => { self.onScrolled(state); }); istyle.setOnClickCallback(({ worldPos }) => { self.onCrosshairPointSelected({ worldPos, srcKey: key }, state); }); this.mprViews[key].setInteractor(istyle); }); this._activeTool = "crosshair"; } /** * Update slice positions on user interaction (for crosshair tool) * @private * @param {Object} {} */ onCrosshairPointSelected({ srcKey, worldPos }, externalState) { Object.keys(this.elements).forEach(key => { if (key !== srcKey) { // We are basically doing the same as getSlice but with the world coordinate // that we want to jump to instead of the camera focal point. // I would rather do the camera adjustment directly but I keep // doing it wrong and so this is good enough for now. // ~ swerik const renderWindow = this.mprViews[ key ]._genericRenderWindow.getRenderWindow(); const istyle = renderWindow.getInteractor().getInteractorStyle(); const sliceNormal = istyle.getSliceNormal(); const transform = vtkMatrixBuilder .buildFromDegree() .identity() .rotateFromDirections(sliceNormal, [1, 0, 0]); const mutatedWorldPos = worldPos.slice(); transform.apply(mutatedWorldPos); const slice = mutatedWorldPos[0]; istyle.setSlice(slice); renderWindow.render(); } this.updateInteractorCenters(externalState); }); // update both internal & external state this.sliceIntersection = [...worldPos]; externalState.sliceIntersection = [...worldPos]; } /** * Update wwwl on user interaction (for level tool) * @private * @param {Object} {} * @param {State} state - The current manager state */ updateLevels({ windowCenter, windowWidth, srcKey }, state) { state.views[srcKey].window.center = windowCenter; state.views[srcKey].window.width = windowWidth; if (this.syncWindowLevels) { Object.keys(this.elements) .filter(key => key !== srcKey) .forEach(k => { this.mprViews[k].wwwl = [windowWidth, windowCenter]; }); } } /** * Update slice position when scrolling * @private */ onScrolled(state) { let planes = []; Object.keys(this.elements).forEach(key => { const camera = this.mprViews[key].camera; planes.push({ position: camera.getFocalPoint(), normal: camera.getDirectionOfProjection() // this[viewportIndex].slicePlaneNormal }); }); const newPoint = getPlaneIntersection(...planes); if ( !Number.isNaN(newPoint) && !newPoint.some(coord => Number.isNaN(coord)) ) { this.sliceIntersection = [...newPoint]; state.sliceIntersection = [...newPoint]; if (this.VERBOSE) console.log("updating slice intersection", newPoint); } this.updateInteractorCenters(state); return newPoint; } /** * Update slice planes on rotation * @param {String} key - One of the initially provided keys (identify a view) * @param {String} axis - 'x' or 'y' axis * @param {Number} angle - The amount of rotation [deg], absolute * @param {State} state - The current manager state */ onRotate(key, axis, angle, state) { // Match the source axis to the associated plane switch (key) { case "top": if (axis === "x") state.views.front.slicePlaneYRotation = angle; else if (axis === "y") state.views.left.slicePlaneYRotation = angle; break; case "left": if (axis === "x") state.views.top.slicePlaneXRotation = angle; else if (axis === "y") state.views.front.slicePlaneXRotation = angle; break; case "front": if (axis === "x") state.views.top.slicePlaneYRotation = angle; else if (axis === "y") state.views.left.slicePlaneXRotation = angle; break; } // dv: this was a watcher in mpr component, update all except myself ? Object.keys(this.elements) .filter(c => c !== key) .forEach(k => { this.mprViews[k].updateSlicePlane(state.views[k]); }); if (this.VERBOSE) console.log("afterOnRotate", state); } /** * Update slice planes on rotation * @param {String} key - One of the initially provided keys (identify a view) * @param {String} axis - 'x' or 'y' axis * @param {Number} thickness - The amount of thickness [px], absolute * @param {State} state - The current manager state */ onThickness(key, axis, thickness, state) { const shouldBeMIP = thickness > 1; let target_view; switch (key) { case "top": if (axis === "x") target_view = "front"; else if (axis === "y") target_view = "left"; break; case "left": if (axis === "x") target_view = "top"; else if (axis === "y") target_view = "front"; break; case "front": if (axis === "x") target_view = "top"; else if (axis === "y") target_view = "left"; break; } // if thickness > 1 switch to MIP if (shouldBeMIP && this.mprViews[target_view].blendMode === "none") { this.mprViews[target_view].blendMode = "MIP"; state.mprViews[target_view].blendMode = "MIP"; } // update both internal and external state this.mprViews[target_view].sliceThickness = thickness; state.views[target_view].sliceThickness = thickness; } /** * Update interactor centers coordinates on canvas * @private * @param {State} state - The current manager state */ updateInteractorCenters(state) { Object.keys(this.elements).forEach(key => { // compute interactor centers display position const renderer = this.mprViews[key]._genericRenderWindow.getRenderer(); const wPos = vtkCoordinate.newInstance(); wPos.setCoordinateSystemToWorld(); wPos.setValue(...this.sliceIntersection); const displayPosition = wPos.getComputedDisplayValue(renderer); if (this.VERBOSE) console.log("interactor center", key, displayPosition); // set new interactor center on canvas into external state state.interactorCenters[key] = displayPosition; }); } /** * Force views resize * @param {String} key - If provided, resize just its view, otherwise all views */ resize(state, key) { if (key) { this.mprViews[key].onResize(); } else { Object.values(this.mprViews).forEach(view => { view.onResize(); }); } this.updateInteractorCenters(state); } /** * Destroy webgl content and release listeners */ destroy() { Object.keys(this.elements).forEach(k => { this.mprViews[k].destroy(); }); } }