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

This commit is contained in:
Alireza authored and GitHub committed 2024-04-19 15:11:31 -04:00
1 parent 8c5ab942b9
commit 8192e348ec
27 files changed
+593 -212

No files matched your search

@@ -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>
@@ -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
+48 -3
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,
@@ -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);
@@ -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
);
};