diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts index a9ce6cc819..548a9629e7 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts @@ -40,6 +40,9 @@ import { import { applyPlanarVolumePresentation } from './planarVolumePresentation'; const SLICE_OVERLAY_DEPTH_EPSILON = 1e-4; +// Trailing delay before repainting a slab-projecting segmentation overlay +// after its volume was modified (brush edits fire many events per second). +const PROJECTING_OVERLAY_RENDER_DELAY_MS = 240; /** @internal */ export class VtkVolumeSliceRenderPath @@ -52,7 +55,7 @@ export class VtkVolumeSliceRenderPath ): Promise> { const payload: PlanarPayload = data as unknown as LoadedData; const imageVolume = payload.imageVolume; - const shouldInvalidateFullTextureOnVolumeModified = + const isSegmentationOverlay = options.role === 'overlay' && payload.reference?.kind === 'segmentation'; if (!imageVolume) { @@ -92,20 +95,50 @@ export class VtkVolumeSliceRenderPath ? { lower: defaultRange[0], upper: defaultRange[1] } : undefined, dataPresentation: undefined, - removeStreamingSubscriptions: subscribeToVolumeEvents( - payload.volumeId, - (eventType) => { - if (eventType === Events.IMAGE_VOLUME_MODIFIED) { - if (shouldInvalidateFullTextureOnVolumeModified) { - imageVolume.vtkOpenGLTexture?.modified?.(); - } + isSegmentationOverlay, + }; + + let deferredProjectionRenderTimer: ReturnType | null = + null; + const removeVolumeSubscriptions = subscribeToVolumeEvents( + payload.volumeId, + (eventType) => { + if (eventType === Events.IMAGE_VOLUME_MODIFIED) { + // Volume writers (streaming loader, labelmap updates) mark the + // modified slices on the shared streaming texture themselves, so + // the next render only re-uploads those slices; the mapper just + // needs to know its buffers are stale. + mapper.modified(); + } - mapper.modified(); - } + const isProjectingOverlay = + rendering.isSegmentationOverlay && + (rendering.dataPresentation?.slabThickness ?? 0) > 0; + if (!isProjectingOverlay) { ctx.display.requestRender(); + return; } - ), + + // A slab-projecting overlay (e.g. labelmap over a MIP) re-marches the + // whole slab per fragment, which is far too expensive to repeat for + // every brush-stroke event; repaint once the modification stream goes + // quiet instead of live. + if (deferredProjectionRenderTimer !== null) { + clearTimeout(deferredProjectionRenderTimer); + } + deferredProjectionRenderTimer = setTimeout(() => { + deferredProjectionRenderTimer = null; + ctx.display.requestRender(); + }, PROJECTING_OVERLAY_RENDER_DELAY_MS); + } + ); + rendering.removeStreamingSubscriptions = () => { + if (deferredProjectionRenderTimer !== null) { + clearTimeout(deferredProjectionRenderTimer); + deferredProjectionRenderTimer = null; + } + removeVolumeSubscriptions(); }; imageVolume.load(() => { ctx.display.requestRender(); @@ -162,9 +195,16 @@ export class VtkVolumeSliceRenderPath props: unknown ): void { rendering.dataPresentation = props as PlanarDataPresentation | undefined; + // Segmentation overlays get their color/opacity transfer functions from + // the segmentation styling (setLabelmapColorAndOpacity); rebuilding them + // here from a VOI range would overwrite the label colors with a grayscale + // ramp. Dropping the default VOI keeps the presentation application to + // blend/slab/visibility for those actors. applyPlanarVolumePresentation({ actor: rendering.actor, - defaultVOIRange: rendering.defaultVOIRange, + defaultVOIRange: rendering.isSegmentationOverlay + ? undefined + : rendering.defaultVOIRange, mapper: rendering.mapper, props: rendering.dataPresentation, }); diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts index 91101a2725..5439742fbe 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarRuntimeTypes.ts @@ -149,6 +149,7 @@ export type PlanarVolumeSliceRendering = MountedRendering<{ maxImageIdIndex: number; defaultVOIRange?: VOIRange; dataPresentation?: PlanarDataPresentation; + isSegmentationOverlay?: boolean; removeStreamingSubscriptions?: () => void; }>; diff --git a/packages/tools/examples/labelmapMIPGeneric/index.ts b/packages/tools/examples/labelmapMIPGeneric/index.ts new file mode 100644 index 0000000000..f5f9c2d81b --- /dev/null +++ b/packages/tools/examples/labelmapMIPGeneric/index.ts @@ -0,0 +1,291 @@ +import type { PlanarViewport, Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + utilities, + volumeLoader, +} from '@cornerstonejs/core'; +import * as cornerstone from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addButtonToToolbar, +} from '../../../../utils/demo/helpers'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { fillVolumeLabelmapWithMockData } from '../../../../utils/test/testUtils'; +import { SegmentationRepresentations } from '../../src/enums'; +import { triggerSegmentationDataModified } from '../../src/stateManagement/segmentation/triggerSegmentationEvents'; +import { BrushTool, SegmentSelectTool } from '../../src/tools'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + VolumeRotateTool, + StackScrollTool, +} = cornerstoneTools; + +const { ViewportType, BlendModes } = Enums; +const { MouseBindings } = csToolsEnums; + +// Define a unique id for the volume +const volumeName = 'CT_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'; +const sourceDataId = 'labelmap-mip-generic:source'; +// Create the viewports +const viewportId1 = 'CT_LEFT'; +const viewportId2 = 'CT_MIP'; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Labelmap Rendering over MIP data (Generic Planar Viewport)', + 'Here we demonstrate rendering of a mock ellipsoid labelmap over MIP data using the new generic planar viewport API' +); + +const size = '512px'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; + +const element1 = document.createElement('div'); +const element2 = document.createElement('div'); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); + +content.appendChild(viewportGrid); + +addButtonToToolbar({ + title: 'Add Labelmap Representation', + onClick: () => { + segmentation.addLabelmapRepresentationToViewport(viewportId2, [ + { + segmentationId, + config: { + blendMode: BlendModes.LABELMAP_EDGE_PROJECTION_BLEND, + }, + }, + ]); + }, +}); + +addButtonToToolbar({ + title: 'Delete Labelmap Representation', + onClick: () => { + const representations = + segmentation.state.getSegmentationRepresentations(viewportId2); + + if (representations && representations.length > 0) { + segmentation.removeSegmentationRepresentation(viewportId2, { + segmentationId, + type: SegmentationRepresentations.Labelmap, + }); + } + }, +}); + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + cornerstoneTools.addTool(VolumeRotateTool); + cornerstoneTools.addTool(StackScrollTool); + cornerstoneTools.addTool(SegmentSelectTool); + cornerstoneTools.addTool(BrushTool); + + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + const toolGroup2 = ToolGroupManager.createToolGroup('mipToolGroup'); + + toolGroup2.addTool(VolumeRotateTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + + [toolGroup, toolGroup2].forEach((toolGroup) => { + toolGroup?.addTool(SegmentSelectTool.toolName); + toolGroup?.addToolInstance('SphereBrush', BrushTool.toolName, { + activeStrategy: 'FILL_INSIDE_SPHERE', + }); + }); + + // 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', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.879445243400782656317561081015', + wadoRsRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + // Instantiate a rendering engine + const renderingEngineId = 'myRenderingEngine'; + const renderingEngine = new RenderingEngine(renderingEngineId); + + const viewportInputArray = [ + { + viewportId: viewportId1, + type: ViewportType.PLANAR_NEXT, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0.2, 0, 0.2], + }, + }, + { + viewportId: viewportId2, + type: ViewportType.PLANAR_NEXT, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0.2, 0, 0.2], + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup2.addViewport(viewportId2, renderingEngineId); + + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + toolGroup.setToolActive('SphereBrush', { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + toolGroup.setToolActive(SegmentSelectTool.toolName); + + toolGroup2.setToolActive(VolumeRotateTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + + toolGroup2.setToolActive(SegmentSelectTool.toolName); + + // Set the volume to load + volume.load(); + + // Register the source display set for the generic viewports + utilities.genericViewportDisplaySetMetadataProvider.add(sourceDataId, { + kind: 'planar', + imageIds, + initialImageIdIndex: Math.floor(imageIds.length / 2), + volumeId, + }); + + const viewport1 = renderingEngine.getViewport(viewportId1) as PlanarViewport; + const viewport2 = renderingEngine.getViewport(viewportId2) as PlanarViewport; + + await viewport1.setDisplaySets({ + displaySetId: sourceDataId, + options: { + orientation: Enums.OrientationAxis.CORONAL, + }, + }); + + await viewport2.setDisplaySets({ + displaySetId: sourceDataId, + options: { + orientation: Enums.OrientationAxis.SAGITTAL, + }, + }); + + // Render the MIP viewport with a full-volume slab and the PET transfer + // function (VOI 0-5, inverted grayscale) + viewport2.setDisplaySetPresentation(sourceDataId, { + blendMode: BlendModes.MAXIMUM_INTENSITY_BLEND, + slabThickness: 50000, + voiRange: { lower: 0, upper: 5 }, + invert: true, + }); + + // Render the image + renderingEngine.renderViewports([viewportId1, viewportId2]); + + // Add some segmentations based on the source data volume + // ============================= // + + // Create a segmentation of the same resolution as the source data + volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: segmentationId, + }); + + // Add some data to the segmentations + fillVolumeLabelmapWithMockData({ + volumeId: segmentationId, + cornerstone, + innerRadius: 20, + outerRadius: 30, + scale: [1, 2, 1], + }); + + // The reslice mapper draws outlines in data-texel units (not screen pixels + // like the legacy volume path), so keep the width at 1 to avoid fat outlines + // when zoomed in + segmentation.config.style.setStyle( + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + viewportId: viewportId1, + }, + { + fillAlpha: 0.0, + outlineWidth: 1, + activeSegmentOutlineWidthDelta: 0, + } + ); + + // Edge-projection look: the labelmap projects across the same slab as the + // MIP and only the per-label edges of the projected footprint are drawn + segmentation.config.style.setStyle( + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + viewportId: viewportId2, + }, + { + fillAlpha: 0.0, + outlineWidth: 1, + activeSegmentOutlineWidthDelta: 0, + } + ); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: SegmentationRepresentations.Labelmap, + data: { + volumeId: segmentationId, + }, + }, + }, + ]); + + await segmentation.addLabelmapRepresentationToViewport(viewportId1, [ + { segmentationId }, + ]); + + triggerSegmentationDataModified(segmentationId); +} + +run(); diff --git a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts index 084134e70f..6b27f0cd11 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/labelmapRenderPlan/planarGenericVolumeLabelmap.ts @@ -33,10 +33,18 @@ type PlanarNextVolumeViewport = Types.IViewport & { role?: 'source' | 'overlay'; } ) => Promise; + getSourceDataId?: () => string | undefined; + getDisplaySetPresentation?: (dataId: string) => + | { + blendMode?: Enums.BlendModes; + slabThickness?: number; + } + | undefined; setDisplaySetPresentation: ( dataId: string, props: { blendMode?: Enums.BlendModes; + slabThickness?: number; visible?: boolean; } ) => void; @@ -84,6 +92,13 @@ async function addLabelmapToPlanarGenericViewport(args: { ? viewport.getViewReference({ volumeId: sourceVolumeId }) : viewport.getViewReference(); const requestedOrientation = viewport.getViewState().orientation; + // A slab-projected source (e.g. MIP) shows structures from the whole slab, so + // the labelmap must project across the same slab or its labels will only + // reflect the current slice and misalign with what the user sees. + const sourceDataId = viewport.getSourceDataId?.(); + const sourceSlabThickness = sourceDataId + ? viewport.getDisplaySetPresentation?.(sourceDataId)?.slabThickness + : undefined; const currentImageIdIndex = Math.max( 0, viewport.getCurrentImageIdIndex?.() ?? 0 @@ -132,6 +147,9 @@ async function addLabelmapToPlanarGenericViewport(args: { viewport.setDisplaySetPresentation(dataId, { blendMode, visible: visibility, + ...(sourceSlabThickness !== undefined + ? { slabThickness: sourceSlabThickness } + : {}), }); firstActorEntry ||= viewport