diff --git a/packages/tools/examples/rectangleROIStartEndThresholdWithSegmentation/index.ts b/packages/tools/examples/rectangleROIStartEndThresholdWithSegmentation/index.ts index 75021a6687..1c99cf838d 100644 --- a/packages/tools/examples/rectangleROIStartEndThresholdWithSegmentation/index.ts +++ b/packages/tools/examples/rectangleROIStartEndThresholdWithSegmentation/index.ts @@ -11,7 +11,9 @@ import { createImageIdsAndCacheMetaData, setTitleAndDescription, addButtonToToolbar, - setCtTransferFunctionForVolumeActor, + addCheckboxToToolbar, + addInputToToolbar, + setPetTransferFunctionForVolumeActor, getLocalUrl, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -37,14 +39,86 @@ const { MouseBindings } = csToolsEnums; const { ViewportType } = Enums; // Define a unique id for the volume -const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const volumeName = 'PT_VOLUME_ID'; // Id of the volume less loader prefix const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id const segmentationId = 'MY_SEGMENTATION_ID'; const toolGroupId = 'MY_TOOLGROUP_ID'; -let segmentationRepresentationByUID; +let thresholdLower = 0.41; // Typical default +let thresholdByMaxRelative = true; + +// Helper function - based off of getThresholdValue from OHIF Viewer +function getReliableAnnotationMaxValue(annotation, volume) { + if (!annotation) { + console.error('getReliableAnnotationMaxValue: Missing annotation.'); + return -Infinity; // Or throw error + } + + if (!volume) { + console.error('getReliableAnnotationMaxValue: Missing volume.'); + return -Infinity; // Or throw error + } + + if (!volume.imageData || typeof volume.imageData.get !== 'function') { + console.error( + `getReliableAnnotationMaxValue: Volume ${volume.volumeId} or its imageData/get method not found.` + ); + return -Infinity; + } + + const primaryVmData = volume.imageData.get('voxelManager'); + if (!primaryVmData || !primaryVmData.voxelManager) { + console.error( + `getReliableAnnotationMaxValue: Nested voxelManager not found for volume ${volume.volumeId}.` + ); + return -Infinity; + } + const actualVoxelManager = primaryVmData.voxelManager; + + const boundsIJK = + cornerstoneTools.utilities.rectangleROITool.getBoundsIJKFromRectangleAnnotations( + [annotation], // Pass as an array + volume + ); + + if (!boundsIJK) { + console.warn( + `getReliableAnnotationMaxValue: Could not get boundsIJK for annotation ${annotation.annotationUID}` + ); + return -Infinity; + } + + let maxValue = -Infinity; + let iteratedCount = 0; + try { + actualVoxelManager.forEach( + ({ value: voxelValue }) => { + iteratedCount++; + if (voxelValue > maxValue) { + maxValue = voxelValue; + } + }, + { boundsIJK } + ); + } catch (e) { + console.error( + `Error during voxelManager.forEach in getReliableAnnotationMaxValue: ${e.message}` + ); + return -Infinity; // Or rethrow if critical + } + + if (iteratedCount === 0 && maxValue === -Infinity) { + console.warn( + `getReliableAnnotationMaxValue: forEach did not iterate or all values <= -Infinity for annotation ${ + annotation.annotationUID + }. BoundsIJK: ${JSON.stringify(boundsIJK)}.` + ); + } + + return maxValue; +} // ======== Set up page ======== // setTitleAndDescription( @@ -168,26 +242,82 @@ addButtonToToolbar({ onClick: () => { const annotations = cornerstoneTools.annotation.state.getAllAnnotations(); const labelmapVolume = cache.getVolume(segmentationId); + const volume = cache.getVolume(volumeId); - annotations.map((annotation, i) => { - // @ts-ignore - const pointsInVolume = annotation.data.cachedStats.pointsInVolume; - for (let i = 0; i < pointsInVolume.length; i++) { - for (let j = 0; j < pointsInVolume[i].length; j++) { - if (pointsInVolume[i][j].value > 2) { - labelmapVolume.voxelManager.setAtIndex( - pointsInVolume[i][j].index, - 1 - ); - } - } - } + const annotationUIDs = annotations.map((a) => { + return a.annotationUID; }); + const upper = Infinity; + let lower = thresholdLower; + if (thresholdByMaxRelative) { + // Works normally, but doesn't populate in time for playwright + //const annotationMaxValue = annotations[0].data.cachedStats.statistics.max; + + // Alternative more reliable method + //const sourceVolume = cache.getVolume(volumeId); + const annotationMaxValue = getReliableAnnotationMaxValue( + annotations[0], + volume + ); + lower = thresholdLower * annotationMaxValue; + console.log(annotationMaxValue); + console.log(lower); + } + + //const volume = cache.getVolumes()[0]; // 0 is volume loaded + + cornerstoneTools.utilities.segmentation.rectangleROIThresholdVolumeByRange( + annotationUIDs, + labelmapVolume, + [{ volume, lower, upper }], + { overwrite: true, segmentationId } + ); + cornerstoneTools.segmentation.triggerSegmentationEvents.triggerSegmentationDataModified( labelmapVolume.volumeId ); labelmapVolume.modified(); + + // example calculate TMTV + const segmentationIds = [segmentationId]; + cornerstoneTools.utilities.segmentation + .computeMetabolicStats({ + segmentationIds, + segmentIndex: 1, + }) + .then((stats) => { + console.log(stats); + }); + + // Example get statistics over threshold region + cornerstoneTools.utilities.segmentation + .getStatistics({ + segmentationId, + segmentIndices: 1, + mode: 'individual', + }) + .then((stats) => { + console.log(stats); + }); + }, +}); + +addInputToToolbar({ + id: 'thresholdSlider', + title: `Threshold:`, + defaultValue: thresholdLower, + onSelectedValueChange: (newVal) => { + thresholdLower = parseFloat(newVal); + }, +}); + +addCheckboxToToolbar({ + id: 'thresholdMaxRelative', + title: 'Relative to Max', + checked: thresholdByMaxRelative, + onChange: (newChecked) => { + thresholdByMaxRelative = newChecked; }, }); @@ -271,10 +401,13 @@ async function run() { // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ - StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + StudyInstanceUID: '1.2.840.113619.2.290.3.3767434740.226.1600859119.501', // Water phantom + //'1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', // Original + SeriesInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + '2.16.840.1.114362.1.12114306.25269253871.642214905.509.634', // PT AC192 + //'1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015', // Original + wadoRsRoot: getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); @@ -283,6 +416,7 @@ async function run() { const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds, }); + // Add some segmentations based on the source data volume await addSegmentationsToState(); @@ -291,9 +425,9 @@ async function run() { const renderingEngine = new RenderingEngine(renderingEngineId); // Create the viewports - const viewportId1 = 'CT_AXIAL'; - const viewportId2 = 'CT_SAGITTAL'; - const viewportId3 = 'CT_CORONAL'; + const viewportId1 = 'PT_AXIAL'; + const viewportId2 = 'PT_SAGITTAL'; + const viewportId3 = 'PT_CORONAL'; const viewportInputArray = [ { @@ -302,7 +436,7 @@ async function run() { element: element1, defaultOptions: { orientation: Enums.OrientationAxis.AXIAL, - background: [0, 0, 0], + background: [1, 1, 1], }, }, { @@ -311,7 +445,7 @@ async function run() { element: element2, defaultOptions: { orientation: Enums.OrientationAxis.SAGITTAL, - background: [0, 0, 0], + background: [1, 1, 1], }, }, { @@ -320,7 +454,7 @@ async function run() { element: element3, defaultOptions: { orientation: Enums.OrientationAxis.CORONAL, - background: [0, 0, 0], + background: [1, 1, 1], }, }, ]; @@ -337,7 +471,7 @@ async function run() { // Set volumes on the viewports await setVolumesForViewports( renderingEngine, - [{ volumeId, callback: setCtTransferFunctionForVolumeActor }], + [{ volumeId, callback: setPetTransferFunctionForVolumeActor }], [viewportId1, viewportId2, viewportId3] ); diff --git a/playwright.config.ts b/playwright.config.ts index 37ba2da708..91d01082fa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ '**/labelmapsegmentationtools.spec.ts', '**/splineContourSegmentationTools.spec.ts', '**/stackLabelmapSegmentation.spec.ts', + '**/rectangleROIThresholdStatisticsMIM.spec.ts', '**/renderingPipeline.spec.ts', '**/stackLabelmapSegmentation/**.spec.ts', ], @@ -68,6 +69,7 @@ export default defineConfig({ '**/labelmapsegmentationtools.spec.ts', '**/splineContourSegmentationTools.spec.ts', '**/stackLabelmapSegmentation.spec.ts', + '**/rectangleROIThresholdStatisticsMIM.spec.ts', '**/stackLabelmapSegmentation/**.spec.ts', ], }, diff --git a/tests/rectangleROIThresholdStatisticsMIM.spec.ts b/tests/rectangleROIThresholdStatisticsMIM.spec.ts new file mode 100644 index 0000000000..735107fb5a --- /dev/null +++ b/tests/rectangleROIThresholdStatisticsMIM.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from 'playwright-test-coverage'; +import { visitExample, simulateClicksOnElement } from './utils/index'; + +test.beforeEach(async ({ page }) => { + await visitExample(page, 'rectangleROIStartEndThresholdWithSegmentation'); +}); + +const testCases = [ + { + name: 'Slice 1-5 Threshold 0.41% Statistics', + startSlice: 0, + endSlice: 5, // should be 4 once end slice also included + threshold: 0.41, + maxRelative: true, + expectedStats: { + // From MIM + kurtosis: 6.38, + max: 1.41, + mean: 0.99, + median: 1, + min: 0.58, + stdDev: 0.09, + skewness: -1.97, + //peakValue: 1.09, // edge case (start/end slice issue) + lesionGlycolysis: 474.68, + volume: 479.34, + }, + }, + { + name: 'Slice 10-15 0.41% Threshold Statistics', + startSlice: 9, + endSlice: 15, // should be 14 once end slice also included + threshold: 0.41, + maxRelative: true, + expectedStats: { + // From MIM + kurtosis: 10.04, + max: 1.09, + mean: 0.97, + median: 1, + min: 0.45, + stdDev: 0.1, + skewness: -3.17, + peakValue: 1.05, + lesionGlycolysis: 572.68, + volume: 589.97, + }, + }, + { + name: 'Slice 1-5 Threshold 0.8 Statistics', + startSlice: 0, + endSlice: 5, // should be 4 once end slice also included + threshold: 0.8, + maxRelative: false, + expectedStats: { + // From MIM + kurtosis: 2.7, + max: 1.41, + mean: 1, + median: 1.01, + min: 0.8, + stdDev: 0.06, + skewness: -0.08, + //peakValue: 1.09, // edge case (start/end slice issue) + lesionGlycolysis: 459.44, + volume: 457.4, + }, + }, + { + name: 'Slice 10-15 0.8 Threshold Statistics', + startSlice: 9, + endSlice: 15, // should be 14 once end slice also included + threshold: 0.8, + maxRelative: false, + expectedStats: { + // From MIM + kurtosis: 7.48, + max: 1.09, + mean: 1, + median: 1, + min: 0.8, + stdDev: 0.04, + skewness: -2.27, + peakValue: 1.05, + lesionGlycolysis: 546.97, + volume: 549.28, + }, + }, +]; + +testCases.forEach( + ({ name, startSlice, endSlice, threshold, maxRelative, expectedStats }) => { + test(name, async ({ page }) => { + // Jump to slice - Move to utils? + const jumpToSlice = async (sliceIndex) => { + await page.evaluate((index) => { + const cornerstone = window.cornerstone; + cornerstone.utilities.jumpToSlice( + cornerstone.getEnabledElementByViewportId('PT_AXIAL').viewport + .element, + { imageIndex: index } + ); + }, sliceIndex); + }; + + // Set threshold if provided + if (threshold) { + await page.locator('#thresholdSlider').fill(threshold.toString()); + } + + if (!maxRelative) { + await page.locator('#thresholdMaxRelative').uncheck(); + } + + // Goto start slice + await jumpToSlice(startSlice); + + // Define region + const locator = page.locator('canvas').first(); + await simulateClicksOnElement({ + locator, + points: [ + { x: 10, y: 10 }, + { x: 400, y: 400 }, + ], + }); + + // Set start slice + await page.getByRole('button', { name: 'Set Start Slice' }).click(); + + // Goto end slice + await jumpToSlice(endSlice); + + // Set end slice + await page.getByRole('button', { name: 'Set End Slice' }).click(); + + // Pause 150 ms to account for 100 ms debounce + await page.waitForTimeout(150); + + // Run threshold over ROI + await page.getByRole('button', { name: 'Run Segmentation' }).click(); + + // Get calculations + const stats = await page.evaluate(() => { + // Access csTools from window object + const cornerstoneTools = window.cornerstoneTools; + + // calculate Statistics over threshold region + const segmentationId = 'MY_SEGMENTATION_ID'; // from example page + return cornerstoneTools.utilities.segmentation.getStatistics({ + segmentationId, + segmentIndices: 1, + mode: 'individual', + }); + }); + + // Check Calculations + for (const stat in expectedStats) { + if (stat === 'lesionGlycolysis' || stat === 'volume') { + await expect + .soft(stats['1'][stat].value / 1000) + .toBeCloseTo(expectedStats[stat], 1); + } else { + await expect + .soft(stats['1'][stat].value) + .toBeCloseTo(expectedStats[stat], 2); + } + } + }); + } +); diff --git a/utils/demo/helpers/addInputToToolbar.ts b/utils/demo/helpers/addInputToToolbar.ts new file mode 100644 index 0000000000..81eb70f432 --- /dev/null +++ b/utils/demo/helpers/addInputToToolbar.ts @@ -0,0 +1,74 @@ +import { utilities as csUtilities } from '@cornerstonejs/core'; + +import type { configElement } from './createElement'; +import createElement from './createElement'; +import addLabelToToolbar from './addLabelToToolbar'; + +interface configSlider extends configElement { + id?: string; + title: string; + defaultValue: number; + container?: HTMLElement; + onSelectedValueChange: (value: string) => void; + updateLabelOnChange?: (value: string, label: HTMLElement) => void; + label?: configElement; +} + +export type DeleteFn = () => void; + +export default function addSliderToToolbar(config: configSlider): DeleteFn { + config = csUtilities.deepMerge(config, config.merge); + + config.container = + config.container ?? document.getElementById('demo-toolbar'); + + // + const elLabel = addLabelToToolbar({ + merge: config.label, + title: config.title, + container: config.container, + }); + + if (config.id) { + elLabel.id = `${config.id}-label`; + } + + elLabel.htmlFor = config.title; + + // + const fnInput = (evt: Event) => { + const selectElement = evt.target; + + if (selectElement) { + config.onSelectedValueChange(selectElement.value); + + if (config.updateLabelOnChange !== undefined) { + config.updateLabelOnChange(selectElement.value, elLabel); + } + } + }; + + // + const elInput = createElement({ + merge: config, + tag: 'input', + attr: { + type: 'number', + name: config.title, + }, + event: { + input: fnInput, + }, + }); + + if (config.id) { + elInput.id = config.id; + } + + elInput.value = String(config.defaultValue); + + return () => { + elLabel.remove(); + elInput.remove(); + }; +} diff --git a/utils/demo/helpers/index.js b/utils/demo/helpers/index.js index 8b5620c06a..b399c7fcc5 100644 --- a/utils/demo/helpers/index.js +++ b/utils/demo/helpers/index.js @@ -2,6 +2,7 @@ import addBrushSizeSlider from './addBrushSizeSlider'; import addButtonToToolbar from './addButtonToToolbar'; import addCheckboxToToolbar from './addCheckboxToToolbar'; import addDropdownToToolbar from './addDropdownToToolbar'; +import addInputToToolbar from './addInputToToolbar'; import addLabelToToolbar from './addLabelToToolbar'; import addManipulationBindings from './addManipulationBindings'; import addSegmentIndexDropdown from './addSegmentIndexDropdown'; @@ -39,6 +40,7 @@ export { addButtonToToolbar, addCheckboxToToolbar, addDropdownToToolbar, + addInputToToolbar, addLabelToToolbar, addManipulationBindings, addSegmentIndexDropdown,