feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664)
Co-authored-by: Alireza <ar.sedghi@gmail.com> Co-authored-by: Neil <neil.a.macphee@gmail.com>
This commit is contained in:
200 files changed
+9589
-2178
No files matched your search
+31
@@ -0,0 +1,31 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ServicesManager } from '@ohif/core';
|
||||
import { useViewportGrid } from '@ohif/ui';
|
||||
import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel';
|
||||
|
||||
const ActiveViewportWindowLevel = ({
|
||||
servicesManager,
|
||||
}: {
|
||||
servicesManager: ServicesManager;
|
||||
}): ReactElement => {
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { activeViewportId } = viewportGrid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeViewportId && (
|
||||
<ViewportWindowLevel
|
||||
servicesManager={servicesManager}
|
||||
viewportId={activeViewportId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveViewportWindowLevel.propTypes = {
|
||||
servicesManager: PropTypes.instanceOf(ServicesManager),
|
||||
};
|
||||
|
||||
export default ActiveViewportWindowLevel;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ActiveViewportWindowLevel';
|
||||
@@ -1,73 +1,108 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { CinePlayer, useCine } from '@ohif/ui';
|
||||
import { Enums, eventTarget } from '@cornerstonejs/core';
|
||||
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
|
||||
import { Enums as StreamingEnums } from '@cornerstonejs/streaming-image-volume-loader';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
|
||||
const { customizationService, displaySetService, viewportGridService } = servicesManager.services;
|
||||
const [{ isCineEnabled, cines }, api] = useCine();
|
||||
const [{ isCineEnabled, cines }, cineService] = useCine();
|
||||
const [newStackFrameRate, setNewStackFrameRate] = useState(24);
|
||||
const [dynamicInfo, setDynamicInfo] = useState(null);
|
||||
const [appConfig] = useAppConfig();
|
||||
const isMountedRef = useRef(null);
|
||||
|
||||
const { component: CinePlayerComponent = CinePlayer } =
|
||||
customizationService.get('cinePlayer') ?? {};
|
||||
|
||||
const cineHandler = () => {
|
||||
if (!cines || !cines[viewportId] || !enabledVPElement) {
|
||||
if (!cines?.[viewportId] || !enabledVPElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cine = cines[viewportId];
|
||||
const isPlaying = cine.isPlaying || false;
|
||||
const frameRate = cine.frameRate || 24;
|
||||
|
||||
const { isPlaying = false, frameRate = 24 } = cines[viewportId];
|
||||
const validFrameRate = Math.max(frameRate, 1);
|
||||
|
||||
if (isPlaying) {
|
||||
api.playClip(enabledVPElement, {
|
||||
framesPerSecond: validFrameRate,
|
||||
});
|
||||
} else {
|
||||
api.stopClip(enabledVPElement);
|
||||
}
|
||||
return isPlaying
|
||||
? cineService.playClip(enabledVPElement, { framesPerSecond: validFrameRate })
|
||||
: cineService.stopClip(enabledVPElement);
|
||||
};
|
||||
|
||||
const newStackCineHandler = useCallback(() => {
|
||||
const newDisplaySetHandler = useCallback(() => {
|
||||
if (!enabledVPElement || !isCineEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewports } = viewportGridService.getState();
|
||||
const { displaySetInstanceUIDs } = viewports.get(viewportId);
|
||||
|
||||
let frameRate = 24;
|
||||
let isPlaying = cines[viewportId].isPlaying;
|
||||
let isPlaying = cines[viewportId]?.isPlaying || false;
|
||||
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
if (displaySet.FrameRate) {
|
||||
// displaySet.FrameRate corresponds to DICOM tag (0018,1063) which is defined as the the frame time in milliseconds
|
||||
// So a bit of math to get the actual frame rate.
|
||||
frameRate = Math.round(1000 / displaySet.FrameRate);
|
||||
isPlaying ||= !!appConfig.autoPlayCine;
|
||||
}
|
||||
|
||||
// check if the displaySet is dynamic and set the dynamic info
|
||||
if (displaySet.isDynamicVolume) {
|
||||
const { dynamicVolumeInfo } = displaySet;
|
||||
const numTimePoints = dynamicVolumeInfo.timePoints.length;
|
||||
const label = dynamicVolumeInfo.splittingTag;
|
||||
const timePointIndex = dynamicVolumeInfo.timePointIndex || 0;
|
||||
setDynamicInfo({
|
||||
volumeId: displaySet.displaySetInstanceUID,
|
||||
timePointIndex,
|
||||
numTimePoints,
|
||||
label,
|
||||
});
|
||||
} else {
|
||||
setDynamicInfo(null);
|
||||
}
|
||||
});
|
||||
|
||||
if (isPlaying) {
|
||||
api.setIsCineEnabled(isPlaying);
|
||||
cineService.setIsCineEnabled(isPlaying);
|
||||
}
|
||||
api.setCine({ id: viewportId, isPlaying, frameRate });
|
||||
cineService.setCine({ id: viewportId, isPlaying, frameRate });
|
||||
setNewStackFrameRate(frameRate);
|
||||
}, [displaySetService, viewportId, viewportGridService, cines]);
|
||||
}, [displaySetService, viewportId, viewportGridService, cines, isCineEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
eventTarget.addEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
|
||||
newDisplaySetHandler();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
api.stopClip(enabledVPElement);
|
||||
api.setCine({ id: viewportId, isPlaying: false });
|
||||
eventTarget.removeEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
|
||||
};
|
||||
}, [enabledVPElement, newStackCineHandler]);
|
||||
}, [isCineEnabled, newDisplaySetHandler]);
|
||||
|
||||
/**
|
||||
* Use effect for handling new display set
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!enabledVPElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newDisplaySetHandler);
|
||||
// this doesn't makes sense that we are listening to this event on viewport element
|
||||
enabledVPElement.addEventListener(
|
||||
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
|
||||
newDisplaySetHandler
|
||||
);
|
||||
|
||||
return () => {
|
||||
cineService.setCine({ id: viewportId, isPlaying: false });
|
||||
|
||||
eventTarget.removeEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newDisplaySetHandler);
|
||||
enabledVPElement.removeEventListener(
|
||||
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
|
||||
newDisplaySetHandler
|
||||
);
|
||||
};
|
||||
}, [enabledVPElement, newDisplaySetHandler, viewportId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cines || !cines[viewportId] || !enabledVPElement || !isMountedRef.current) {
|
||||
@@ -77,41 +112,118 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
|
||||
cineHandler();
|
||||
|
||||
return () => {
|
||||
api.stopClip(enabledVPElement);
|
||||
cineService.stopClip(enabledVPElement);
|
||||
};
|
||||
}, [cines, viewportId, enabledVPElement, cineHandler]);
|
||||
}, [cines, viewportId, cineService, enabledVPElement, cineHandler]);
|
||||
|
||||
if (!isCineEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cine = cines[viewportId];
|
||||
const isPlaying = (cine && cine.isPlaying) || false;
|
||||
const isPlaying = cine?.isPlaying || false;
|
||||
|
||||
return (
|
||||
isCineEnabled && (
|
||||
<CinePlayerComponent
|
||||
className="absolute left-1/2 bottom-3 -translate-x-1/2"
|
||||
frameRate={newStackFrameRate}
|
||||
isPlaying={isPlaying}
|
||||
onClose={() => {
|
||||
// also stop the clip
|
||||
api.setCine({
|
||||
id: viewportId,
|
||||
isPlaying: false,
|
||||
});
|
||||
api.setIsCineEnabled(false);
|
||||
}}
|
||||
onPlayPauseChange={isPlaying => {
|
||||
api.setCine({
|
||||
id: viewportId,
|
||||
isPlaying,
|
||||
});
|
||||
}}
|
||||
onFrameRateChange={frameRate =>
|
||||
api.setCine({
|
||||
id: viewportId,
|
||||
frameRate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
<RenderCinePlayer
|
||||
viewportId={viewportId}
|
||||
cineService={cineService}
|
||||
newStackFrameRate={newStackFrameRate}
|
||||
isPlaying={isPlaying}
|
||||
dynamicInfo={dynamicInfo}
|
||||
customizationService={customizationService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderCinePlayer({
|
||||
viewportId,
|
||||
cineService,
|
||||
newStackFrameRate,
|
||||
isPlaying,
|
||||
dynamicInfo: dynamicInfoProp,
|
||||
customizationService,
|
||||
}) {
|
||||
const { component: CinePlayerComponent = CinePlayer } =
|
||||
customizationService.get('cinePlayer') ?? {};
|
||||
|
||||
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicInfo(dynamicInfoProp);
|
||||
}, [dynamicInfoProp]);
|
||||
|
||||
/**
|
||||
* Use effect for handling 4D time index changed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!dynamicInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTimePointIndexChange = evt => {
|
||||
const { volumeId, timePointIndex, numTimePoints, splittingTag } = evt.detail;
|
||||
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
|
||||
};
|
||||
|
||||
eventTarget.addEventListener(
|
||||
StreamingEnums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
|
||||
handleTimePointIndexChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener(
|
||||
StreamingEnums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
|
||||
handleTimePointIndexChange
|
||||
);
|
||||
};
|
||||
}, [dynamicInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dynamicInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { volumeId, timePointIndex, numTimePoints, splittingTag } = dynamicInfo || {};
|
||||
const volume = cache.getVolume(volumeId);
|
||||
volume.timePointIndex = timePointIndex;
|
||||
|
||||
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
|
||||
}, []);
|
||||
|
||||
const updateDynamicInfo = useCallback(props => {
|
||||
const { volumeId, timePointIndex } = props;
|
||||
const volume = cache.getVolume(volumeId);
|
||||
volume.timePointIndex = timePointIndex;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CinePlayerComponent
|
||||
className="absolute left-1/2 bottom-3 -translate-x-1/2"
|
||||
frameRate={newStackFrameRate}
|
||||
isPlaying={isPlaying}
|
||||
onClose={() => {
|
||||
// also stop the clip
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
isPlaying: false,
|
||||
});
|
||||
cineService.setIsCineEnabled(false);
|
||||
}}
|
||||
onPlayPauseChange={isPlaying => {
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
isPlaying,
|
||||
});
|
||||
}}
|
||||
onFrameRateChange={frameRate =>
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
frameRate,
|
||||
})
|
||||
}
|
||||
dynamicInfo={dynamicInfo}
|
||||
updateDynamicInfo={updateDynamicInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useEffect, useCallback, useState, ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { ServicesManager } from '@ohif/core';
|
||||
import { PanelSection, WindowLevel } from '@ohif/ui';
|
||||
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
|
||||
import { Enums, eventTarget, cache as cs3DCache, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
|
||||
|
||||
const { Events } = Enums;
|
||||
|
||||
const ViewportWindowLevel = ({
|
||||
servicesManager,
|
||||
viewportId,
|
||||
}: {
|
||||
servicesManager: ServicesManager;
|
||||
viewportId: string;
|
||||
}): ReactElement => {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [windowLevels, setWindowLevels] = useState([]);
|
||||
const [cachedHistograms, setCachedHistograms] = useState({});
|
||||
|
||||
/**
|
||||
* Looks for all viewports that has exactly all volumeIds passed as parameter.
|
||||
*/
|
||||
const getViewportsWithVolumeIds = useCallback(
|
||||
(volumeIds: string[]) => {
|
||||
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
||||
const viewports = renderingEngine.getVolumeViewports();
|
||||
|
||||
return viewports.filter(vp => {
|
||||
const viewportVolumeIds = vp.getActors().map(actor => actor.uid);
|
||||
|
||||
return (
|
||||
volumeIds.length === viewportVolumeIds.length &&
|
||||
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
|
||||
);
|
||||
});
|
||||
},
|
||||
[cornerstoneViewportService]
|
||||
);
|
||||
|
||||
const getNodeOpacity = (volumeActor, nodeIndex) => {
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
const nodeValue = [];
|
||||
|
||||
volumeOpacity.getNodeValue(nodeIndex, nodeValue);
|
||||
|
||||
return nodeValue[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the opacity applied to the PET volume is something like
|
||||
* [{x: 0, y: 0}, {x: 0.1, y: [C]}, {x: [ANY], y: [C]}] where C is a
|
||||
* constant opacity value for all x's greater than 0.1
|
||||
*/
|
||||
const isPetVolumeWithDefaultOpacity = (volumeId, volumeActor) => {
|
||||
const volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
if (!volume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const modality = volume.metadata.Modality;
|
||||
|
||||
if (modality !== 'PT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
|
||||
// It must have at least two points (0 and 0.1)
|
||||
if (volumeOpacity.getSize() < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const node1Value = [];
|
||||
const node2Value = [];
|
||||
|
||||
volumeOpacity.getNodeValue(0, node1Value);
|
||||
volumeOpacity.getNodeValue(1, node2Value);
|
||||
|
||||
// First node must be (x:0, y:0} and the second one {x:0.1, y:any}
|
||||
if (node1Value[0] !== 0 || node1Value[1] !== 0 || node2Value[0] !== 0.1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedOpacity = node2Value[1];
|
||||
const opacitySize = volumeOpacity.getSize();
|
||||
const currentNodeValue = [];
|
||||
|
||||
// Any point after 0.1 must have the same opacity
|
||||
for (let i = 2; i < opacitySize; i++) {
|
||||
volumeOpacity.getNodeValue(i, currentNodeValue);
|
||||
|
||||
if (currentNodeValue[1] !== expectedOpacity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the opacity function has a constance opacity value for all x's
|
||||
*/
|
||||
const isVolumeWithConstantOpacity = volumeActor => {
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
const opacitySize = volumeOpacity.getSize();
|
||||
const firstNodeValue = [];
|
||||
|
||||
volumeOpacity.getNodeValue(0, firstNodeValue);
|
||||
|
||||
const firstNodeOpacity = firstNodeValue[1];
|
||||
|
||||
for (let i = 0; i < opacitySize; i++) {
|
||||
const currentNodeValue = [];
|
||||
|
||||
volumeOpacity.getNodeValue(0, currentNodeValue);
|
||||
|
||||
if (currentNodeValue[1] !== firstNodeOpacity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getVolumeOpacity = useCallback((viewport, volumeId) => {
|
||||
const volumeActor = viewport.getActor(volumeId).actor;
|
||||
|
||||
if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) {
|
||||
// Get the opacity from the second node at 0.1
|
||||
return getNodeOpacity(volumeActor, 1);
|
||||
} else if (isVolumeWithConstantOpacity(volumeActor)) {
|
||||
return getNodeOpacity(volumeActor, 0);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const getWindowLevelsData = useCallback(
|
||||
async (viewportId: number) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
const volumeIds = viewport.getActors().map(actor => actor.uid);
|
||||
const viewportProperties = viewport.getProperties();
|
||||
const { voiRange } = viewportProperties;
|
||||
const viewportVoi = voiRange
|
||||
? {
|
||||
windowWidth: voiRange.upper - voiRange.lower,
|
||||
windowCenter: voiRange.lower + (voiRange.upper - voiRange.lower) / 2,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const windowLevels = await Promise.all(
|
||||
volumeIds.map(async (volumeId, volumeIndex) => {
|
||||
const volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
if (!volume) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opacity = getVolumeOpacity(viewport, volumeId);
|
||||
const { metadata, scaling } = volume;
|
||||
const modality = metadata.Modality;
|
||||
|
||||
// TODO: find a proper way to fix the histogram
|
||||
const options = {
|
||||
min: modality === 'PT' ? 0.1 : -999,
|
||||
max: modality === 'PT' ? 5 : 10000,
|
||||
};
|
||||
|
||||
const histogram =
|
||||
cachedHistograms[volumeId] ??
|
||||
(await getViewportVolumeHistogram(viewport, volume, options));
|
||||
const { voi: displaySetVOI, colormap: displaySetColormap } =
|
||||
viewportInfo.displaySetOptions[volumeIndex];
|
||||
let colormap;
|
||||
if (displaySetColormap) {
|
||||
colormap =
|
||||
csUtils.colormap.getColormap(displaySetColormap.name) ??
|
||||
vtkColorMaps.getPresetByName(displaySetColormap.name);
|
||||
}
|
||||
|
||||
const voi = !volumeIndex ? viewportVoi ?? displaySetVOI : displaySetVOI;
|
||||
|
||||
return {
|
||||
viewportId,
|
||||
modality,
|
||||
volumeId,
|
||||
volumeIndex,
|
||||
voi,
|
||||
histogram,
|
||||
colormap,
|
||||
step: scaling?.PT ? 0.05 : 1,
|
||||
opacity,
|
||||
showOpacitySlider: volumeIndex === 1 && opacity !== undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const data = windowLevels.filter(Boolean);
|
||||
return data;
|
||||
},
|
||||
|
||||
[cachedHistograms, cornerstoneViewportService, getVolumeOpacity]
|
||||
);
|
||||
|
||||
const updateViewportHistograms = useCallback(() => {
|
||||
getWindowLevelsData(viewportId).then(windowLevels => {
|
||||
setWindowLevels(windowLevels);
|
||||
});
|
||||
}, [viewportId, getWindowLevelsData]);
|
||||
|
||||
const handleCornerstoneVOIModified = useCallback(
|
||||
e => {
|
||||
const { detail } = e;
|
||||
const { volumeId, range } = detail;
|
||||
const oldWindowLevel = windowLevels.find(wl => wl.volumeId === volumeId);
|
||||
|
||||
if (!oldWindowLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldVOI = oldWindowLevel.voi;
|
||||
const windowWidth = range.upper - range.lower;
|
||||
const windowCenter = range.lower + windowWidth / 2;
|
||||
|
||||
if (windowWidth === oldVOI.windowWidth && windowCenter === oldVOI.windowCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newWindowLevel = {
|
||||
...oldWindowLevel,
|
||||
voi: {
|
||||
windowWidth,
|
||||
windowCenter,
|
||||
},
|
||||
};
|
||||
|
||||
setWindowLevels(
|
||||
windowLevels.map(windowLevel =>
|
||||
windowLevel === oldWindowLevel ? newWindowLevel : windowLevel
|
||||
)
|
||||
);
|
||||
},
|
||||
[windowLevels]
|
||||
);
|
||||
|
||||
const debouncedHandleCornerstoneVOIModified = useCallback(
|
||||
debounce(handleCornerstoneVOIModified, 100),
|
||||
[handleCornerstoneVOIModified]
|
||||
);
|
||||
|
||||
const handleVOIChange = useCallback(
|
||||
(volumeId, voi) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
const newRange = {
|
||||
lower: voi.windowCenter - voi.windowWidth / 2,
|
||||
upper: voi.windowCenter + voi.windowWidth / 2,
|
||||
};
|
||||
|
||||
viewport.setProperties({ voiRange: newRange }, volumeId);
|
||||
viewport.render();
|
||||
},
|
||||
[cornerstoneViewportService, viewportId]
|
||||
);
|
||||
|
||||
const handleOpacityChange = useCallback(
|
||||
(viewportId, _volumeIndex, volumeId, opacity) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportVolumeIds = viewport.getActors().map(actor => actor.uid);
|
||||
const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
|
||||
|
||||
viewports.forEach(vp => {
|
||||
vp.setProperties({ colormap: { opacity } }, volumeId);
|
||||
vp.render();
|
||||
});
|
||||
},
|
||||
[getViewportsWithVolumeIds, cornerstoneViewportService]
|
||||
);
|
||||
|
||||
// Listen to windowLevels changes and caches all the new ones
|
||||
useEffect(() => {
|
||||
const newVolumeHistograms = windowLevels
|
||||
.filter(windowLevel => !cachedHistograms[windowLevel.volumeId] && windowLevel.histogram)
|
||||
.reduce((volumeHistograms, windowLevel) => {
|
||||
// If the histogram exists, add it to the volumeHistograms object
|
||||
if (windowLevel?.histogram) {
|
||||
volumeHistograms[windowLevel.volumeId] = windowLevel.histogram;
|
||||
}
|
||||
return volumeHistograms;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(newVolumeHistograms).length) {
|
||||
setCachedHistograms(prev => ({ ...prev, ...newVolumeHistograms }));
|
||||
}
|
||||
}, [windowLevels, cachedHistograms]);
|
||||
|
||||
// Updates the histogram when the viewport index prop has changed
|
||||
useEffect(() => updateViewportHistograms(), [viewportId, updateViewportHistograms]);
|
||||
|
||||
// Listen to cornerstone events on "eventTarget" and at the document level
|
||||
useEffect(() => {
|
||||
eventTarget.addEventListener(Events.IMAGE_VOLUME_LOADING_COMPLETED, updateViewportHistograms);
|
||||
|
||||
document.addEventListener(Events.VOI_MODIFIED, debouncedHandleCornerstoneVOIModified, true);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener(
|
||||
Events.IMAGE_VOLUME_LOADING_COMPLETED,
|
||||
updateViewportHistograms
|
||||
);
|
||||
|
||||
document.removeEventListener(
|
||||
Events.VOI_MODIFIED,
|
||||
debouncedHandleCornerstoneVOIModified,
|
||||
true
|
||||
);
|
||||
};
|
||||
}, [updateViewportHistograms, debouncedHandleCornerstoneVOIModified]);
|
||||
|
||||
// Updates the viewport when the context of the viewport has changed. This is
|
||||
// necessary when moving across different stages because the viewport index
|
||||
// may not change but the volumes loaded on it may change.
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = cornerstoneViewportService.subscribe(
|
||||
cornerstoneViewportService.EVENTS.VIEWPORT_VOLUMES_CHANGED,
|
||||
({ viewportInfo }) => {
|
||||
if (viewportInfo.viewportId === viewportId) {
|
||||
updateViewportHistograms();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewportId, cornerstoneViewportService, updateViewportHistograms]);
|
||||
|
||||
return (
|
||||
<PanelSection title="Window Level">
|
||||
{windowLevels.map((windowLevel, i) => {
|
||||
if (!windowLevel.histogram) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowLevel
|
||||
key={windowLevel.volumeId}
|
||||
title={`${windowLevel.modality}`}
|
||||
histogram={windowLevel.histogram}
|
||||
voi={windowLevel.voi}
|
||||
step={windowLevel.step}
|
||||
showOpacitySlider={windowLevel.showOpacitySlider}
|
||||
colormap={windowLevel.colormap}
|
||||
onVOIChange={voi => handleVOIChange(windowLevel.volumeId, voi)}
|
||||
opacity={windowLevel.opacity}
|
||||
onOpacityChange={opacity =>
|
||||
handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
ViewportWindowLevel.propTypes = {
|
||||
servicesManager: PropTypes.instanceOf(ServicesManager),
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ViewportWindowLevel;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { getWebWorkerManager } from '@cornerstonejs/core';
|
||||
|
||||
const workerManager = getWebWorkerManager();
|
||||
|
||||
const options = {
|
||||
// maxWorkerInstances: 1,
|
||||
// overwrite: false
|
||||
autoTerminationOnIdle: 1000,
|
||||
};
|
||||
|
||||
// Register the task
|
||||
const workerFn = () => {
|
||||
return new Worker(new URL('./histogramWorker.js', import.meta.url), {
|
||||
name: 'histogram-worker', // name used by the browser to name the worker
|
||||
});
|
||||
};
|
||||
|
||||
workerManager.registerWorker('histogram-worker', workerFn, options);
|
||||
|
||||
const getViewportVolumeHistogram = async (viewport, volume, options?) => {
|
||||
if (!volume?.loadStatus.loaded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const volumeImageData = viewport.getImageData(volume.volumeId);
|
||||
|
||||
if (!volumeImageData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let scalarData = volume.scalarData;
|
||||
|
||||
let prevTimePoint;
|
||||
if (volume.numTimePoints > 1) {
|
||||
prevTimePoint = volume.timePointIndex;
|
||||
const middleTimePoint = Math.round(volume.numTimePoints / 2);
|
||||
volume.timePointIndex = middleTimePoint;
|
||||
scalarData = volume.getScalarData(middleTimePoint);
|
||||
}
|
||||
|
||||
const { dimensions, origin, direction, spacing } = volume;
|
||||
|
||||
const range = await workerManager.executeTask('histogram-worker', 'getRange', {
|
||||
dimensions,
|
||||
origin,
|
||||
direction,
|
||||
spacing,
|
||||
scalarData,
|
||||
});
|
||||
|
||||
// after we calculate the range let's reset the timePointIndex
|
||||
if (volume.numTimePoints > 1) {
|
||||
volume.timePointIndex = prevTimePoint;
|
||||
}
|
||||
const { minimum: min, maximum: max } = range;
|
||||
const calcHistOptions = {
|
||||
numBins: 256,
|
||||
min: Math.max(min, options?.min ?? min),
|
||||
max: Math.min(max, options?.max ?? max),
|
||||
};
|
||||
|
||||
const histogram = await workerManager.executeTask('histogram-worker', 'calcHistogram', {
|
||||
data: scalarData,
|
||||
options: calcHistOptions,
|
||||
});
|
||||
|
||||
return histogram;
|
||||
};
|
||||
|
||||
export { getViewportVolumeHistogram };
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expose } from 'comlink';
|
||||
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
|
||||
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
|
||||
|
||||
/**
|
||||
* This object simulates a heavy task by implementing a sleep function and a recursive Fibonacci function.
|
||||
* It's used for testing or demonstrating purposes where a heavy or time-consuming task is needed.
|
||||
*/
|
||||
const obj = {
|
||||
getRange: ({ dimensions, origin, direction, spacing, scalarData }) => {
|
||||
const imageData = vtkImageData.newInstance();
|
||||
imageData.setDimensions(dimensions);
|
||||
imageData.setOrigin(origin);
|
||||
imageData.setDirection(direction);
|
||||
imageData.setSpacing(spacing);
|
||||
|
||||
const scalarArray = vtkDataArray.newInstance({
|
||||
name: 'Pixels',
|
||||
numberOfComponents: 1,
|
||||
values: scalarData,
|
||||
});
|
||||
|
||||
imageData.getPointData().setScalars(scalarArray);
|
||||
|
||||
imageData.modified();
|
||||
|
||||
const range = imageData.computeHistogram(imageData.getBounds());
|
||||
|
||||
return range;
|
||||
},
|
||||
calcHistogram: ({ data, options }) => {
|
||||
if (options === undefined) {
|
||||
options = {};
|
||||
}
|
||||
const histogram = {
|
||||
numBins: options.numBins || 256,
|
||||
range: { min: 0, max: 0 },
|
||||
bins: new Int32Array(1),
|
||||
maxBin: 0,
|
||||
maxBinValue: 0,
|
||||
};
|
||||
|
||||
let minToUse = options.min;
|
||||
let maxToUse = options.max;
|
||||
|
||||
if (minToUse === undefined || maxToUse === undefined) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let index = data.length;
|
||||
|
||||
while (index--) {
|
||||
const value = data[index];
|
||||
if (value < min) {
|
||||
min = value;
|
||||
}
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
|
||||
minToUse = min;
|
||||
maxToUse = max;
|
||||
}
|
||||
|
||||
histogram.range = { min: minToUse, max: maxToUse };
|
||||
|
||||
const bins = new Int32Array(histogram.numBins);
|
||||
const binScale = histogram.numBins / (maxToUse - minToUse);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
const value = data[index];
|
||||
if (value < minToUse) {
|
||||
continue;
|
||||
}
|
||||
if (value > maxToUse) {
|
||||
continue;
|
||||
}
|
||||
const bin = Math.floor((value - minToUse) * binScale);
|
||||
bins[bin] += 1;
|
||||
}
|
||||
|
||||
histogram.bins = bins;
|
||||
histogram.maxBin = 0;
|
||||
histogram.maxBinValue = 0;
|
||||
|
||||
for (let bin = 0; bin < histogram.numBins; bin++) {
|
||||
if (histogram.bins[bin] > histogram.maxBinValue) {
|
||||
histogram.maxBin = bin;
|
||||
histogram.maxBinValue = histogram.bins[bin];
|
||||
}
|
||||
}
|
||||
|
||||
return histogram;
|
||||
},
|
||||
};
|
||||
|
||||
expose(obj);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ViewportWindowLevel';
|
||||
Reference in new issue
Block a user