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..1f589635d9 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: () => 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 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,