From 36279f736cf3bd2fa58584a820538677379dc735 Mon Sep 17 00:00:00 2001 From: Alireza Date: Sat, 4 Jul 2026 22:11:41 -0400 Subject: [PATCH 1/3] feat(segmentation): labelmap edge projection over MIP for generic viewports Adds the labelmapMIPGeneric example and makes LABELMAP_EDGE_PROJECTION_BLEND work on generic planar viewports: the labelmap mounts as an overlay display set that inherits the source display set's slab thickness, and segmentation overlays keep their labelmap transfer functions instead of having a VOI grayscale ramp reapplied over them. Editing performance: segmentation-data modifications no longer re-render projection-heavy viewports live. triggerSegmentationRenderForModified renders cheap viewports immediately and defers viewports that re-march a slab per fragment (legacy edge-projection blend or generic slab presentations) until the modification stream goes quiet (240ms trailing). The generic volume-slice render path applies the same deferral to volume-modified repaints of slab overlays and drops its full-texture invalidation so brush edits upload only the modified slices. Legacy independent-components path fixes: the combined actor is re-added under the render plan's representationUID (the plan tore it down otherwise), restore removes the injected CPU scalars and label-outline property state, the data-modified merge now touches only modifiedSlicesToUse with direct per-slice copies instead of a full-volume per-voxel loop, and the debounced listener is removed with removeEventListenerDebounced (the plain removal leaked the wrapper, leaving zombie merges after delete/re-add cycles). The generic slab label outline requires vtk.js with the slab-projected label outline (Kitware/vtk-js#3541). --- .../Planar/VtkVolumeSliceRenderPath.ts | 64 +++- .../Planar/planarRuntimeTypes.ts | 1 + .../examples/labelmapMIPGeneric/index.ts | 291 ++++++++++++++++++ .../segmentationDataModifiedEventListener.ts | 7 +- .../SegmentationRenderingEngine.ts | 99 ++++++ .../addVolumesAsIndependentComponents.ts | 79 ++++- .../planarGenericVolumeLabelmap.ts | 18 ++ 7 files changed, 534 insertions(+), 25 deletions(-) create mode 100644 packages/tools/examples/labelmapMIPGeneric/index.ts 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/eventListeners/segmentation/segmentationDataModifiedEventListener.ts b/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts index e0c6ed20e3..d00a5bf732 100644 --- a/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts +++ b/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts @@ -1,5 +1,5 @@ import type { SegmentationDataModifiedEventType } from '../../types/EventTypes'; -import { triggerSegmentationRenderBySegmentationId } from '../../stateManagement/segmentation/SegmentationRenderingEngine'; +import { triggerSegmentationRenderForModified } from '../../stateManagement/segmentation/SegmentationRenderingEngine'; import onLabelmapSegmentationDataModified from './labelmap/onLabelmapSegmentationDataModified'; import { getSegmentation } from '../../stateManagement/segmentation/getSegmentation'; @@ -16,7 +16,10 @@ const onSegmentationDataModified = function ( onLabelmapSegmentationDataModified(evt); } - triggerSegmentationRenderBySegmentationId(segmentationId); + // Data modifications stream in at brush-stroke frequency; this trigger + // renders cheap viewports live and defers projection-heavy ones (labelmap + // over MIP) until the stream goes quiet. + triggerSegmentationRenderForModified(segmentationId); }; export default onSegmentationDataModified; diff --git a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts index f551515e5b..7cf5cfb6f6 100644 --- a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts +++ b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts @@ -299,9 +299,108 @@ function triggerSegmentationRenderBySegmentationId( segmentationRenderingEngine.renderSegmentation(segmentationId); } +// Trailing delay before re-rendering a projection-heavy viewport after a +// segmentation-data modification (brush edits fire many events per second). +const DEFERRED_SEGMENTATION_RENDER_DELAY_MS = 240; +const deferredSegmentationRenderTimers = new Map< + string, + ReturnType +>(); + +/** + * Whether re-rendering this viewport's segmentations means re-marching a slab + * per fragment (labelmap over MIP style projections), which is too expensive + * to repeat for every segmentation-data-modified event. + */ +function isProjectionHeavySegmentationViewport( + viewport: Types.IViewport +): boolean { + // Legacy volume viewports render labelmap-over-MIP by switching the whole + // viewport to the edge-projection blend mode. + const blendMode = ( + viewport as Partial + ).getBlendMode?.(); + + if (blendMode === Enums.BlendModes.LABELMAP_EDGE_PROJECTION_BLEND) { + return true; + } + + // Generic planar viewports are projection-heavy when any of their display + // sets renders across a slab (e.g. a MIP source with a projected labelmap + // overlay, which inherits the source's slab thickness). + const planarViewport = viewport as Partial<{ + getActors: () => Types.ActorEntry[]; + getSourceDataId: () => string | undefined; + getDisplaySetPresentation: ( + dataId: string + ) => { slabThickness?: number } | undefined; + }>; + + if (typeof planarViewport.getDisplaySetPresentation !== 'function') { + return false; + } + + const dataIds: string[] = []; + const sourceDataId = planarViewport.getSourceDataId?.(); + + if (sourceDataId) { + dataIds.push(sourceDataId); + } + + planarViewport.getActors?.().forEach((actorEntry) => { + if (actorEntry.representationUID) { + dataIds.push(String(actorEntry.representationUID)); + } + }); + + return dataIds.some( + (dataId) => + (planarViewport.getDisplaySetPresentation(dataId)?.slabThickness ?? 0) > 0 + ); +} + +/** + * Segmentation render trigger for high-frequency data modifications (brush + * strokes and the like): viewports that are cheap to repaint render live, + * while projection-heavy viewports (labelmap over MIP) are re-rendered once + * the modification stream goes quiet. + */ +function triggerSegmentationRenderForModified(segmentationId?: string): void { + const viewportIds = + segmentationRenderingEngine._getViewportIdsForSegmentation(segmentationId); + + viewportIds.forEach((viewportId) => { + const { viewport } = getEnabledElementByViewportId(viewportId) || {}; + + if (!viewport) { + return; + } + + if (!isProjectionHeavySegmentationViewport(viewport)) { + segmentationRenderingEngine.renderSegmentationsForViewport(viewportId); + return; + } + + const existingTimer = deferredSegmentationRenderTimers.get(viewportId); + + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + } + + deferredSegmentationRenderTimers.set( + viewportId, + setTimeout(() => { + deferredSegmentationRenderTimers.delete(viewportId); + segmentationRenderingEngine.renderSegmentationsForViewport(viewportId); + }, DEFERRED_SEGMENTATION_RENDER_DELAY_MS) + ); + }); +} + const segmentationRenderingEngine = new SegmentationRenderingEngine(); export { triggerSegmentationRender, triggerSegmentationRenderBySegmentationId, + triggerSegmentationRenderForModified, segmentationRenderingEngine, }; diff --git a/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts b/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts index 611b2e1ee7..a8e7dc1843 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts @@ -4,7 +4,6 @@ import { convertMapperToNotSharedMapper, volumeLoader, eventTarget, - createVolumeActor, type Types, } from '@cornerstonejs/core'; import { Events, SegmentationRepresentations } from '../../../enums'; @@ -113,6 +112,14 @@ export async function addVolumesAsIndependentComponents({ viewport.removeActors([uid]); const oldMapper = actor.getMapper(); + // convertMapperToNotSharedMapper reuses the old mapper's vtkImageData and + // replaces its point-data scalars with a CPU array, which this function then + // rewrites as a 2-component (base + seg) array. The shared streaming mapper + // renders from the GPU texture and misconfigures its shader if that injected + // array is still active, so capture the original scalars (usually none) to + // undo the mutation on restore. + const sharedImageData = (oldMapper as vtkVolumeMapper).getInputData(); + const originalScalars = sharedImageData.getPointData().getScalars() ?? null; const mapper = convertMapperToNotSharedMapper(oldMapper as vtkVolumeMapper); actor.setMapper(mapper); @@ -137,9 +144,29 @@ export async function addVolumesAsIndependentComponents({ .getIndependentComponents(); actor.getProperty().setIndependentComponents(true); + // While the segmentation is mounted, setLabelmapColorAndOpacity treats this + // combined actor as the labelmap actor and enables the label-outline shader + // path on its property. Capture the pre-mount outline state so the restore + // can undo it - otherwise the plain base volume keeps rendering through the + // label-outline shader after the representation is removed. + const oldUseLabelOutline = actor.getProperty().getUseLabelOutline(); + // @ts-ignore - fix type in vtk + const oldLabelOutlineOpacity = actor.getProperty().getLabelOutlineOpacity(); + const oldLabelOutlineThickness = actor + .getProperty() + .getLabelOutlineThickness(); + + // Reuse the representationUID computed by the render plan (which includes + // the labelmapId suffix). The plan's reconcile step compares actor UIDs + // against that format; a mismatch makes it tear down this combined actor — + // which is also the viewport's base volume actor — on the next render. + const representationUID = + (volumeInputArray[0].representationUID as string) ?? + `${segmentationId}-${SegmentationRepresentations.Labelmap}`; + viewport.addActor({ ...defaultActor, - representationUID: `${segmentationId}-${SegmentationRepresentations.Labelmap}`, + representationUID, }); internalCache.set(uid, { @@ -155,7 +182,7 @@ export async function addVolumesAsIndependentComponents({ function onSegmentationDataModified(evt) { // update the second component of the array with the new segmentation data - const { segmentationId } = evt.detail; + const { segmentationId, modifiedSlicesToUse } = evt.detail; const { representationData } = getSegmentation(segmentationId); const { volumeId: segVolumeId } = representationData.Labelmap as LabelmapSegmentationDataVolume; @@ -169,24 +196,37 @@ export async function addVolumesAsIndependentComponents({ const imageData = mapper.getInputData(); const array = imageData.getPointData().getArray(0); - const baseData = array.getData(); + const combinedData = array.getData(); const newComp = 2; const dims = segImageData.getDimensions(); + const sliceSize = dims[0] * dims[1]; - const slices = Array.from({ length: dims[2] }, (_, i) => i); + // Brush strokes report which slices they touched; merging only those + // keeps the CPU cost proportional to the edit instead of the volume. + const slices: number[] = modifiedSlicesToUse?.length + ? modifiedSlicesToUse + : Array.from({ length: dims[2] }, (_, i) => i); for (const z of slices) { - for (let y = 0; y < dims[1]; ++y) { - for (let x = 0; x < dims[0]; ++x) { - const iTuple = x + dims[0] * (y + dims[1] * z); - baseData[iTuple * newComp + 1] = segVoxelManager.getAtIndex( + const sliceStart = z * sliceSize; + const sliceImage = cache.getImage(segmentationVolume.imageIds?.[z]); + const sliceData = sliceImage?.voxelManager?.getScalarData?.(); + + if (sliceData?.length === sliceSize) { + for (let i = 0; i < sliceSize; ++i) { + combinedData[(sliceStart + i) * newComp + 1] = sliceData[i]; + } + } else { + for (let i = 0; i < sliceSize; ++i) { + const iTuple = sliceStart + i; + combinedData[iTuple * newComp + 1] = segVoxelManager.getAtIndex( iTuple ) as number; } } } - array.setData(baseData); + array.setData(combinedData); imageData.modified(); viewport.render(); @@ -203,7 +243,10 @@ export async function addVolumesAsIndependentComponents({ return; } - eventTarget.removeEventListener( + // The data-modified handler was registered debounced; the plain + // removeEventListener would leave the debounce wrapper attached forever, + // still merging into this detached imageData on every edit. + eventTarget.removeEventListenerDebounced( Events.SEGMENTATION_DATA_MODIFIED, onSegmentationDataModified ); @@ -226,11 +269,25 @@ export async function addVolumesAsIndependentComponents({ // Restore the original actor and add it back to the viewport. actor.setMapper(oldMapper); + + const pointData = sharedImageData.getPointData(); + + if (originalScalars) { + pointData.setScalars(originalScalars); + } else { + pointData.removeArray('Pixels'); + } + sharedImageData.modified(); + actor.getProperty().setColorMixPreset(oldColorMixPreset); actor .getProperty() .setForceNearestInterpolation(1, oldForceNearestInterpolation); actor.getProperty().setIndependentComponents(oldIndependentComponents); + actor.getProperty().setUseLabelOutline(oldUseLabelOutline); + // @ts-ignore - fix type in vtk + actor.getProperty().setLabelOutlineOpacity(oldLabelOutlineOpacity); + actor.getProperty().setLabelOutlineThickness(oldLabelOutlineThickness); viewport.addActor({ ...defaultActor, 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 From b9d63656cac7653eced2820d45ac3e66fe9ea992 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 8 Jul 2026 23:18:11 -0400 Subject: [PATCH 2/3] fix(tools): cast viewport to minimal shape for getBlendMode probe The merge with main made Viewport.flip protected, so casting IViewport to Partial now fails TS2352 (protected-vs-public flip overlap). Use the same minimal structural cast already used for the planar-method probe below it. Claude-Session: https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp --- .../stateManagement/segmentation/SegmentationRenderingEngine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts index 7cf5cfb6f6..1f589635d9 100644 --- a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts +++ b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts @@ -318,7 +318,7 @@ function isProjectionHeavySegmentationViewport( // Legacy volume viewports render labelmap-over-MIP by switching the whole // viewport to the edge-projection blend mode. const blendMode = ( - viewport as Partial + viewport as Partial<{ getBlendMode: () => Enums.BlendModes }> ).getBlendMode?.(); if (blendMode === Enums.BlendModes.LABELMAP_EDGE_PROJECTION_BLEND) { From 2811ec121ed1bbac079770c614f3790def38cac3 Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 10 Jul 2026 15:57:06 -0400 Subject: [PATCH 3/3] chore(segmentation): split data-modified render perf changes into #2797 --- .../segmentationDataModifiedEventListener.ts | 7 +- .../SegmentationRenderingEngine.ts | 99 ------------------- .../addVolumesAsIndependentComponents.ts | 79 +++------------ 3 files changed, 13 insertions(+), 172 deletions(-) diff --git a/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts b/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts index d00a5bf732..e0c6ed20e3 100644 --- a/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts +++ b/packages/tools/src/eventListeners/segmentation/segmentationDataModifiedEventListener.ts @@ -1,5 +1,5 @@ import type { SegmentationDataModifiedEventType } from '../../types/EventTypes'; -import { triggerSegmentationRenderForModified } from '../../stateManagement/segmentation/SegmentationRenderingEngine'; +import { triggerSegmentationRenderBySegmentationId } from '../../stateManagement/segmentation/SegmentationRenderingEngine'; import onLabelmapSegmentationDataModified from './labelmap/onLabelmapSegmentationDataModified'; import { getSegmentation } from '../../stateManagement/segmentation/getSegmentation'; @@ -16,10 +16,7 @@ const onSegmentationDataModified = function ( onLabelmapSegmentationDataModified(evt); } - // Data modifications stream in at brush-stroke frequency; this trigger - // renders cheap viewports live and defers projection-heavy ones (labelmap - // over MIP) until the stream goes quiet. - triggerSegmentationRenderForModified(segmentationId); + triggerSegmentationRenderBySegmentationId(segmentationId); }; export default onSegmentationDataModified; diff --git a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts index 1f589635d9..f551515e5b 100644 --- a/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts +++ b/packages/tools/src/stateManagement/segmentation/SegmentationRenderingEngine.ts @@ -299,108 +299,9 @@ function triggerSegmentationRenderBySegmentationId( segmentationRenderingEngine.renderSegmentation(segmentationId); } -// Trailing delay before re-rendering a projection-heavy viewport after a -// segmentation-data modification (brush edits fire many events per second). -const DEFERRED_SEGMENTATION_RENDER_DELAY_MS = 240; -const deferredSegmentationRenderTimers = new Map< - string, - ReturnType ->(); - -/** - * Whether re-rendering this viewport's segmentations means re-marching a slab - * per fragment (labelmap over MIP style projections), which is too expensive - * to repeat for every segmentation-data-modified event. - */ -function isProjectionHeavySegmentationViewport( - viewport: Types.IViewport -): boolean { - // Legacy volume viewports render labelmap-over-MIP by switching the whole - // viewport to the edge-projection blend mode. - const blendMode = ( - viewport as Partial<{ getBlendMode: () => Enums.BlendModes }> - ).getBlendMode?.(); - - if (blendMode === Enums.BlendModes.LABELMAP_EDGE_PROJECTION_BLEND) { - return true; - } - - // Generic planar viewports are projection-heavy when any of their display - // sets renders across a slab (e.g. a MIP source with a projected labelmap - // overlay, which inherits the source's slab thickness). - const planarViewport = viewport as Partial<{ - getActors: () => Types.ActorEntry[]; - getSourceDataId: () => string | undefined; - getDisplaySetPresentation: ( - dataId: string - ) => { slabThickness?: number } | undefined; - }>; - - if (typeof planarViewport.getDisplaySetPresentation !== 'function') { - return false; - } - - const dataIds: string[] = []; - const sourceDataId = planarViewport.getSourceDataId?.(); - - if (sourceDataId) { - dataIds.push(sourceDataId); - } - - planarViewport.getActors?.().forEach((actorEntry) => { - if (actorEntry.representationUID) { - dataIds.push(String(actorEntry.representationUID)); - } - }); - - return dataIds.some( - (dataId) => - (planarViewport.getDisplaySetPresentation(dataId)?.slabThickness ?? 0) > 0 - ); -} - -/** - * Segmentation render trigger for high-frequency data modifications (brush - * strokes and the like): viewports that are cheap to repaint render live, - * while projection-heavy viewports (labelmap over MIP) are re-rendered once - * the modification stream goes quiet. - */ -function triggerSegmentationRenderForModified(segmentationId?: string): void { - const viewportIds = - segmentationRenderingEngine._getViewportIdsForSegmentation(segmentationId); - - viewportIds.forEach((viewportId) => { - const { viewport } = getEnabledElementByViewportId(viewportId) || {}; - - if (!viewport) { - return; - } - - if (!isProjectionHeavySegmentationViewport(viewport)) { - segmentationRenderingEngine.renderSegmentationsForViewport(viewportId); - return; - } - - const existingTimer = deferredSegmentationRenderTimers.get(viewportId); - - if (existingTimer !== undefined) { - clearTimeout(existingTimer); - } - - deferredSegmentationRenderTimers.set( - viewportId, - setTimeout(() => { - deferredSegmentationRenderTimers.delete(viewportId); - segmentationRenderingEngine.renderSegmentationsForViewport(viewportId); - }, DEFERRED_SEGMENTATION_RENDER_DELAY_MS) - ); - }); -} - const segmentationRenderingEngine = new SegmentationRenderingEngine(); export { triggerSegmentationRender, triggerSegmentationRenderBySegmentationId, - triggerSegmentationRenderForModified, segmentationRenderingEngine, }; diff --git a/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts b/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts index a8e7dc1843..611b2e1ee7 100644 --- a/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts +++ b/packages/tools/src/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.ts @@ -4,6 +4,7 @@ import { convertMapperToNotSharedMapper, volumeLoader, eventTarget, + createVolumeActor, type Types, } from '@cornerstonejs/core'; import { Events, SegmentationRepresentations } from '../../../enums'; @@ -112,14 +113,6 @@ export async function addVolumesAsIndependentComponents({ viewport.removeActors([uid]); const oldMapper = actor.getMapper(); - // convertMapperToNotSharedMapper reuses the old mapper's vtkImageData and - // replaces its point-data scalars with a CPU array, which this function then - // rewrites as a 2-component (base + seg) array. The shared streaming mapper - // renders from the GPU texture and misconfigures its shader if that injected - // array is still active, so capture the original scalars (usually none) to - // undo the mutation on restore. - const sharedImageData = (oldMapper as vtkVolumeMapper).getInputData(); - const originalScalars = sharedImageData.getPointData().getScalars() ?? null; const mapper = convertMapperToNotSharedMapper(oldMapper as vtkVolumeMapper); actor.setMapper(mapper); @@ -144,29 +137,9 @@ export async function addVolumesAsIndependentComponents({ .getIndependentComponents(); actor.getProperty().setIndependentComponents(true); - // While the segmentation is mounted, setLabelmapColorAndOpacity treats this - // combined actor as the labelmap actor and enables the label-outline shader - // path on its property. Capture the pre-mount outline state so the restore - // can undo it - otherwise the plain base volume keeps rendering through the - // label-outline shader after the representation is removed. - const oldUseLabelOutline = actor.getProperty().getUseLabelOutline(); - // @ts-ignore - fix type in vtk - const oldLabelOutlineOpacity = actor.getProperty().getLabelOutlineOpacity(); - const oldLabelOutlineThickness = actor - .getProperty() - .getLabelOutlineThickness(); - - // Reuse the representationUID computed by the render plan (which includes - // the labelmapId suffix). The plan's reconcile step compares actor UIDs - // against that format; a mismatch makes it tear down this combined actor — - // which is also the viewport's base volume actor — on the next render. - const representationUID = - (volumeInputArray[0].representationUID as string) ?? - `${segmentationId}-${SegmentationRepresentations.Labelmap}`; - viewport.addActor({ ...defaultActor, - representationUID, + representationUID: `${segmentationId}-${SegmentationRepresentations.Labelmap}`, }); internalCache.set(uid, { @@ -182,7 +155,7 @@ export async function addVolumesAsIndependentComponents({ function onSegmentationDataModified(evt) { // update the second component of the array with the new segmentation data - const { segmentationId, modifiedSlicesToUse } = evt.detail; + const { segmentationId } = evt.detail; const { representationData } = getSegmentation(segmentationId); const { volumeId: segVolumeId } = representationData.Labelmap as LabelmapSegmentationDataVolume; @@ -196,37 +169,24 @@ export async function addVolumesAsIndependentComponents({ const imageData = mapper.getInputData(); const array = imageData.getPointData().getArray(0); - const combinedData = array.getData(); + const baseData = array.getData(); const newComp = 2; const dims = segImageData.getDimensions(); - const sliceSize = dims[0] * dims[1]; - // Brush strokes report which slices they touched; merging only those - // keeps the CPU cost proportional to the edit instead of the volume. - const slices: number[] = modifiedSlicesToUse?.length - ? modifiedSlicesToUse - : Array.from({ length: dims[2] }, (_, i) => i); + const slices = Array.from({ length: dims[2] }, (_, i) => i); for (const z of slices) { - const sliceStart = z * sliceSize; - const sliceImage = cache.getImage(segmentationVolume.imageIds?.[z]); - const sliceData = sliceImage?.voxelManager?.getScalarData?.(); - - if (sliceData?.length === sliceSize) { - for (let i = 0; i < sliceSize; ++i) { - combinedData[(sliceStart + i) * newComp + 1] = sliceData[i]; - } - } else { - for (let i = 0; i < sliceSize; ++i) { - const iTuple = sliceStart + i; - combinedData[iTuple * newComp + 1] = segVoxelManager.getAtIndex( + for (let y = 0; y < dims[1]; ++y) { + for (let x = 0; x < dims[0]; ++x) { + const iTuple = x + dims[0] * (y + dims[1] * z); + baseData[iTuple * newComp + 1] = segVoxelManager.getAtIndex( iTuple ) as number; } } } - array.setData(combinedData); + array.setData(baseData); imageData.modified(); viewport.render(); @@ -243,10 +203,7 @@ export async function addVolumesAsIndependentComponents({ return; } - // The data-modified handler was registered debounced; the plain - // removeEventListener would leave the debounce wrapper attached forever, - // still merging into this detached imageData on every edit. - eventTarget.removeEventListenerDebounced( + eventTarget.removeEventListener( Events.SEGMENTATION_DATA_MODIFIED, onSegmentationDataModified ); @@ -269,25 +226,11 @@ export async function addVolumesAsIndependentComponents({ // Restore the original actor and add it back to the viewport. actor.setMapper(oldMapper); - - const pointData = sharedImageData.getPointData(); - - if (originalScalars) { - pointData.setScalars(originalScalars); - } else { - pointData.removeArray('Pixels'); - } - sharedImageData.modified(); - actor.getProperty().setColorMixPreset(oldColorMixPreset); actor .getProperty() .setForceNearestInterpolation(1, oldForceNearestInterpolation); actor.getProperty().setIndependentComponents(oldIndependentComponents); - actor.getProperty().setUseLabelOutline(oldUseLabelOutline); - // @ts-ignore - fix type in vtk - actor.getProperty().setLabelOutlineOpacity(oldLabelOutlineOpacity); - actor.getProperty().setLabelOutlineThickness(oldLabelOutlineThickness); viewport.addActor({ ...defaultActor,