feat(segmentation): segment statistics, labelmap interpolation and segment bidirectional (#4865)

This commit is contained in:
Alireza authored and GitHub committed 2025-03-17 22:34:34 -04:00
1 parent 694f3e9611
commit ec79b5523e
73 files changed
+3603 -1563

No files matched your search

@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.5.0",
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/adapters": "^3.7.0",
"@cornerstonejs/core": "^3.7.0",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.5.0",
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/adapters": "^3.7.0",
"@cornerstonejs/core": "^3.7.0",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
@@ -42,6 +42,13 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
};
}
if (!toolNames) {
return {
disabled: false,
// isActive: true,
};
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup.hasTool(toolName) && !toolNames) {
+3 -3
View File
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.5.0",
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/tools": "^3.5.0",
"@cornerstonejs/adapters": "^3.7.0",
"@cornerstonejs/core": "^3.7.0",
"@cornerstonejs/tools": "^3.7.0",
"classnames": "^2.3.2"
}
}
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/tools": "^3.5.0",
"@cornerstonejs/core": "^3.7.0",
"@cornerstonejs/tools": "^3.7.0",
"classnames": "^2.3.2"
}
}
+6 -5
View File
@@ -38,7 +38,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.5.0",
"@cornerstonejs/dicom-image-loader": "^3.7.0",
"@ohif/core": "3.10.0-beta.125",
"@ohif/ui": "3.10.0-beta.125",
"dcmjs": "*",
@@ -51,10 +51,11 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.5.0",
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/polymorphic-segmentation": "^3.5.0",
"@cornerstonejs/tools": "^3.5.0",
"@cornerstonejs/adapters": "^3.7.0",
"@cornerstonejs/core": "^3.7.0",
"@cornerstonejs/polymorphic-segmentation": "^3.7.0",
"@cornerstonejs/labelmap-interpolation": "^3.7.0",
"@cornerstonejs/tools": "^3.7.0",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "32.12.0",
"html2canvas": "^1.4.1",
+122 -3
View File
@@ -4,6 +4,7 @@ import {
VolumeViewport,
utilities as csUtils,
Types as CoreTypes,
cache,
BaseVolumeViewport,
} from '@cornerstonejs/core';
import {
@@ -14,6 +15,7 @@ import {
annotation,
} from '@cornerstonejs/tools';
import * as cornerstoneTools from '@cornerstonejs/tools';
import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation';
import { Types as OhifTypes, utils } from '@ohif/core';
import i18n from '@ohif/i18n';
@@ -31,6 +33,8 @@ import toggleVOISliceSync from './utils/toggleVOISliceSync';
import { usePositionPresentationStore, useSegmentationPresentationStore } from './stores';
import { toolNames } from './initCornerstoneTools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats';
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
const toggleSyncFunctions = {
imageSlice: toggleImageSliceSync,
@@ -39,7 +43,6 @@ const toggleSyncFunctions = {
function commandsModule({
servicesManager,
extensionManager,
commandsManager,
}: OhifTypes.Extensions.ExtensionParams): OhifTypes.Extensions.CommandsModule {
const {
@@ -54,7 +57,9 @@ function commandsModule({
colorbarService,
hangingProtocolService,
syncGroupService,
} = servicesManager.services;
segmentationService,
displaySetService,
} = servicesManager.services as AppTypes.Services;
const { measurementServiceSource } = this;
@@ -67,7 +72,75 @@ function commandsModule({
return toolGroupService.getToolGroupForViewport(viewport.id);
}
function _getActiveSegmentationInfo() {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const segmentationId = activeSegmentation?.segmentationId;
const activeSegmentIndex = segmentationService.getActiveSegment(viewportId).segmentIndex;
return {
segmentationId,
segmentIndex: activeSegmentIndex,
};
}
const actions = {
runSegmentBidirectional: async ({ segmentationId, segmentIndex } = {}) => {
// Get active segmentation if not specified
const targetSegmentation =
segmentationId && segmentIndex
? { segmentationId, segmentIndex }
: _getActiveSegmentationInfo();
const { segmentationId: targetId, segmentIndex: targetIndex } = targetSegmentation;
// Get bidirectional measurement data
const bidirectionalData = await cstUtils.segmentation.getSegmentLargestBidirectional({
segmentationId: targetId,
segmentIndices: [targetIndex],
});
const activeViewportId = viewportGridService.getActiveViewportId();
// Process each bidirectional measurement
bidirectionalData.forEach(measurement => {
const { segmentIndex, majorAxis, minorAxis } = measurement;
// Create annotation
const annotation = cornerstoneTools.SegmentBidirectionalTool.hydrate(
activeViewportId,
[majorAxis, minorAxis],
{
segmentIndex,
segmentationId: targetId,
}
);
// Update segmentation stats
const updatedSegmentation = updateSegmentBidirectionalStats({
segmentationId: targetId,
segmentIndex: targetIndex,
bidirectionalData: measurement,
segmentationService,
annotation,
});
// Save changes if needed
if (updatedSegmentation) {
segmentationService.addOrUpdateSegmentation({
segmentationId: targetId,
segments: updatedSegmentation.segments,
});
}
});
},
interpolateLabelmap: () => {
const { segmentationId, segmentIndex } = _getActiveSegmentationInfo();
labelmapInterpolation.interpolate({
segmentationId,
segmentIndex,
});
},
/**
* Generates the selector props for the context menu, specific to
* the cornerstone viewport, and then runs the context menu.
@@ -373,6 +446,44 @@ function commandsModule({
utils.downloadCSVReport(measurementService.getMeasurements(measurementFilter));
},
downloadCSVSegmentationReport: ({ segmentationId }) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const cachedStats = segmentation.cachedStats;
const { representationData } = segmentation;
const { Labelmap } = representationData;
const { referencedImageIds } = Labelmap;
const firstImageId = referencedImageIds[0];
// find displaySet for firstImageId
const displaySet = displaySetService
.getActiveDisplaySets()
.find(ds => ds.imageIds?.some(i => i === firstImageId));
const {
SeriesNumber,
SeriesInstanceUID,
StudyInstanceUID,
SeriesDate,
SeriesTime,
SeriesDescription,
} = displaySet;
const additionalInfo = {
reference: {
SeriesNumber,
SeriesInstanceUID,
StudyInstanceUID,
SeriesDate,
SeriesTime,
SeriesDescription,
},
};
generateSegmentationCSVReport(segmentation, additionalInfo);
},
// Retrieve value commands
getActiveViewportEnabledElement: _getActiveViewportEnabledElement,
@@ -1094,7 +1205,12 @@ function commandsModule({
* @param props.segmentIndex - The index of the segment to activate
*/
setActiveSegmentAndCenterCommand: ({ segmentationId, segmentIndex }) => {
const { segmentationService } = servicesManager.services;
const { segmentationService, viewportGridService } = servicesManager.services;
// set both active segmentation and active segment
segmentationService.setActiveSegmentation(
viewportGridService.getActiveViewportId(),
segmentationId
);
segmentationService.setActiveSegment(segmentationId, segmentIndex);
segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex);
},
@@ -1636,6 +1752,9 @@ function commandsModule({
},
undo: actions.undo,
redo: actions.redo,
interpolateLabelmap: actions.interpolateLabelmap,
runSegmentBidirectional: actions.runSegmentBidirectional,
downloadCSVSegmentationReport: actions.downloadCSVSegmentationReport,
};
return {
@@ -0,0 +1,127 @@
import React from 'react';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
Icons,
useSegmentationTableContext,
useSegmentationExpanded,
} from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import { useSystem } from '@ohif/core/src';
/**
* Custom dropdown menu component for segmentation panel that uses context for data
*/
export const CustomDropdownMenuContent = () => {
const { commandsManager } = useSystem();
const { t } = useTranslation('SegmentationTable');
const {
onSegmentationAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
exportOptions,
activeSegmentation,
activeSegmentationId,
} = useSegmentationTableContext('CustomDropdownMenu');
// Try to get segmentation data from expanded context first, fall back to table context
let segmentation;
let segmentationId;
let allowExport = false;
try {
// Try to get from expanded context
const context = useSegmentationExpanded();
segmentation = context.segmentation;
segmentationId = segmentation.segmentationId;
} catch (e) {
// If not in expanded context, fallback to active segmentation from table context
segmentation = activeSegmentation;
segmentationId = activeSegmentationId;
}
// Determine if export is allowed for this segmentation
if (exportOptions && segmentationId) {
const exportOption = exportOptions.find(opt => opt.segmentationId === segmentationId);
allowExport = exportOption?.isExportable || false;
}
if (!segmentation || !segmentationId) {
return null;
}
const actions = {
storeSegmentation: async segmentationId => {
commandsManager.run({
commandName: 'storeSegmentation',
commandOptions: { segmentationId },
context: 'CORNERSTONE',
});
},
onSegmentationDownloadRTSS: segmentationId => {
commandsManager.run('downloadRTSS', { segmentationId });
},
onSegmentationDownload: segmentationId => {
commandsManager.run('downloadSegmentation', { segmentationId });
},
downloadCSVSegmentationReport: segmentationId => {
commandsManager.run('downloadCSVSegmentationReport', { segmentationId });
},
};
return (
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(segmentationId)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSegmentationRemoveFromViewport(segmentationId)}>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationEdit(segmentationId)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="pl-1">
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => actions.downloadCSVSegmentationReport(segmentationId)}>
{t('Download CSV Report')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => actions.storeSegmentation(segmentationId)}
disabled={!allowExport}
>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => actions.onSegmentationDownload(segmentationId)}
disabled={!allowExport}
>
{t('Download DICOM SEG')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(segmentationId)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
);
};
@@ -0,0 +1,138 @@
import * as cornerstoneTools from '@cornerstonejs/tools';
import React from 'react';
import { Separator, Button, Tooltip, TooltipTrigger, TooltipContent, Icons } from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import { roundNumber } from '@ohif/core/src/utils';
import { useSystem } from '@ohif/core/src';
interface CustomSegmentStatisticsHeaderProps {
segmentationId: string;
segmentIndex: number;
}
/**
* Custom header component for segment statistics
*/
export const CustomSegmentStatisticsHeader = ({
segmentationId,
segmentIndex,
}: CustomSegmentStatisticsHeaderProps) => {
const { servicesManager, commandsManager } = useSystem();
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('SegmentationTable');
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const cachedStats = segment.cachedStats;
const namedStats = cachedStats.namedStats;
if (!namedStats) {
return null;
}
const bidirectional = namedStats.bidirectional;
if (!bidirectional) {
return (
<div className="-mt-2 space-y-2">
<div className="flex">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-primary flex items-center gap-2"
onClick={() => {
commandsManager.run('runSegmentBidirectional', {
segmentationId,
segmentIndex,
});
}}
>
<Icons.ToolBidirectionalSegment className="h-5 w-5" />
<span>{t('Compute Largest Bidirectional')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('Add bidirectional measurement')}</TooltipContent>
</Tooltip>
</div>
<Separator className="bg-input" />
</div>
);
}
const { value, unit } = bidirectional;
const maxMajor = value.maxMajor;
const maxMinor = value.maxMinor;
const max = Math.max(maxMajor, maxMinor);
const min = Math.min(maxMajor, maxMinor);
const isVisible = cornerstoneTools.annotation.visibility.isAnnotationVisible(
bidirectional.annotationUID
);
return (
<div className="-mt-2 space-y-2">
<div className="flex items-center justify-between">
<div className="text-foreground">
<div>
L: {roundNumber(max)} {unit}
</div>
<div>
W: {roundNumber(min)} {unit}
</div>
</div>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity`}
onClick={e => {
e.stopPropagation();
cornerstoneTools.annotation.visibility.setAnnotationVisibility(
bidirectional.annotationUID,
!isVisible
);
segmentationService.addOrUpdateSegmentation({
segmentationId,
segmentIndex,
});
}}
>
{isVisible ? (
<Icons.Hide className="h-6 w-6" />
) : (
<Icons.Show className="h-6 w-6" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('Toggle visibility')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (bidirectional.annotationUID) {
commandsManager.run('jumpToMeasurement', {
uid: bidirectional.annotationUID,
});
}
}}
>
<Icons.JumpToSlice />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('Jump to measurement')}</TooltipContent>
</Tooltip>
</div>
</div>
<Separator className="bg-input" />
</div>
);
};
@@ -1,77 +1,10 @@
import React from 'react';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
Icons,
} from '@ohif/ui-next';
import { CustomDropdownMenuContent } from './CustomDropdownMenuContent';
import { CustomSegmentStatisticsHeader } from './CustomSegmentStatisticsHeader';
export default function getSegmentationPanelCustomization({ commandsManager, servicesManager }) {
return {
'panelSegmentation.customDropdownMenuContent': ({
activeSegmentation,
onSegmentationAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
allowExport,
storeSegmentation,
onSegmentationDownload,
onSegmentationDownloadRTSS,
t,
}) => (
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(activeSegmentation.segmentationId)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onSegmentationRemoveFromViewport(activeSegmentation.segmentationId)}
>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationEdit(activeSegmentation.segmentationId)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={!allowExport}
className="pl-1"
>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => storeSegmentation(activeSegmentation.segmentationId)}
>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSegmentationDownload(activeSegmentation.segmentationId)}
>
{t('Download DICOM SEG')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(activeSegmentation.segmentationId)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
),
'panelSegmentation.customDropdownMenuContent': CustomDropdownMenuContent,
'panelSegmentation.customSegmentStatisticsHeader': CustomSegmentStatisticsHeader,
'panelSegmentation.disableEditing': false,
'panelSegmentation.showAddSegment': true,
'panelSegmentation.onSegmentationAdd': () => {
@@ -81,15 +14,21 @@ export default function getSegmentationPanelCustomization({ commandsManager, ser
},
'panelSegmentation.tableMode': 'collapsed',
'panelSegmentation.readableText': {
lesionStats: 'Lesion Statistics',
minValue: 'Minimum Value',
maxValue: 'Maximum Value',
meanValue: 'Mean Value',
volume: 'Volume (ml)',
suvPeak: 'SUV Peak',
suvMax: 'Maximum SUV',
suvMaxIJK: 'SUV Max IJK',
lesionGlyoclysisStats: 'Lesion Glycolysis',
// the values will appear in this order
min: 'Min Value',
minLPS: 'Min Coord',
max: 'Max Value',
maxLPS: 'Max Coord',
mean: 'Mean Value',
stdDev: 'Standard Deviation',
count: 'Voxel Count',
median: 'Median',
skewness: 'Skewness',
kurtosis: 'Kurtosis',
peakValue: 'Peak Value',
peakLPS: 'Peak Coord',
volume: 'Volume',
lesionGlycolysis: 'Lesion Glycolysis',
},
};
}
@@ -190,16 +190,16 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w
},
},
{
name: 'evaluate.mpr',
name: 'evaluate.displaySetIsReconstructable',
evaluate: ({ viewportId, disabledText = 'Selected viewport is not reconstructable' }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetUIDs?.length) {
if (!viewport) {
return;
}
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
const areReconstructable = displaySets.every(displaySet => {
@@ -210,11 +210,8 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w
return getDisabledState(disabledText);
}
const isMpr = protocol?.id === 'mpr';
return {
disabled: false,
className: utils.getToggledClassName(isMpr),
};
},
},
+68 -29
View File
@@ -39,6 +39,7 @@ import { useLutPresentationStore } from './stores/useLutPresentationStore';
import { usePositionPresentationStore } from './stores/usePositionPresentationStore';
import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore';
import { imageRetrieveMetadataProvider } from '@cornerstonejs/core/utilities';
import { updateSegmentationStats } from './utils/updateSegmentationStats';
const { registerColormap } = csUtilities.colormap;
@@ -90,6 +91,7 @@ export default async function init({
cornerstoneViewportService,
hangingProtocolService,
viewportGridService,
segmentationService,
} = servicesManager.services;
window.services = servicesManager.services;
@@ -150,8 +152,6 @@ export default async function init({
);
});
// ... existing code ...
// add metadata providers
metaData.addProvider(
csUtilities.calibratedPixelSpacingMetadataProvider.get.bind(
@@ -177,6 +177,45 @@ export default async function init({
initCineService(servicesManager);
initStudyPrefetcherService(servicesManager);
segmentationService.subscribeDebounced(
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
async ({ segmentationId }) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const readableText = customizationService.getCustomization('panelSegmentation.readableText');
const updatedSegmentation = await updateSegmentationStats({
segmentation,
segmentationId,
readableText,
});
if (updatedSegmentation) {
segmentationService.addOrUpdateSegmentation({
segmentationId,
segments: updatedSegmentation.segments,
});
}
// Check for segments with bidirectional measurements and update them
if (segmentation) {
const segmentIndices = Object.keys(segmentation.segments)
.map(index => parseInt(index))
.filter(index => index > 0);
for (const segmentIndex of segmentIndices) {
const segment = segmentation.segments[segmentIndex];
if (segment?.cachedStats?.namedStats?.bidirectional) {
// Run the command to update the bidirectional measurement
commandsManager.runCommand('runSegmentBidirectional', {
segmentationId,
segmentIndex,
});
}
}
}
},
1000
);
// When a custom image load is performed, update the relevant viewports
hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
@@ -278,35 +317,35 @@ export default async function init({
function initializeWebWorkerProgressHandler(uiNotificationService) {
const activeToasts = new Map();
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
const { progress, type, id } = detail;
// eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
// const { progress, type, id } = detail;
const cacheKey = `${type}-${id}`;
if (progress === 0 && !activeToasts.has(cacheKey)) {
const progressPromise = new Promise((resolve, reject) => {
activeToasts.set(cacheKey, { resolve, reject });
});
// const cacheKey = `${type}-${id}`;
// if (progress === 0 && !activeToasts.has(cacheKey)) {
// const progressPromise = new Promise((resolve, reject) => {
// activeToasts.set(cacheKey, { resolve, reject });
// });
uiNotificationService.show({
id: cacheKey,
title: `${type}`,
message: `${type}: ${progress}%`,
autoClose: false,
promise: progressPromise,
promiseMessages: {
loading: `Computing...`,
success: `Completed successfully`,
error: 'Web Worker failed',
},
});
} else {
if (progress === 100) {
const { resolve } = activeToasts.get(cacheKey);
resolve({ progress, type });
activeToasts.delete(cacheKey);
}
}
});
// uiNotificationService.show({
// id: cacheKey,
// title: `${type}`,
// message: `${type}: ${progress}%`,
// autoClose: false,
// promise: progressPromise,
// promiseMessages: {
// loading: `Computing...`,
// success: `Completed successfully`,
// error: 'Web Worker failed',
// },
// });
// } else {
// if (progress === 100) {
// const { resolve } = activeToasts.get(cacheKey);
// resolve({ progress, type });
// activeToasts.delete(cacheKey);
// }
// }
// });
}
/**
@@ -1,6 +1,7 @@
import {
PanTool,
WindowLevelTool,
SegmentBidirectionalTool,
StackScrollTool,
VolumeRotateTool,
ZoomTool,
@@ -52,8 +53,14 @@ export default function initCornerstoneTools(configuration = {}) {
addons: {
polySeg,
},
computeWorker: {
autoTerminateOnIdle: {
enabled: false,
},
},
});
addTool(PanTool);
addTool(SegmentBidirectionalTool);
addTool(WindowLevelTool);
addTool(StackScrollTool);
addTool(VolumeRotateTool);
@@ -142,6 +149,7 @@ const toolNames = {
OrientationMarker: OrientationMarkerTool.toolName,
WindowLevelRegion: WindowLevelRegionTool.toolName,
PlanarFreehandContourSegmentation: PlanarFreehandContourSegmentationTool.toolName,
SegmentBidirectional: SegmentBidirectionalTool.toolName,
};
export { toolNames };
@@ -34,6 +34,7 @@ const initMeasurementService = (
LivewireContour,
Probe,
UltrasoundDirectional,
SegmentBidirectional,
} = measurementServiceMappingsFactory(
measurementService,
displaySetService,
@@ -172,6 +173,14 @@ const initMeasurementService = (
UltrasoundDirectional.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'SegmentBidirectional',
SegmentBidirectional.matchingCriteria,
SegmentBidirectional.toAnnotation,
SegmentBidirectional.toMeasurement
);
return csTools3DVer1MeasurementSource;
};
@@ -15,217 +15,191 @@ export default function PanelSegmentation({
servicesManager,
});
const handlers = {
onSegmentationClick: (segmentationId: string) => {
commandsManager.run('setActiveSegmentation', { segmentationId });
},
onSegmentAdd: segmentationId => {
commandsManager.run('addSegment', { segmentationId });
},
onSegmentClick: (segmentationId, segmentIndex) => {
commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex });
},
onSegmentEdit: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentLabel', { segmentationId, segmentIndex });
},
onSegmentationEdit: segmentationId => {
commandsManager.run('editSegmentationLabel', { segmentationId });
},
onSegmentColorClick: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentColor', { segmentationId, segmentIndex });
},
onSegmentDelete: (segmentationId, segmentIndex) => {
commandsManager.run('deleteSegment', { segmentationId, segmentIndex });
},
onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => {
commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type });
},
onToggleSegmentLock: (segmentationId, segmentIndex) => {
commandsManager.run('toggleSegmentLock', { segmentationId, segmentIndex });
},
onToggleSegmentationRepresentationVisibility: (segmentationId, type) => {
commandsManager.run('toggleSegmentationVisibility', { segmentationId, type });
},
onSegmentationDownload: segmentationId => {
commandsManager.run('downloadSegmentation', { segmentationId });
},
storeSegmentation: async segmentationId => {
commandsManager.run({
commandName: 'storeSegmentation',
commandOptions: { segmentationId },
context: 'CORNERSTONE',
});
},
onSegmentationDownloadRTSS: segmentationId => {
commandsManager.run('downloadRTSS', { segmentationId });
},
setStyle: (segmentationId, type, key, value) => {
commandsManager.run('setSegmentationStyle', { segmentationId, type, key, value });
},
toggleRenderInactiveSegmentations: () => {
commandsManager.run('toggleRenderInactiveSegmentations');
},
onSegmentationRemoveFromViewport: segmentationId => {
commandsManager.run('removeSegmentationFromViewport', { segmentationId });
},
onSegmentationDelete: segmentationId => {
commandsManager.run('deleteSegmentation', { segmentationId });
},
setFillAlpha: ({ type }, value) => {
commandsManager.run('setFillAlpha', { type, value });
},
setOutlineWidth: ({ type }, value) => {
commandsManager.run('setOutlineWidth', { type, value });
},
setRenderFill: ({ type }, value) => {
commandsManager.run('setRenderFill', { type, value });
},
setRenderOutline: ({ type }, value) => {
commandsManager.run('setRenderOutline', { type, value });
},
setFillAlphaInactive: ({ type }, value) => {
commandsManager.run('setFillAlphaInactive', { type, value });
},
getRenderInactiveSegmentations: () => {
return commandsManager.run('getRenderInactiveSegmentations');
},
};
// Extract customization options
const segmentationTableMode = customizationService.getCustomization(
'panelSegmentation.tableMode'
);
// custom onSegmentationAdd if provided
) as unknown as string;
const onSegmentationAdd = customizationService.getCustomization(
'panelSegmentation.onSegmentationAdd'
);
const disableEditing = customizationService.getCustomization('panelSegmentation.disableEditing');
const showAddSegment = customizationService.getCustomization('panelSegmentation.showAddSegment');
const CustomDropdownMenuContent = customizationService.getCustomization(
'panelSegmentation.customDropdownMenuContent'
);
const CustomSegmentStatisticsHeader = customizationService.getCustomization(
'panelSegmentation.customSegmentStatisticsHeader'
);
// Create handlers object for all command runs
const handlers = {
onSegmentationClick: (segmentationId: string) => {
commandsManager.run('setActiveSegmentation', { segmentationId });
},
onSegmentAdd: segmentationId => {
commandsManager.run('addSegment', { segmentationId });
},
onSegmentClick: (segmentationId, segmentIndex) => {
commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex });
},
onSegmentEdit: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentLabel', { segmentationId, segmentIndex });
},
onSegmentationEdit: segmentationId => {
commandsManager.run('editSegmentationLabel', { segmentationId });
},
onSegmentColorClick: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentColor', { segmentationId, segmentIndex });
},
onSegmentDelete: (segmentationId, segmentIndex) => {
commandsManager.run('deleteSegment', { segmentationId, segmentIndex });
},
onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => {
commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type });
},
onToggleSegmentLock: (segmentationId, segmentIndex) => {
commandsManager.run('toggleSegmentLock', { segmentationId, segmentIndex });
},
onToggleSegmentationRepresentationVisibility: (segmentationId, type) => {
commandsManager.run('toggleSegmentationVisibility', { segmentationId, type });
},
onSegmentationDownload: segmentationId => {
commandsManager.run('downloadSegmentation', { segmentationId });
},
setStyle: (segmentationId, type, key, value) => {
commandsManager.run('setSegmentationStyle', { segmentationId, type, key, value });
},
toggleRenderInactiveSegmentations: () => {
commandsManager.run('toggleRenderInactiveSegmentations');
},
onSegmentationRemoveFromViewport: segmentationId => {
commandsManager.run('removeSegmentationFromViewport', { segmentationId });
},
onSegmentationDelete: segmentationId => {
commandsManager.run('deleteSegmentation', { segmentationId });
},
setFillAlpha: ({ type }, value) => {
commandsManager.run('setFillAlpha', { type, value });
},
setOutlineWidth: ({ type }, value) => {
commandsManager.run('setOutlineWidth', { type, value });
},
setRenderFill: ({ type }, value) => {
commandsManager.run('setRenderFill', { type, value });
},
setRenderOutline: ({ type }, value) => {
commandsManager.run('setRenderOutline', { type, value });
},
setFillAlphaInactive: ({ type }, value) => {
commandsManager.run('setFillAlphaInactive', { type, value });
},
getRenderInactiveSegmentations: () => {
return commandsManager.run('getRenderInactiveSegmentations');
},
};
// Generate export options
const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => {
const { representationData, segmentationId } = segmentation;
const { Labelmap } = representationData;
if (!Labelmap) {
return {
segmentationId,
isExportable: true,
};
return { segmentationId, isExportable: true };
}
const referencedImageIds = Labelmap.referencedImageIds;
const firstImageId = referencedImageIds[0];
const instance = metaData.get('instance', firstImageId);
if (!instance) {
return {
segmentationId,
isExportable: false,
};
return { segmentationId, isExportable: false };
}
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
const SeriesInstanceUID = instance.SeriesInstanceUID;
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
const isExportable = displaySet?.isReconstructable;
return {
segmentationId,
isExportable,
isExportable: displaySet?.isReconstructable,
};
});
return (
<>
<SegmentationTable
disabled={disabled}
data={segmentationsWithRepresentations}
mode={segmentationTableMode}
title="Segmentations"
exportOptions={exportOptions}
disableEditing={disableEditing}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={handlers.onSegmentationClick}
onSegmentationDelete={handlers.onSegmentationDelete}
showAddSegment={showAddSegment}
onSegmentAdd={handlers.onSegmentAdd}
onSegmentClick={handlers.onSegmentClick}
onSegmentEdit={handlers.onSegmentEdit}
onSegmentationEdit={handlers.onSegmentationEdit}
onSegmentColorClick={handlers.onSegmentColorClick}
onSegmentDelete={handlers.onSegmentDelete}
onToggleSegmentVisibility={handlers.onToggleSegmentVisibility}
onToggleSegmentLock={handlers.onToggleSegmentLock}
onToggleSegmentationRepresentationVisibility={
handlers.onToggleSegmentationRepresentationVisibility
}
onSegmentationDownload={handlers.onSegmentationDownload}
storeSegmentation={handlers.storeSegmentation}
onSegmentationDownloadRTSS={handlers.onSegmentationDownloadRTSS}
setStyle={handlers.setStyle}
toggleRenderInactiveSegmentations={handlers.toggleRenderInactiveSegmentations}
onSegmentationRemoveFromViewport={handlers.onSegmentationRemoveFromViewport}
setFillAlpha={handlers.setFillAlpha}
setOutlineWidth={handlers.setOutlineWidth}
setRenderFill={handlers.setRenderFill}
setRenderOutline={handlers.setRenderOutline}
setFillAlphaInactive={handlers.setFillAlphaInactive}
renderInactiveSegmentations={handlers.getRenderInactiveSegmentations()}
>
{children}
<SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow />
// Common props for SegmentationTable
const tableProps = {
disabled,
data: segmentationsWithRepresentations,
mode: segmentationTableMode,
title: 'Segmentations',
exportOptions,
disableEditing,
onSegmentationAdd,
showAddSegment,
renderInactiveSegmentations: handlers.getRenderInactiveSegmentations(),
...handlers,
};
{segmentationTableMode === 'collapsed' ? (
<SegmentationTable.Collapsed>
<SegmentationTable.SelectorHeader>
const renderSegments = () => {
return (
<SegmentationTable.Segments>
<SegmentationTable.SegmentStatistics.Header>
<CustomSegmentStatisticsHeader />
</SegmentationTable.SegmentStatistics.Header>
<SegmentationTable.SegmentStatistics.Body />
</SegmentationTable.Segments>
);
};
// Render content based on mode
const renderModeContent = () => {
if (tableProps.mode === 'collapsed') {
return (
<SegmentationTable.Collapsed>
<SegmentationTable.Collapsed.Header>
<SegmentationTable.Collapsed.DropdownMenu>
<CustomDropdownMenuContent />
</SegmentationTable.SelectorHeader>
</SegmentationTable.Collapsed.DropdownMenu>
<SegmentationTable.Collapsed.Selector />
<SegmentationTable.Collapsed.Info />
</SegmentationTable.Collapsed.Header>
<SegmentationTable.Collapsed.Content>
<SegmentationTable.AddSegmentRow />
<SegmentationTable.Segments />
</SegmentationTable.Collapsed>
) : (
<SegmentationTable.Expanded>
<SegmentationTable.Header>
{renderSegments()}
</SegmentationTable.Collapsed.Content>
</SegmentationTable.Collapsed>
);
}
return (
<>
<SegmentationTable.Expanded>
<SegmentationTable.Expanded.Header>
<SegmentationTable.Expanded.DropdownMenu>
<CustomDropdownMenuContent />
</SegmentationTable.Header>
{/* <SegmentationTable.AddSegmentRow /> */}
<SegmentationTable.Segments />
</SegmentationTable.Expanded>
)}
</SegmentationTable>
</>
</SegmentationTable.Expanded.DropdownMenu>
<SegmentationTable.Expanded.Label />
<SegmentationTable.Expanded.Info />
</SegmentationTable.Expanded.Header>
<SegmentationTable.Expanded.Content>
<SegmentationTable.AddSegmentRow />
{renderSegments()}
</SegmentationTable.Expanded.Content>
</SegmentationTable.Expanded>
</>
);
};
return (
<SegmentationTable {...tableProps}>
{children}
<SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow />
{renderModeContent()}
</SegmentationTable>
);
}
@@ -92,6 +92,7 @@ class SegmentationService extends PubSubService {
};
private _segmentationIdToColorLUTIndexMap: Map<string, number>;
private _segmentationGroupStatsMap: Map<string, any>;
readonly servicesManager: AppTypes.ServicesManager;
highlightIntervalId = null;
readonly EVENTS = EVENTS;
@@ -102,6 +103,8 @@ class SegmentationService extends PubSubService {
this._segmentationIdToColorLUTIndexMap = new Map();
this.servicesManager = servicesManager;
this._segmentationGroupStatsMap = new Map();
}
public onModeEnter(): void {
@@ -1058,6 +1061,33 @@ class SegmentationService extends PubSubService {
public getRenderInactiveSegmentations(viewportId: string): boolean {
return cstSegmentation.config.style.getRenderInactiveSegmentations(viewportId);
}
/**
* Sets statistics for a group of segmentations
* @param segmentationIds - Array of segmentation IDs that form the group
* @param stats - Statistics object containing metrics for the segmentation group
*
* @remarks
* This method stores statistical data for a group of related segmentations.
* The stats are stored using a composite key created from the sorted and joined
*/
public setSegmentationGroupStats(segmentationIds: string[], stats: any) {
const groupId = this.getGroupId(segmentationIds);
this._segmentationGroupStatsMap.set(groupId, stats);
}
/**
* Gets statistics for a group of segmentations
* @param segmentationIds - Array of segmentation IDs that form the group
* @returns The stored statistics object for the segmentation group if found, undefined otherwise
*/
public getSegmentationGroupStats(segmentationIds: string[]) {
const groupId = this.getGroupId(segmentationIds);
return this._segmentationGroupStatsMap.get(groupId);
}
private getGroupId(segmentationIds: string[]) {
return segmentationIds.sort().join(',');
}
/**
* Toggles the visibility of a segmentation in the state, and broadcasts the event.
@@ -0,0 +1,139 @@
export function generateSegmentationCSVReport(
segmentationData,
info: {
reference: {
SeriesNumber: string;
SeriesInstanceUID: string;
StudyInstanceUID: string;
SeriesDate: string;
SeriesTime: string;
SeriesDescription: string;
};
}
) {
// Initialize the rows for our CSV
const csvRows = [];
// Add segmentation-level information
csvRows.push(['Segmentation ID', segmentationData.segmentationId || '']);
csvRows.push(['Segmentation Label', segmentationData.label || '']);
csvRows.push([]);
const additionalInfo = info.reference;
// Add reference information
const referenceKeys = [
['Series Number', additionalInfo.SeriesNumber],
['Series Instance UID', additionalInfo.SeriesInstanceUID],
['Study Instance UID', additionalInfo.StudyInstanceUID],
['Series Date', additionalInfo.SeriesDate],
['Series Time', additionalInfo.SeriesTime],
['Series Description', additionalInfo.SeriesDescription],
];
referenceKeys.forEach(([key, value]) => {
if (value) {
csvRows.push([`reference ${key}`, value]);
}
});
// Add a blank row for separation
csvRows.push([]);
csvRows.push(['Segments Statistics']);
// Add segment information in columns
if (segmentationData.segments) {
// First row: Segment headers
const segmentHeaderRow = ['Label'];
for (const segmentId in segmentationData.segments) {
const segment = segmentationData.segments[segmentId];
segmentHeaderRow.push(`${segment.label || ''}`);
}
csvRows.push(segmentHeaderRow);
// Add segment properties
csvRows.push([
'Segment Index',
...Object.values(segmentationData.segments).map(s => s.segmentIndex || ''),
]);
csvRows.push([
'Locked',
...Object.values(segmentationData.segments).map(s => (s.locked ? 'Yes' : 'No')),
]);
csvRows.push([
'Active',
...Object.values(segmentationData.segments).map(s => (s.active ? 'Yes' : 'No')),
]);
// Add segment statistics
// First, collect all unique statistics across all segments
const allStats = new Set();
for (const segment of Object.values(segmentationData.segments)) {
if (segment.cachedStats && segment.cachedStats.namedStats) {
for (const statKey in segment.cachedStats.namedStats) {
const stat = segment.cachedStats.namedStats[statKey];
const statLabel = stat.label || stat.name;
const statUnit = stat.unit ? ` (${stat.unit})` : '';
allStats.add(`${statLabel}${statUnit}`);
}
}
}
// Then create a row for each statistic
for (const statName of allStats) {
const statRow = [statName];
for (const segment of Object.values(segmentationData.segments)) {
let statValue = '';
if (segment.cachedStats && segment.cachedStats.namedStats) {
for (const statKey in segment.cachedStats.namedStats) {
const stat = segment.cachedStats.namedStats[statKey];
const currentStatName = `${stat.label || stat.name}${stat.unit ? ` (${stat.unit})` : ''}`;
if (currentStatName === statName) {
statValue = stat.value !== undefined ? stat.value : '';
break;
}
}
}
statRow.push(statValue);
}
csvRows.push(statRow);
}
}
// Convert to CSV string
let csvString = '';
for (const row of csvRows) {
const formattedRow = row.map(cell => {
// Handle values that need to be quoted (contain commas, quotes, or newlines)
const cellValue = cell !== undefined && cell !== null ? cell.toString() : '';
if (cellValue.includes(',') || cellValue.includes('"') || cellValue.includes('\n')) {
// Escape quotes and wrap in quotes
return '"' + cellValue.replace(/"/g, '""') + '"';
}
return cellValue;
});
csvString += formattedRow.join(',') + '\n';
}
// Create a download link and trigger the download
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute(
'download',
`${segmentationData.label || 'Segmentation'}_Report_${new Date().toISOString().split('T')[0]}.csv`
);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
@@ -0,0 +1,185 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
import { utils } from '@ohif/core';
import { getDisplayUnit } from './utils';
import { getIsLocked } from './utils/getIsLocked';
import { getIsVisible } from './utils/getIsVisible';
const SegmentBidirectional = {
toAnnotation: measurement => {},
toMeasurement: (
csToolsEventDetail,
displaySetService,
cornerstoneViewportService,
getValueTypeFromToolType,
customizationService
) => {
const { annotation } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
debugger;
const isLocked = getIsLocked(annotationUID);
const isVisible = getIsVisible(annotationUID);
if (!metadata || !data) {
console.debug('SegmentBidirectional tool: Missing metadata or data');
return null;
}
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
const validToolType = SUPPORTED_TOOLS.includes(toolName);
if (!validToolType) {
throw new Error('Tool not supported');
}
const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } = getSOPInstanceAttributes(
referencedImageId,
displaySetService,
annotation
);
let displaySet;
if (SOPInstanceUID) {
displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID)[0];
}
const { points, textBox } = data.handles;
const mappedAnnotations = getMappedAnnotations(annotation, displaySetService);
const displayText = getDisplayText(mappedAnnotations, displaySet);
const getReport = () =>
_getReport(mappedAnnotations, points, FrameOfReferenceUID, customizationService);
return {
uid: annotationUID,
SOPInstanceUID,
FrameOfReferenceUID,
points,
textBox,
isLocked,
isVisible,
metadata,
referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID,
referencedImageId,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.label,
displayText: displayText,
data: data.cachedStats,
type: getValueTypeFromToolType(toolName),
getReport,
};
},
};
function getMappedAnnotations(annotation, displaySetService) {
const { metadata, data } = annotation;
const { cachedStats } = data;
const { referencedImageId } = metadata;
const targets = Object.keys(cachedStats);
if (!targets.length) {
return [];
}
const annotations = [];
Object.keys(cachedStats).forEach(targetId => {
const targetStats = cachedStats[targetId];
const { SOPInstanceUID, SeriesInstanceUID, frameNumber } = getSOPInstanceAttributes(
referencedImageId,
displaySetService,
annotation
);
const displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID)[0];
const { SeriesNumber } = displaySet;
const { length, width, unit } = targetStats;
annotations.push({
SeriesInstanceUID,
SOPInstanceUID,
SeriesNumber,
frameNumber,
unit,
length,
width,
});
});
return annotations;
}
function _getReport(mappedAnnotations, points, FrameOfReferenceUID, customizationService) {
const columns = [];
const values = [];
// Add Type
columns.push('AnnotationType');
values.push('Cornerstone:SegmentBidirectional');
mappedAnnotations.forEach(annotation => {
const { length, width, unit } = annotation;
columns.push(`Length`, `Width`, 'Unit');
values.push(length, width, unit);
});
if (FrameOfReferenceUID) {
columns.push('FrameOfReferenceUID');
values.push(FrameOfReferenceUID);
}
if (points) {
columns.push('points');
values.push(points.map(p => p.join(' ')).join(';'));
}
return {
columns,
values,
};
}
function getDisplayText(mappedAnnotations, displaySet) {
const displayText = {
primary: [],
secondary: [],
};
if (!mappedAnnotations || !mappedAnnotations.length) {
return displayText;
}
// Area is the same for all series
const { length, width, unit, SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0];
const roundedLength = utils.roundNumber(length, 2);
const roundedWidth = utils.roundNumber(width, 2);
const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID);
let InstanceNumber;
if (instance) {
InstanceNumber = instance.InstanceNumber;
}
const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
displayText.primary.push(`L: ${roundedLength} ${getDisplayUnit(unit)}`);
displayText.primary.push(`W: ${roundedWidth} ${getDisplayUnit(unit)}`);
displayText.secondary.push(`S: ${SeriesNumber}${instanceText}${frameText}`);
return displayText;
}
export default SegmentBidirectional;
@@ -13,6 +13,7 @@ const supportedTools = [
'LivewireContour',
'UltrasoundDirectionalTool',
'SCOORD3DPoint',
'SegmentBidirectional',
];
export default supportedTools;
@@ -12,6 +12,7 @@ import SplineROI from './SplineROI';
import LivewireContour from './LivewireContour';
import Probe from './Probe';
import UltrasoundDirectional from './UltrasoundDirectional';
import SegmentBidirectional from './SegmentBidirectional';
const measurementServiceMappingsFactory = (
measurementService: MeasurementService,
@@ -48,6 +49,7 @@ const measurementServiceMappingsFactory = (
LivewireContour: POLYLINE,
Probe: POINT,
UltrasoundDirectional: POLYLINE,
SegmentBidirectional: BIDIRECTIONAL,
};
return TOOL_TYPE_TO_VALUE_TYPE[toolType];
@@ -94,6 +96,27 @@ const measurementServiceMappingsFactory = (
},
],
},
SegmentBidirectional: {
toAnnotation: SegmentBidirectional.toAnnotation,
toMeasurement: csToolsAnnotation =>
SegmentBidirectional.toMeasurement(
csToolsAnnotation,
displaySetService,
cornerstoneViewportService,
_getValueTypeFromToolType,
customizationService
),
matchingCriteria: [
{
valueType: MeasurementService.VALUE_TYPES.POLYLINE,
points: 2,
},
{
valueType: MeasurementService.VALUE_TYPES.POLYLINE,
points: 2,
},
],
},
EllipticalROI: {
toAnnotation: EllipticalROI.toAnnotation,
toMeasurement: csToolsAnnotation =>
@@ -0,0 +1,185 @@
import * as cornerstoneTools from '@cornerstonejs/tools';
interface BidirectionalAxis {
length: number;
// Add other axis properties as needed
}
interface BidirectionalData {
majorAxis: BidirectionalAxis;
minorAxis: BidirectionalAxis;
}
/**
* Updates the statistics for a segmentation by calculating stats for each segment
* and storing them in the segment's cachedStats property
*
* @param segmentation - The segmentation object containing segments to update stats for
* @param segmentationId - The ID of the segmentation
* @returns The updated segmentation object with new stats, or null if no updates were made
*/
export async function updateSegmentationStats({
segmentation,
segmentationId,
readableText,
}: {
segmentation: any;
segmentationId: string;
readableText: any;
}): Promise<any | null> {
if (!segmentation) {
console.debug('No segmentation found for id:', segmentationId);
return null;
}
const segmentIndices = Object.keys(segmentation.segments)
.map(index => parseInt(index))
.filter(index => index > 0); // Filter out segment 0 which is typically background
if (segmentIndices.length === 0) {
console.debug('No segments found in segmentation:', segmentationId);
return null;
}
const stats = await cornerstoneTools.utilities.segmentation.getStatistics({
segmentationId,
segmentIndices,
mode: 'individual',
});
if (!stats) {
return null;
}
const updatedSegmentation = { ...segmentation };
let hasUpdates = false;
// Loop through each segment's stats
Object.entries(stats).forEach(([segmentIndex, segmentStats]) => {
const index = parseInt(segmentIndex);
if (!updatedSegmentation.segments[index].cachedStats) {
updatedSegmentation.segments[index].cachedStats = {};
hasUpdates = true;
}
// Get existing namedStats or initialize if not present
const namedStats = updatedSegmentation.segments[index].cachedStats.namedStats || {};
if (segmentStats.array) {
segmentStats.array.forEach(stat => {
// only gather stats that are in the readableText
if (!readableText[stat.name]) {
return;
}
if (stat && stat.name) {
namedStats[stat.name] = {
name: stat.name,
label: readableText[stat.name],
value: stat.value,
unit: stat.unit,
order: Object.keys(readableText).indexOf(stat.name),
};
}
});
if (readableText.volume) {
// Add volume if it exists but isn't in the array
if (segmentStats.volume && !namedStats.volume) {
namedStats.volume = {
name: 'volume',
label: 'Volume',
value: segmentStats.volume.value,
unit: segmentStats.volume.unit,
order: Object.keys(readableText).indexOf('volume'),
};
}
}
// Update the segment's cachedStats with namedStats
updatedSegmentation.segments[index].cachedStats.namedStats = namedStats;
hasUpdates = true;
}
});
return hasUpdates ? updatedSegmentation : null;
}
/**
* Updates a segment's statistics with bidirectional measurement data
*
* @param segmentationId - The ID of the segmentation
* @param segmentIndex - The index of the segment to update
* @param bidirectionalData - The bidirectional measurement data to add
* @param segmentationService - The segmentation service to use for updating the segment
* @returns Whether the update was successful
*/
export function updateSegmentBidirectionalStats({
segmentationId,
segmentIndex,
bidirectionalData,
segmentationService,
annotation,
}: {
segmentationId: string;
segmentIndex: number;
bidirectionalData: BidirectionalData;
segmentationService: AppTypes.SegmentationService;
annotation: any;
}) {
if (!segmentationId || segmentIndex === undefined || !bidirectionalData) {
console.debug('Missing required data for bidirectional stats update');
return null;
}
const segmentation = segmentationService.getSegmentation(segmentationId);
if (!segmentation || !segmentation.segments[segmentIndex]) {
console.debug('Segment not found:', segmentIndex, 'in segmentation:', segmentationId);
return null;
}
const updatedSegmentation = { ...segmentation };
const segment = updatedSegmentation.segments[segmentIndex];
if (!segment.cachedStats) {
segment.cachedStats = { namedStats: {} };
}
if (!segment.cachedStats.namedStats) {
segment.cachedStats.namedStats = {};
}
const { majorAxis, minorAxis, maxMajor, maxMinor } = bidirectionalData;
if (!majorAxis || !minorAxis) {
console.debug('Missing major or minor axis data');
return null;
}
let hasUpdates = false;
const namedStats = segment.cachedStats.namedStats;
// Only calculate and update if we have valid measurements
if (maxMajor > 0 && maxMinor > 0) {
namedStats.bidirectional = {
name: 'bidirectional',
label: 'Bidirectional',
annotationUID: annotation.annotationUID,
value: {
maxMajor,
maxMinor,
majorAxis,
minorAxis,
},
unit: 'mm',
};
hasUpdates = true;
}
if (hasUpdates) {
return updatedSegmentation;
}
return null;
}
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
//
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import getImageSrcFromImageId from './getImageSrcFromImageId';
@@ -26,7 +26,7 @@ export function ToolBoxButtonGroupWrapper({ groupId, buttonSection, ...props })
{...item}
key={item.id}
size="small"
className={props.disabled && 'text-primary'}
className={item.disabled && 'text-foreground/70'}
onInteraction={event => {
onInteraction?.({
event,
@@ -49,7 +49,7 @@ export function ToolBoxButtonWrapper({ onInteraction, options, ...props }) {
{...props}
id={props.id}
size="small"
className={classNames(props.disabled && 'text-primary')}
className={classNames(props.disabled && 'text-foreground/70')}
onInteraction={event => {
onInteraction?.({
event,
+2 -2
View File
@@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^3.5.0",
"@cornerstonejs/tools": "^3.5.0",
"@cornerstonejs/core": "^3.7.0",
"@cornerstonejs/tools": "^3.7.0",
"@ohif/core": "3.10.0-beta.125",
"@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.125",
"@ohif/extension-default": "3.10.0-beta.125",
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
//
import PanelStudyBrowserTracking from './PanelStudyBrowserTracking';
import getImageSrcFromImageId from './getImageSrcFromImageId';
@@ -11,11 +11,9 @@ export default function PanelRoiThresholdSegmentation({
const { segmentationsWithRepresentations: segmentationsInfo } =
useActiveViewportSegmentationRepresentations({ servicesManager });
useEffect(() => {
const segmentationIds = segmentationsInfo.map(
segmentationInfo => segmentationInfo.segmentation.segmentationId
);
const segmentationIds = segmentationsInfo?.map(info => info.segmentation.segmentationId) || [];
useEffect(() => {
const initialRun = async () => {
for (const segmentationId of segmentationIds) {
await handleROIThresholding({
@@ -54,10 +52,9 @@ export default function PanelRoiThresholdSegmentation({
}, [commandsManager, segmentationService]);
// Find the first segmentation with a TMTV value since all of them have the same value
const tmtvSegmentation = segmentationsInfo.find(
info => info.segmentation.cachedStats?.tmtv !== undefined
);
const tmtvValue = tmtvSegmentation?.segmentation.cachedStats?.tmtv;
const stats = segmentationService.getSegmentationGroupStats(segmentationIds);
const tmtvValue = stats?.tmtv;
const tlgValue = stats?.tlg;
return (
<div className="mt-2 mb-10 flex flex-col">
@@ -68,6 +65,7 @@ export default function PanelRoiThresholdSegmentation({
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue?.toFixed(3)} mL`}</div>
<div className="text-white">{`${tlgValue?.toFixed(3)} mL`}</div>
</div>
) : null}
</div>
+7 -185
View File
@@ -24,20 +24,7 @@ const ROI_THRESHOLD_MANUAL_TOOL_IDS = [
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
});
};
function getVolumesFromSegmentation(segmentationId) {
const csSegmentation = csTools.segmentation.state.getSegmentation(segmentationId);
@@ -262,153 +249,22 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
{ overwrite: true, segmentIndex, segmentationId }
);
},
calculateSuvPeak: async ({ segmentationId, segmentIndex }) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const { representationData } = segmentation;
const { volumeId, referencedVolumeId } = representationData[
SegmentationRepresentations.Labelmap
] as csTools.Types.LabelmapToolOperationDataVolume;
const labelmap = cs.cache.getVolume(volumeId);
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
// if we put it in the top, it will appear in other modes
workerManager.registerWorker('suv-peak-worker', workerFn, options);
const annotationUIDs = _getAnnotationsSelectedByToolNames(ROI_THRESHOLD_MANUAL_TOOL_IDS);
const annotations = annotationUIDs.map(annotationUID =>
csTools.annotation.state.getAnnotation(annotationUID)
);
const labelmapProps = {
dimensions: labelmap.dimensions,
origin: labelmap.origin,
direction: labelmap.direction,
spacing: labelmap.spacing,
metadata: labelmap.metadata,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
};
const referenceVolumeProps = {
dimensions: referencedVolume.dimensions,
origin: referencedVolume.origin,
direction: referencedVolume.direction,
spacing: referencedVolume.spacing,
metadata: referencedVolume.metadata,
scalarData: referencedVolume.voxelManager.getCompleteScalarDataArray(),
};
// metadata in annotations has enabledElement which is not serializable
// we need to remove it
// Todo: we should probably have a sanitization function for this
const annotationsToSend = annotations.map(annotation => {
return {
...annotation,
metadata: {
...annotation.metadata,
enabledElement: {
...annotation.metadata.enabledElement,
viewport: null,
renderingEngine: null,
element: null,
},
},
};
});
const suvPeak =
(await workerManager.executeTask('suv-peak-worker', 'calculateSuvPeak', {
labelmapProps,
referenceVolumeProps,
annotations: annotationsToSend,
segmentIndex,
})) || {};
return {
suvPeak: suvPeak.mean,
suvMax: suvPeak.max,
suvMaxIJK: suvPeak.maxIJK,
suvMaxLPS: suvPeak.maxLPS,
};
},
getLesionStats: ({ segmentationId, segmentIndex = 1 }) => {
const { labelmapVolume, referencedVolume } = getVolumesFromSegmentation(segmentationId);
const { voxelManager: segVoxelManager, imageData, spacing } = labelmapVolume;
const { voxelManager: refVoxelManager } = referencedVolume;
let segmentationMax = -Infinity;
let segmentationMin = Infinity;
const segmentationValues = [];
let voxelCount = 0;
const callback = ({ value, index }) => {
if (value === segmentIndex) {
const refValue = refVoxelManager.getAtIndex(index) as number;
segmentationValues.push(refValue);
if (refValue > segmentationMax) {
segmentationMax = refValue;
}
if (refValue < segmentationMin) {
segmentationMin = refValue;
}
voxelCount++;
}
};
segVoxelManager.forEach(callback, { imageData });
const mean = segmentationValues.reduce((a, b) => a + b, 0) / voxelCount;
const stats = {
minValue: segmentationMin,
maxValue: segmentationMax,
meanValue: mean,
stdValue: Math.sqrt(
segmentationValues.map(k => (k - mean) ** 2).reduce((acc, curr) => acc + curr, 0) /
voxelCount
),
volume: voxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3,
};
return stats;
},
calculateLesionGlycolysis: ({ lesionStats }) => {
const { meanValue, volume } = lesionStats;
return {
lesionGlyoclysisStats: volume * meanValue,
};
},
calculateTMTV: async ({ segmentations }) => {
const labelmapProps = segmentations.map(segmentation => {
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
return {
dimensions: labelmap.dimensions,
spacing: labelmap.spacing,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
origin: labelmap.origin,
direction: labelmap.direction,
};
const segmentationIds = segmentations.map(segmentation => segmentation.segmentationId);
const stats = await csTools.utilities.segmentation.computeMetabolicStats({
segmentationIds,
segmentIndex: 1,
});
if (!labelmapProps.length) {
return;
}
const tmtv = await workerManager.executeTask(
'suv-peak-worker',
'calculateTMTV',
labelmapProps
);
return tmtv;
segmentationService.setSegmentationGroupStats(segmentationIds, stats);
return stats;
},
exportTMTVReportCSV: async ({ segmentations, tmtv, config, options }) => {
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
segmentations,
});
const tlg = await actions.getTotalLesionGlycolysis({ segmentations });
const additionalReportRows = [
{ key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } },
{ key: 'Threshold Configuration', value: { ...config } },
@@ -423,35 +279,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
createAndDownloadTMTVReport(segReport, additionalReportRows, options);
},
getTotalLesionGlycolysis: async ({ segmentations }) => {
const labelmapProps = segmentations.map(segmentation => {
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
return {
dimensions: labelmap.dimensions,
spacing: labelmap.spacing,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
origin: labelmap.origin,
direction: labelmap.direction,
};
});
const { referencedVolume: ptVolume } = getVolumesFromSegmentation(
segmentations[0].segmentationId
);
const ptVolumeProps = {
dimensions: ptVolume.dimensions,
spacing: ptVolume.spacing,
scalarData: ptVolume.voxelManager.getCompleteScalarDataArray(),
origin: ptVolume.origin,
direction: ptVolume.direction,
};
return await workerManager.executeTask('suv-peak-worker', 'getTotalLesionGlycolysis', {
labelmapProps,
referenceVolumeProps: ptVolumeProps,
});
},
setStartSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const { focalPoint } = viewport.getCamera();
@@ -651,12 +479,6 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
getTotalLesionGlycolysis: {
commandFn: actions.getTotalLesionGlycolysis,
},
calculateSuvPeak: {
commandFn: actions.calculateSuvPeak,
},
getLesionStats: {
commandFn: actions.getLesionStats,
},
calculateTMTV: {
commandFn: actions.calculateTMTV,
},
@@ -1,209 +0,0 @@
import { utilities } from '@cornerstonejs/core';
import { utilities as cstUtils } 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';
const createVolume = ({ dimensions, origin, direction, spacing, metadata, 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 voxelManager = utilities.VoxelManager.createScalarVolumeVoxelManager({
scalarData,
dimensions,
numberOfComponents: 1,
});
return {
imageData,
spacing,
origin,
direction,
metadata,
voxelManager,
};
};
/**
* 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
* rectangle. Otherwise, the calculation is performed on the entire volume which
* will be slower but same result.
* @param viewport Viewport to use for the calculation
* @param labelmap Labelmap from which the mask is taken
* @param referenceVolume PET volume to use for SUV calculation
* @param toolData [Optional] list of toolData to use for SUV calculation
* @param segmentIndex The index of the segment to use for masking
* @returns
*/
function calculateSuvPeak({ labelmapProps, referenceVolumeProps, annotations, segmentIndex = 1 }) {
const labelmapInfo = createVolume(labelmapProps);
const referenceInfo = createVolume(referenceVolumeProps);
if (referenceInfo.metadata.Modality !== 'PT') {
return;
}
const { dimensions, imageData: labelmapImageData } = labelmapInfo;
const { imageData: referenceVolumeImageData } = referenceInfo;
let boundsIJK;
// Todo: using the first annotation for now
if (annotations?.length && annotations[0].data?.cachedStats) {
const { projectionPoints } = annotations[0].data.cachedStats;
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now
const rectangleCornersIJK = pointsToUse.map(world => {
const ijk = vec3.fromValues(0, 0, 0);
referenceVolumeImageData.worldToIndex(world, ijk);
return ijk;
});
boundsIJK = cstUtils.boundingBox.getBoundingBoxAroundShape(rectangleCornersIJK, dimensions);
}
let max = 0;
let maxIJK = [0, 0, 0];
let maxLPS = [0, 0, 0];
const callback = ({ pointIJK, pointLPS }) => {
const value = labelmapInfo.voxelManager.getAtIJKPoint(pointIJK);
if (value !== segmentIndex) {
return;
}
const referenceValue = referenceInfo.voxelManager.getAtIJKPoint(pointIJK);
if (referenceValue > max) {
max = referenceValue;
maxIJK = pointIJK;
maxLPS = pointLPS;
}
};
labelmapInfo.voxelManager.forEach(callback, {
boundsIJK,
imageData: labelmapImageData,
isInObject: () => true,
returnPoints: true,
});
const direction = labelmapImageData.getDirection().slice(0, 3);
/**
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
* V = (4/3)πr3
*/
const radius = Math.pow(1 / ((4 / 3) * Math.PI), 1 / 3) * 10;
const diameter = radius * 2;
const secondaryCircleWorld = vec3.create();
const bottomWorld = vec3.create();
const topWorld = vec3.create();
referenceVolumeImageData.indexToWorld(maxIJK, secondaryCircleWorld);
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
const suvPeakCirclePoints = [bottomWorld, topWorld];
/**
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
* sphere
*/
let count = 0;
let acc = 0;
const suvPeakMeanCallback = ({ value }) => {
acc += value;
count += 1;
};
cstUtils.pointInSurroundingSphereCallback(
referenceVolumeImageData,
suvPeakCirclePoints,
suvPeakMeanCallback
);
const mean = acc / count;
return {
max,
maxIJK,
maxLPS,
mean,
};
}
function calculateTMTV(labelmapProps, segmentIndex = 1) {
const labelmaps = labelmapProps.map(props => createVolume(props));
const mergedLabelmap =
labelmaps.length === 1
? labelmaps[0]
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
const { imageData, spacing } = mergedLabelmap;
const values = imageData.getPointData().getScalars().getData();
// count non-zero values inside the outputData, this would
// consider the overlapping regions to be only counted once
const numVoxels = values.reduce((acc, curr) => {
if (curr > 0) {
return acc + 1;
}
return acc;
}, 0);
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
}
function getTotalLesionGlycolysis({ labelmapProps, referenceVolumeProps }) {
const labelmaps = labelmapProps.map(props => createVolume(props));
const mergedLabelmap =
labelmaps.length === 1
? labelmaps[0]
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
// grabbing the first labelmap referenceVolume since it will be the same for all
const { spacing } = labelmaps[0];
const ptVolume = createVolume(referenceVolumeProps);
let suv = 0;
let totalLesionVoxelCount = 0;
const scalarDataLength = mergedLabelmap.voxelManager.getScalarDataLength();
for (let i = 0; i < scalarDataLength; i++) {
// if not background
if (mergedLabelmap.voxelManager.getAtIndex(i) !== 0) {
suv += ptVolume.voxelManager.getAtIndex(i);
totalLesionVoxelCount += 1;
}
}
// Average SUV for the merged labelmap
const averageSuv = suv / totalLesionVoxelCount;
// total Lesion Glycolysis [suv * ml]
return averageSuv * totalLesionVoxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3;
}
const obj = {
calculateSuvPeak,
calculateTMTV,
getTotalLesionGlycolysis,
};
expose(obj);
@@ -1,42 +0,0 @@
import { Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
/**
* Given a list of labelmaps (with the possibility of overlapping regions),
* and a referenceVolume, it calculates the total metabolic tumor volume (TMTV)
* by flattening and rasterizing each segment into a single labelmap and summing
* the total number of volume voxels. It should be noted that for this calculation
* we do not double count voxels that are part of multiple labelmaps.
* @param {} labelmaps
* @param {number} segmentIndex
* @returns {number} TMTV in ml
*/
function calculateTMTV(labelmaps: Array<Types.IImageVolume>, segmentIndex = 1): number {
const volumeId = 'mergedLabelmap';
const mergedLabelmap = utilities.segmentation.createMergedLabelmapForIndex(
labelmaps,
segmentIndex,
volumeId
);
const { imageData, spacing, voxelManager } = mergedLabelmap;
// count non-zero values inside the outputData, this would
// consider the overlapping regions to be only counted once
let numVoxels = 0;
const callback = ({ value }) => {
if (value > 0) {
numVoxels += 1;
}
};
voxelManager.forEach(callback, {
imageData,
isInObject: () => true,
});
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
}
export default calculateTMTV;
@@ -1,72 +1,12 @@
import { Segment, Segmentation } from '@cornerstonejs/tools/types';
import { triggerEvent, eventTarget, Enums } from '@cornerstonejs/core';
export const handleROIThresholding = async ({
segmentationId,
commandsManager,
segmentationService,
}: withAppTypes<{
segmentationId: string;
}>) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
progress: 0,
type: 'Calculate Lesion Stats',
id: segmentationId,
});
// re-calculating the cached stats for the active segmentation
const updatedPerSegmentCachedStats = {};
for (const [segmentIndex, segment] of Object.entries(segmentation.segments)) {
if (!segment) {
continue;
}
const numericSegmentIndex = Number(segmentIndex);
const lesionStats = await commandsManager.run('getLesionStats', {
segmentationId,
segmentIndex: numericSegmentIndex,
});
const suvPeak = await commandsManager.run('calculateSuvPeak', {
segmentationId,
segmentIndex: numericSegmentIndex,
});
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const updatedSegment: Segment = {
...segment,
cachedStats: {
...segment.cachedStats,
...cachedStats,
},
};
updatedPerSegmentCachedStats[numericSegmentIndex] = cachedStats;
segmentation.segments[segmentIndex] = updatedSegment;
}
// all available segmentations
const segmentations = segmentationService.getSegmentations();
const tmtv = await commandsManager.run('calculateTMTV', { segmentations });
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
progress: 100,
type: 'Calculate Lesion Stats',
id: segmentationId,
});
// add the tmtv to all the segment cachedStats, although it is a global
// value but we don't have any other way to display it for now
// Update all segmentations with the calculated TMTV
@@ -76,22 +16,6 @@ export const handleROIThresholding = async ({
tmtv,
};
// Update each segment within the segmentation
Object.keys(segmentation.segments).forEach(segmentIndex => {
segmentation.segments[segmentIndex].cachedStats = {
...segmentation.segments[segmentIndex].cachedStats,
tmtv,
};
});
// Update the segmentation object
const updatedSegmentation: Segmentation = {
...segmentation,
segments: {
...segmentation.segments,
},
};
segmentationService.addOrUpdateSegmentation(updatedSegmentation);
segmentationService.addOrUpdateSegmentation(segmentation);
});
};