feat(tmtv-mode): Add Brush tools and move SUV peak calculation to web worker (#4053)

This commit is contained in:
Alireza 2024-04-19 15:11:31 -04:00 committed by GitHub
parent 8c5ab942b9
commit 8192e348ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 593 additions and 212 deletions

View File

@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.70.9",
"@cornerstonejs/core": "^1.70.9",
"@cornerstonejs/adapters": "^1.70.10",
"@cornerstonejs/core": "^1.70.10",
"@kitware/vtk.js": "30.3.3",
"react-color": "^2.19.3"
}

View File

@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.70.9",
"@cornerstonejs/core": "^1.70.9",
"@cornerstonejs/tools": "^1.70.9",
"@cornerstonejs/adapters": "^1.70.10",
"@cornerstonejs/core": "^1.70.10",
"@cornerstonejs/tools": "^1.70.10",
"classnames": "^2.3.2"
}
}

View File

@ -42,9 +42,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^1.70.9",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.9",
"@cornerstonejs/tools": "^1.70.9",
"@cornerstonejs/core": "^1.70.10",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.10",
"@cornerstonejs/tools": "^1.70.10",
"classnames": "^2.3.2"
}
}

View File

@ -43,21 +43,21 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
return [
{
name: 'dynamic-volume',
iconName: 'group-layers',
iconName: 'tab-4d',
iconLabel: '4D Workflow',
label: '4D Workflow',
component: wrappedDynamicDataPanel,
},
{
name: 'dynamic-toolbox',
iconName: 'group-layers',
iconName: 'tab-4d',
iconLabel: '4D Workflow',
label: 'Dynamic Toolbox',
component: wrappedDynamicToolbox,
},
{
name: 'dynamic-export',
iconName: 'group-layers',
iconName: 'tab-4d',
iconLabel: '4D Workflow',
label: '4D Workflow',
component: wrappedDynamicExport,

View File

@ -38,7 +38,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"@cornerstonejs/dicom-image-loader": "^1.70.9",
"@cornerstonejs/dicom-image-loader": "^1.70.10",
"@icr/polyseg-wasm": "^0.4.0",
"@ohif/core": "3.8.0-beta.86",
"@ohif/ui": "3.8.0-beta.86",
@ -55,10 +55,10 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.70.9",
"@cornerstonejs/core": "^1.70.9",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.9",
"@cornerstonejs/tools": "^1.70.9",
"@cornerstonejs/adapters": "^1.70.10",
"@cornerstonejs/core": "^1.70.10",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.10",
"@cornerstonejs/tools": "^1.70.10",
"@icr/polyseg-wasm": "^0.4.0",
"@kitware/vtk.js": "30.3.3",
"html2canvas": "^1.4.1",

View File

@ -346,9 +346,9 @@ function commandsModule({
}
}
},
setToolActiveToolbar: ({ value, itemId, toolGroupIds = [] }) => {
setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [] }) => {
// Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons)
const toolName = itemId || value;
toolName = toolName || itemId || value;
toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds();

View File

@ -2,10 +2,12 @@ import { getWebWorkerManager } from '@cornerstonejs/core';
const workerManager = getWebWorkerManager();
const options = {
// maxWorkerInstances: 1,
// overwrite: false
autoTerminationOnIdle: 1000,
const WorkerOptions = {
maxWorkerInstances: 1,
autoTerminateOnIdle: {
enabled: true,
idleTimeThreshold: 1000,
},
};
// Register the task
@ -15,9 +17,9 @@ const workerFn = () => {
});
};
workerManager.registerWorker('histogram-worker', workerFn, options);
const getViewportVolumeHistogram = async (viewport, volume, options?) => {
workerManager.registerWorker('histogram-worker', workerFn, WorkerOptions);
if (!volume?.loadStatus.loaded) {
return undefined;
}

View File

@ -279,19 +279,18 @@ export default async function init({
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
colormaps.forEach(registerColormap);
// Create a debounced function that shows the notification
const debouncedShowNotification = debounce(detail => {
uiNotificationService.show({
title: detail.type,
message: detail.message,
type: 'error',
});
}, 300);
// Event listener
eventTarget.addEventListener(EVENTS.ERROR_EVENT, ({ detail }) => {
debouncedShowNotification(detail);
});
eventTarget.addEventListenerDebounced(
EVENTS.ERROR_EVENT,
({ detail }) => {
uiNotificationService.show({
title: detail.type,
message: detail.message,
type: 'error',
});
},
1000
);
}
function CPUModal() {

View File

@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^1.70.9",
"@cornerstonejs/tools": "^1.70.9",
"@cornerstonejs/core": "^1.70.10",
"@cornerstonejs/tools": "^1.70.10",
"@ohif/core": "3.8.0-beta.86",
"@ohif/extension-cornerstone-dicom-sr": "3.8.0-beta.86",
"@ohif/ui": "3.8.0-beta.86",

View File

@ -2,11 +2,16 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Icon, ActionButtons } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import { eventTarget } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { handleROIThresholding } from '../../utils/handleROIThresholding';
export default function PanelRoiThresholdSegmentation({ servicesManager, commandsManager }) {
const { segmentationService, uiNotificationService } = servicesManager.services;
const { t } = useTranslation('PanelSUVExport');
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const [activeSegmentation, setActiveSegmentation] = useState(null);
/**
* Update UI based on segmentation changes (added, removed, updated)
@ -22,7 +27,11 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
const activeSegmentation = segmentations.filter(seg => seg.isActive);
setActiveSegmentation(activeSegmentation[0]);
});
subscriptions.push(unsubscribe);
});
@ -33,26 +42,53 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
};
}, []);
const tmtvValue = segmentations?.[0]?.cachedStats?.tmtv?.value || null;
const config = segmentations?.[0]?.cachedStats?.tmtv?.config || {};
useEffect(() => {
const callback = async evt => {
const { detail } = evt;
const { segmentationId } = detail;
segmentations.forEach(segmentation => {
const { cachedStats } = segmentation;
if (!cachedStats) {
return;
}
if (!segmentationId) {
return;
}
// segment 1
const suvPeak = cachedStats?.['1']?.suvPeak?.suvPeak;
if (Number.isNaN(suvPeak)) {
uiNotificationService.show({
title: 'SUV Peak',
message: 'Segmented volume does not allow SUV Peak calculation',
type: 'warning',
await handleROIThresholding({
segmentationId,
commandsManager,
segmentationService,
});
}
});
const segmentation = segmentationService.getSegmentation(segmentationId);
const { cachedStats } = segmentation;
if (!cachedStats) {
return;
}
// segment 1
const suvPeak = cachedStats?.['1']?.suvPeak?.suvPeak;
if (Number.isNaN(suvPeak)) {
uiNotificationService.show({
title: 'SUV Peak',
message: 'Segmented volume does not allow SUV Peak calculation',
type: 'warning',
});
}
};
eventTarget.addEventListenerDebounced(Enums.Events.SEGMENTATION_DATA_MODIFIED, callback, 300);
return () => {
eventTarget.removeEventListenerDebounced(Enums.Events.SEGMENTATION_DATA_MODIFIED, callback);
};
}, []);
if (!activeSegmentation) {
return null;
}
const tmtvValue = activeSegmentation.cachedStats?.tmtv?.value || null;
const config = activeSegmentation.cachedStats?.tmtv?.config || {};
const actions = [
{
@ -67,7 +103,7 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
disabled: tmtvValue === null,
},
{
label: 'Create RT Report',
label: 'Export RT Report',
onClick: () => {
commandsManager.runCommand('createTMTVRTReport');
},
@ -80,7 +116,7 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
<div className="mt-2 mb-10 flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-1 flex items-baseline justify-between px-2 py-1">
<div className="bg-secondary-dark flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>

View File

@ -62,72 +62,16 @@ function RectangleROIOptions({ servicesManager, commandsManager }) {
const handleROIThresholding = useCallback(() => {
const segmentationId = selectedSegmentationId;
const segmentation = segmentationService.getSegmentation(segmentationId);
const activeSegmentIndex =
cs3dTools.segmentation.segmentIndex.getActiveSegmentIndex(segmentationId);
// run the threshold based on the active segment index
// Todo: later find a way to associate each rectangle with a segment (e.g., maybe with color?)
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId,
config,
segmentIndex: activeSegmentIndex,
});
// re-calculating the cached stats for the active segmentation
const updatedPerSegmentCachedStats = {};
segmentation.segments = segmentation.segments.map(segment => {
if (!segment || !segment.segmentIndex) {
return segment;
}
const segmentIndex = segment.segmentIndex;
const lesionStats = runCommand('getLesionStats', { labelmap, segmentIndex });
const suvPeak = runCommand('calculateSuvPeak', { labelmap, segmentIndex });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
segment.cachedStats = cachedStats;
segment.displayText = [
`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`,
`Volume: ${lesionStats.volume.toFixed(2)} mm3`,
];
updatedPerSegmentCachedStats[segmentIndex] = cachedStats;
return segment;
});
const notYetUpdatedAtSource = true;
const segmentations = segmentationService.getSegmentations();
const tmtv = runCommand('calculateTMTV', { segmentations });
segmentation.cachedStats = Object.assign(
segmentation.cachedStats,
updatedPerSegmentCachedStats,
{
tmtv: {
value: tmtv.toFixed(3),
config: { ...config },
},
}
);
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
},
false, // don't suppress events
notYetUpdatedAtSource
);
}, [selectedSegmentationId, config]);
useEffect(() => {
@ -171,27 +115,6 @@ function RectangleROIOptions({ servicesManager, commandsManager }) {
};
}, []);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
() => {
const segmentations = segmentationService.getSegmentations();
if (segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
handleROIThresholding();
} else {
setSelectedSegmentationId(null);
handleROIThresholding();
}
}
);
return () => {
unsubscribe();
};
}, []);
return (
<div className="invisible-scrollbar mb-2 flex flex-col overflow-y-auto overflow-x-hidden">
<ROIThresholdConfiguration

View File

@ -4,12 +4,13 @@ import * as cs from '@cornerstonejs/core';
import * as csTools from '@cornerstonejs/tools';
import { classes } from '@ohif/core';
import getThresholdValues from './utils/getThresholdValue';
import calculateSuvPeak from './utils/calculateSUVPeak';
import calculateTMTV from './utils/calculateTMTV';
import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
import { getWebWorkerManager } from '@cornerstonejs/core';
const metadataProvider = classes.MetadataProvider;
const RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS = [
'RectangleROIStartEndThreshold',
@ -17,6 +18,23 @@ const RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS = [
];
const LABELMAP = csTools.Enums.SegmentationRepresentations.Labelmap;
const workerManager = getWebWorkerManager();
const options = {
maxWorkerInstances: 1,
autoTerminateOnIdle: {
enabled: true,
idleTimeThreshold: 3000,
},
};
// Register the task
const workerFn = () => {
return new Worker(new URL('./utils/calculateSUVPeakWorker.js', import.meta.url), {
name: 'suv-peak-worker', // name used by the browser to name the worker
});
};
const commandsModule = ({ servicesManager, commandsManager, extensionManager }) => {
const {
viewportGridService,
@ -265,7 +283,10 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
{ overwrite: true, segmentIndex }
);
},
calculateSuvPeak: ({ labelmap, segmentIndex }) => {
calculateSuvPeak: async ({ labelmap, segmentIndex }) => {
// if we put it in the top, it will appear in other modes
workerManager.registerWorker('suv-peak-worker', workerFn, options);
const { referencedVolumeId } = labelmap;
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
@ -277,7 +298,31 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
csTools.annotation.state.getAnnotation(annotationUID)
);
const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations, segmentIndex);
const labelmapProps = {
dimensions: labelmap.dimensions,
origin: labelmap.origin,
direction: labelmap.direction,
spacing: labelmap.spacing,
scalarData: labelmap.scalarData,
metadata: labelmap.metadata,
};
const referenceVolumeProps = {
dimensions: referencedVolume.dimensions,
origin: referencedVolume.origin,
direction: referencedVolume.direction,
spacing: referencedVolume.spacing,
scalarData: referencedVolume.scalarData,
metadata: referencedVolume.metadata,
};
const suvPeak = await workerManager.executeTask('suv-peak-worker', 'calculateSuvPeak', {
labelmapProps,
referenceVolumeProps,
annotations,
segmentIndex,
});
return {
suvPeak: suvPeak.mean,
suvMax: suvPeak.max,

View File

@ -1,18 +1,32 @@
import { Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import { expose } from 'comlink';
type AnnotationsForThresholding = {
data: {
handles: {
points: Types.Point3[];
};
cachedStats?: {
projectionPoints?: Types.Point3[][];
};
const createVolume = ({ dimensions, origin, direction, spacing, scalarData, metadata }) => {
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();
return {
imageData,
metadata,
getScalarData: () => scalarData,
};
};
/**
* This method calculates the SUV peak on a segmented ROI from a reference PET
* volume. If a rectangle annotation is provided, the peak is calculated within that
@ -25,17 +39,10 @@ type AnnotationsForThresholding = {
* @param segmentIndex The index of the segment to use for masking
* @returns
*/
function calculateSuvPeak(
labelmap: Types.IImageVolume,
referenceVolume: Types.IImageVolume,
annotations?: AnnotationsForThresholding[],
segmentIndex = 1
): {
max: number;
maxIJK: Types.Point3;
maxLPS: Types.Point3;
mean: number;
} {
function calculateSuvPeak({ labelmapProps, referenceVolumeProps, annotations, segmentIndex = 1 }) {
const labelmap = createVolume(labelmapProps);
const referenceVolume = createVolume(referenceVolumeProps);
if (referenceVolume.metadata.Modality !== 'PT') {
return;
}
@ -59,7 +66,7 @@ function calculateSuvPeak(
const rectangleCornersIJK = pointsToUse.map(world => {
const ijk = vec3.fromValues(0, 0, 0);
referenceVolumeImageData.worldToIndex(world, ijk);
return ijk as Types.Point3;
return ijk;
});
boundsIJK = utilities.boundingBox.getBoundingBoxAroundShape(rectangleCornersIJK, dimensions);
@ -88,7 +95,7 @@ function calculateSuvPeak(
utilities.pointInShapeCallback(labelmapImageData, () => true, callback, boundsIJK);
const direction = labelmapImageData.getDirection().slice(0, 3) as Types.Point3;
const direction = labelmapImageData.getDirection().slice(0, 3);
/**
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
@ -100,10 +107,10 @@ function calculateSuvPeak(
const secondaryCircleWorld = vec3.create();
const bottomWorld = vec3.create();
const topWorld = vec3.create();
referenceVolumeImageData.indexToWorld(maxIJK as vec3, secondaryCircleWorld);
referenceVolumeImageData.indexToWorld(maxIJK, secondaryCircleWorld);
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
const suvPeakCirclePoints = [bottomWorld, topWorld] as [Types.Point3, Types.Point3];
const suvPeakCirclePoints = [bottomWorld, topWorld];
/**
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
@ -132,4 +139,8 @@ function calculateSuvPeak(
};
}
export default calculateSuvPeak;
const obj = {
calculateSuvPeak,
};
expose(obj);

View File

@ -0,0 +1,64 @@
import { cache } from '@cornerstonejs/core';
export const handleROIThresholding = async ({
segmentationId,
commandsManager,
segmentationService,
config = {},
}) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
// re-calculating the cached stats for the active segmentation
const updatedPerSegmentCachedStats = {};
segmentation.segments = await Promise.all(
segmentation.segments.map(async segment => {
if (!segment || !segment.segmentIndex) {
return segment;
}
const labelmap = cache.getVolume(segmentationId);
const segmentIndex = segment.segmentIndex;
const lesionStats = commandsManager.run('getLesionStats', { labelmap, segmentIndex });
const suvPeak = await commandsManager.run('calculateSuvPeak', { labelmap, segmentIndex });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
segment.cachedStats = cachedStats;
segment.displayText = [
`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`,
`Volume: ${lesionStats.volume.toFixed(2)} mm3`,
];
updatedPerSegmentCachedStats[segmentIndex] = cachedStats;
return segment;
})
);
const notYetUpdatedAtSource = true;
const segmentations = segmentationService.getSegmentations();
const tmtv = commandsManager.run('calculateTMTV', { segmentations });
segmentation.cachedStats = Object.assign(segmentation.cachedStats, updatedPerSegmentCachedStats, {
tmtv: {
value: tmtv.toFixed(3),
config: { ...config },
},
});
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
},
false, // don't suppress events
notYetUpdatedAtSource
);
};

View File

@ -29,7 +29,7 @@ const toolbarButtons: Button[] = [
commands: _createSetToolActiveCommands('CircularBrush'),
options: [
{
name: 'Radius (mm)',
name: 'Size (mm)',
id: 'brush-radius',
type: 'range',
min: 0.5,
@ -131,9 +131,9 @@ const toolbarButtons: Button[] = [
type: 'double-range',
id: 'threshold-range',
min: 0,
max: 10,
step: 1,
values: [2, 5],
max: 100,
step: 0.5,
values: [2, 50],
commands: {
commandName: 'setThresholdRange',
commandOptions: {

View File

@ -99,7 +99,10 @@ function modeFactory({ modeConfiguration }) {
'Pan',
'SyncToggle',
]);
toolbarService.createButtonSection('ROIThresholdToolbox', ['RectangleROIStartEndThreshold']);
toolbarService.createButtonSection('ROIThresholdToolbox', [
'RectangleROIStartEndThreshold',
'BrushTools',
]);
customizationService.addModeCustomizations([
{

View File

@ -4,7 +4,6 @@ export const toolGroupIds = {
Fusion: 'fusionToolGroup',
MIP: 'mipToolGroup',
default: 'default',
// MPR: 'mpr',
};
function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager, modeLabelConfig) {
@ -59,6 +58,67 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager, mo
{ toolName: toolNames.Angle },
{ toolName: toolNames.CobbAngle },
{ toolName: toolNames.Magnify },
{
toolName: 'CircularBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'FILL_INSIDE_CIRCLE',
},
},
{
toolName: 'CircularEraser',
parentTool: 'Brush',
configuration: {
activeStrategy: 'ERASE_INSIDE_CIRCLE',
},
},
{
toolName: 'SphereBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'FILL_INSIDE_SPHERE',
},
},
{
toolName: 'SphereEraser',
parentTool: 'Brush',
configuration: {
activeStrategy: 'ERASE_INSIDE_SPHERE',
},
},
{
toolName: 'ThresholdCircularBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
},
},
{
toolName: 'ThresholdSphereBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_SPHERE',
},
},
{
toolName: 'ThresholdCircularBrushDynamic',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
// preview: {
// enabled: true,
// },
strategySpecificConfiguration: {
// to use the use the center segment index to determine
// if inside -> same segment, if outside -> eraser
// useCenterSegmentIndex: true,
THRESHOLD: {
isDynamic: true,
dynamicRadius: 3,
},
},
},
},
],
enabled: [{ toolName: toolNames.SegmentationDisplay }],
disabled: [

View File

@ -1,8 +1,6 @@
import { defaults, ToolbarService } from '@ohif/core';
import { ToolbarService } from '@ohif/core';
import { toolGroupIds } from './initToolGroups';
const { windowLevelPresets } = defaults;
const setToolActiveToolbar = {
commandName: 'setToolActiveToolbar',
commandOptions: {
@ -10,6 +8,18 @@ const setToolActiveToolbar = {
},
};
function _createSetToolActiveCommands(toolName) {
return [
{
commandName: 'setToolActiveToolbar',
commandOptions: {
toolName,
toolGroupIds: [toolGroupIds.CT, toolGroupIds.PT, toolGroupIds.Fusion],
},
},
];
}
const toolbarButtons = [
{
id: 'MeasurementTools',
@ -107,13 +117,183 @@ const toolbarButtons = [
icon: 'tool-create-threshold',
label: 'Rectangle ROI Threshold',
commands: setToolActiveToolbar,
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: 'Select the PT Axial to enable this tool',
},
evaluate: [
'evaluate.cornerstone.segmentation',
// need to put the disabled text last, since each evaluator will
// merge the result text into the final result
{
name: 'evaluate.cornerstoneTool',
disabledText: 'Select the PT Axial to enable this tool',
},
],
options: 'tmtv.RectangleROIThresholdOptions',
},
},
{
id: 'BrushTools',
uiType: 'ohif.buttonGroup',
props: {
groupId: 'BrushTools',
items: [
{
id: 'Brush',
icon: 'icon-tool-brush',
label: 'Brush',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularBrush', 'SphereBrush'],
disabledText: 'Create new segmentation to enable this tool.',
},
commands: _createSetToolActiveCommands('CircularBrush'),
options: [
{
name: 'Radius (mm)',
id: 'brush-radius',
type: 'range',
min: 0.5,
max: 99.5,
step: 0.5,
value: 25,
commands: {
commandName: 'setBrushSize',
commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] },
},
},
{
name: 'Shape',
type: 'radio',
id: 'brush-mode',
value: 'CircularBrush',
values: [
{ value: 'CircularBrush', label: 'Circle' },
{ value: 'SphereBrush', label: 'Sphere' },
],
commands: 'setToolActiveToolbar',
},
],
},
{
id: 'Eraser',
icon: 'icon-tool-eraser',
label: 'Eraser',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularEraser', 'SphereEraser'],
},
commands: _createSetToolActiveCommands('CircularEraser'),
options: [
{
name: 'Radius (mm)',
id: 'eraser-radius',
type: 'range',
min: 0.5,
max: 99.5,
step: 0.5,
value: 25,
commands: {
commandName: 'setBrushSize',
commandOptions: { toolNames: ['CircularEraser', 'SphereEraser'] },
},
},
{
name: 'Shape',
type: 'radio',
id: 'eraser-mode',
value: 'CircularEraser',
values: [
{ value: 'CircularEraser', label: 'Circle' },
{ value: 'SphereEraser', label: 'Sphere' },
],
commands: 'setToolActiveToolbar',
},
],
},
{
id: 'Threshold',
icon: 'icon-tool-threshold',
label: 'Threshold Tool',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
},
commands: _createSetToolActiveCommands('ThresholdCircularBrush'),
options: [
{
name: 'Radius (mm)',
id: 'threshold-radius',
type: 'range',
min: 0.5,
max: 99.5,
step: 0.5,
value: 25,
commands: {
commandName: 'setBrushSize',
commandOptions: {
toolNames: [
'ThresholdCircularBrush',
'ThresholdSphereBrush',
'ThresholdCircularBrushDynamic',
],
},
},
},
{
name: 'Threshold',
type: 'radio',
id: 'dynamic-mode',
value: 'ThresholdRange',
values: [
{ value: 'ThresholdDynamic', label: 'Dynamic' },
{ value: 'ThresholdRange', label: 'Range' },
],
commands: ({ value, commandsManager }) => {
if (value === 'ThresholdDynamic') {
commandsManager.run('setToolActive', {
toolName: 'ThresholdCircularBrushDynamic',
});
} else {
commandsManager.run('setToolActive', {
toolName: 'ThresholdCircularBrush',
});
}
},
},
{
name: 'Shape',
type: 'radio',
id: 'eraser-mode',
value: 'ThresholdCircularBrush',
values: [
{ value: 'ThresholdCircularBrush', label: 'Circle' },
{ value: 'ThresholdSphereBrush', label: 'Sphere' },
],
condition: ({ options }) =>
options.find(option => option.id === 'dynamic-mode').value === 'ThresholdRange',
commands: 'setToolActiveToolbar',
},
{
name: 'ThresholdRange',
type: 'double-range',
id: 'threshold-range',
min: 0,
max: 50,
step: 0.5,
values: [2.5, 50],
condition: ({ options }) =>
options.find(option => option.id === 'dynamic-mode').value === 'ThresholdRange',
commands: {
commandName: 'setThresholdRange',
commandOptions: {
toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
},
},
},
],
},
],
},
},
];
export default toolbarButtons;

View File

@ -54,7 +54,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^1.70.9",
"@cornerstonejs/dicom-image-loader": "^1.70.10",
"@emotion/serialize": "^1.1.3",
"@ohif/core": "3.8.0-beta.86",
"@ohif/extension-cornerstone": "3.8.0-beta.86",

View File

@ -37,7 +37,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"@cornerstonejs/dicom-image-loader": "^1.70.9",
"@cornerstonejs/dicom-image-loader": "^1.70.10",
"@ohif/ui": "3.8.0-beta.86",
"cornerstone-math": "0.1.9",
"dicom-parser": "^1.8.21"

View File

@ -107,8 +107,8 @@ export default class PanelService extends PubSubService {
// stack the content of the panels in one react component
content = () => (
<>
{panelsData.map(({ content: PanelContent }) => (
<PanelContent key={entry.id} />
{panelsData.map(({ content: PanelContent }, index) => (
<PanelContent key={index} />
))}
</>
);

View File

@ -438,7 +438,11 @@ export default class ToolbarService extends PubSubService {
}
if (Array.isArray(evaluate)) {
const evaluators = evaluate.map(evaluatorName => {
const evaluators = evaluate.map(evaluator => {
const isObject = typeof evaluator === 'object';
const evaluatorName = isObject ? evaluator.name : evaluator;
const evaluateFunction = this._evaluateFunction[evaluatorName];
if (!evaluateFunction) {
@ -447,6 +451,10 @@ export default class ToolbarService extends PubSubService {
);
}
if (isObject) {
return args => evaluateFunction({ ...args, ...evaluator });
}
return evaluateFunction;
});

View File

@ -137,6 +137,36 @@ this pattern, where multiple toolbar buttons are using the same evaluator but wi
},
```
#### Composing evaluators
You can choose to set up multiple evaluators for a single button. This comes in handy when you need to assess the button according to various conditions. For example, we aim to prevent the Cine player from showing up on the 3D viewport, so we have:
```js
evaluate: ['evaluate.cine', 'evaluate.not3D'],
```
You can even come up with advanced evaluators such as:
```js
evaluate: [
'evaluate.cornerstone.segmentation',
// need to put the disabled text last, since each evaluator will
// merge the result text into the final result
{
name: 'evaluate.cornerstoneTool',
disabledText: 'Select the PT Axial to enable this tool',
},
],
```
that we use for our RectangleROIStartEndThreshold tool in tmtv mode.
As you see this evaluator is composed of two evaluators, one is `evaluate.cornerstone.segmentation` which makes sure (in the implementation), that
there is a segmentation created, and the second one is `evaluate.cornerstoneTool` which makes sure that the tool is available in the viewport.
Since we are using multiple evaluators, the `disabledText` of each evaluator will be merged into the final result, so you need to
put the `disabledText` in the last evaluator.
#### Group evaluators
Split buttons (see in [ToolbarService](../../services/data/ToolbarService.md) on how to define one) may feature a group evaluator, we provide two of them and you can write your own.

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>tab-4d</title>
<g id="tab-4d" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" transform="translate(1, 1)" stroke="currentColor">
<rect id="Rectangle" x="0" y="0" width="20" height="20" rx="2"></rect>
<g id="Group-3" transform="translate(4, 5)" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.9072464,0.398231009 C9.3565092,0.398231009 11.342029,2.38375081 11.342029,4.83301362 C11.342029,7.28227642 9.3565092,9.26779623 6.9072464,9.26779623 L6.9072464,0.398231009 Z" id="Path"></path>
<polyline id="Path-2" points="3.27497101 4.83301362 0 4.83301362 3.62401581 0 3.62401581 4.87475204 3.62401581 9.26779623"></polyline>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 970 B

View File

@ -216,6 +216,9 @@ import layoutCommon2x2 from './../../assets/icons/layout-common-2x2.svg';
import layoutCommon2x3 from './../../assets/icons/layout-common-2x3.svg';
import iconToolRotate from './../../assets/icons/tool-3d-rotate.svg';
//
import tab4D from './../../assets/icons/tab-4d.svg';
/** New investigational use */
import investigationalUse from './../../assets/icons/illustration-investigational-use.svg';
@ -426,6 +429,7 @@ const ICONS = {
'layout-common-1x2': layoutCommon1x2,
'layout-common-2x2': layoutCommon2x2,
'layout-common-2x3': layoutCommon2x3,
'tab-4d': tab4D,
/** New investigational use */
'illustration-investigational-use': investigationalUse,

View File

@ -28,9 +28,12 @@ const SegmentItem = ({
return (
<div
className={classnames('text-aqua-pale group/row bg-primary-dark flex min-h-[28px] flex-col', {
'bg-primary-light border-primary-light rounded-l-[6px] border text-black': isActive,
})}
className={classnames(
'text-aqua-pale group/row bg-primary-dark flex min-h-[28px] flex-col overflow-hidden',
{
'bg-primary-light border-primary-light rounded-l-[6px] border text-black': isActive,
}
)}
onClick={e => {
e.stopPropagation();
onClick(segmentationId, segmentIndex);
@ -151,11 +154,11 @@ const SegmentItem = ({
</div>
</div>
{Array.isArray(displayText) ? (
<div className="flex flex-col py-[5px] pl-[43px]">
<div className="flex flex-col bg-black py-[5px] pl-[43px]">
{displayText.map(text => (
<div
key={text}
className="text-aqua-pale flex h-full items-center bg-black text-[11px]"
className="text-aqua-pale flex h-full items-center text-[11px]"
>
{text}
</div>
@ -163,7 +166,7 @@ const SegmentItem = ({
</div>
) : (
displayText && (
<div className="text-aqua-pale flex h-full items-center bg-black px-2 py-[5px] pl-[45px] text-[11px]">
<div className="text-aqua-pale flex h-full items-center px-2 py-[5px] pl-[45px] text-[11px]">
{displayText}
</div>
)

View File

@ -1497,13 +1497,13 @@
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
"@cornerstonejs/adapters@^1.70.9":
version "1.70.9"
resolved "https://registry.yarnpkg.com/@cornerstonejs/adapters/-/adapters-1.70.9.tgz#77f96dbdd2d53225ad8d13d4912afa3538c84baa"
integrity sha512-eGJu9Skb2WUYvXn1JNO/5NB3EMEHj9rGAelrTozFclqPjnfHbxXlMBeScdE8YTmwR6wHtwiB/lXgu6hhrbYOMQ==
"@cornerstonejs/adapters@^1.70.10":
version "1.70.10"
resolved "https://registry.yarnpkg.com/@cornerstonejs/adapters/-/adapters-1.70.10.tgz#5b8113a6a7c0d8677d72d97761530e3e7f91e118"
integrity sha512-OTYDg64SjrmCGEt1VDM27iaUOXi+v7WZaoJI5CfwXOA3cHG9AAv8lRaCtvUhIMKXce7HZuYdgKh/zizvrnVPow==
dependencies:
"@babel/runtime-corejs2" "^7.17.8"
"@cornerstonejs/tools" "^1.70.9"
"@cornerstonejs/tools" "^1.70.10"
buffer "^6.0.3"
dcmjs "^0.29.8"
gl-matrix "^3.4.3"
@ -1550,10 +1550,10 @@
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.5.tgz#8690b61a86fa53ef38a70eee9d665a79229517c0"
integrity sha512-MZCUy8VG0VG5Nl1l58+g+kH3LujAzLYTfJqkwpWI2gjSrGXnP6lgwyy4GmPRZWVoS40/B1LDNALK905cNWm+sg==
"@cornerstonejs/core@^1.70.9":
version "1.70.9"
resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-1.70.9.tgz#c08aa055a786aac79c457e744f18c5fe3f3203f5"
integrity sha512-2Gu6SXRKuWFoP0aD0dC5et5pcWExxJOE2DWXUKuVdXollbIC+uq3I5FSWOwM8Zzu/Z3JzWWY5IYYPKdJqb8ogQ==
"@cornerstonejs/core@^1.70.10":
version "1.70.10"
resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-1.70.10.tgz#8485ad15cd07e784a07892c2d2246bc6e2d5d5fe"
integrity sha512-c9wkgDGKpGt0VEdILLAIn5ZRTqoofsGrWAzj+8h0zogdVY9yQ1PIP2WW7xEbuUcJ/m1RFpapiwqMFU6UEXNNBA==
dependencies:
"@kitware/vtk.js" "30.3.3"
comlink "^4.4.1"
@ -1561,34 +1561,34 @@
gl-matrix "^3.4.3"
lodash.clonedeep "4.5.0"
"@cornerstonejs/dicom-image-loader@^1.70.9":
version "1.70.9"
resolved "https://registry.yarnpkg.com/@cornerstonejs/dicom-image-loader/-/dicom-image-loader-1.70.9.tgz#e329eb86343651f3d6148e03eb1e9d4390b79a6a"
integrity sha512-M4qd88NA+z62d8w6eaDUbYfFCn3dmDqGkF0iI2s67LlySmeDo7pUmSG8gQbuJMOUPQMxD1Hy8EXBexaTAsBiaQ==
"@cornerstonejs/dicom-image-loader@^1.70.10":
version "1.70.10"
resolved "https://registry.yarnpkg.com/@cornerstonejs/dicom-image-loader/-/dicom-image-loader-1.70.10.tgz#29de339e969e370b07fbb229724094b924cf5469"
integrity sha512-4/J4xU3C1Qk8KAQaQdF0duaehfGFX5z47ucU3rXqvN44+qHZrK/iPeGVORhJmErlB9qTY++K8uAZbqo/E65Aiw==
dependencies:
"@cornerstonejs/codec-charls" "^1.2.3"
"@cornerstonejs/codec-libjpeg-turbo-8bit" "^1.2.2"
"@cornerstonejs/codec-openjpeg" "^1.2.2"
"@cornerstonejs/codec-openjph" "^2.4.5"
"@cornerstonejs/core" "^1.70.9"
"@cornerstonejs/core" "^1.70.10"
dicom-parser "^1.8.9"
pako "^2.0.4"
uuid "^9.0.0"
"@cornerstonejs/streaming-image-volume-loader@^1.70.9":
version "1.70.9"
resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-1.70.9.tgz#3f3b7c04b3db6c303cb278e5ab8d1916cd91a3b1"
integrity sha512-Uay1IIRJMge0Tp7DrgisaqIXowh5IdROXbexmbGVT++0qeyzIQIzXdaUrJpuXDpaB2bYppWEY9moKja0mtlBHw==
"@cornerstonejs/streaming-image-volume-loader@^1.70.10":
version "1.70.10"
resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-1.70.10.tgz#e548e82b1014517efd686c294e161a15b66b6e1e"
integrity sha512-Rg7v0WDjGNjV+DPXLj1LLSXV/6kBq+RxRBxLVfKnA3onEFRuPxqnMUYY9uxG7Edr5RLaGlnBCr4V4tvFzzYYmA==
dependencies:
"@cornerstonejs/core" "^1.70.9"
"@cornerstonejs/core" "^1.70.10"
comlink "^4.4.1"
"@cornerstonejs/tools@^1.70.9":
version "1.70.9"
resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-1.70.9.tgz#e71b709ba5652918a9c92ad344638d3188d8af2b"
integrity sha512-/Pbbb/hAeYd0bgrXtAEwQOksdL5qy+zwosk3HMsUCa5S7FnvEOSnSQbs0h8UZwKIkW09LtzAkSiPgJpP7A3cwg==
"@cornerstonejs/tools@^1.70.10":
version "1.70.10"
resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-1.70.10.tgz#9b24b17dfb5ba48556df7643bee01f79b6378672"
integrity sha512-GmwhfB/DzEc86cur2pHvlLp/FCvhG7WlNe0dJWLNRPA/fgPt+BPVBwrMf4a6934r4N7WCzwOJjcnUpiwDjT+MQ==
dependencies:
"@cornerstonejs/core" "^1.70.9"
"@cornerstonejs/core" "^1.70.10"
"@icr/polyseg-wasm" "0.4.0"
"@types/offscreencanvas" "2019.7.3"
comlink "^4.4.1"