Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,7 +55,7 @@ export class VtkVolumeSliceRenderPath
): Promise<RenderPathAttachment<PlanarDataPresentation>> {
const payload: PlanarPayload = data as unknown as LoadedData<PlanarPayload>;
const imageVolume = payload.imageVolume;
const shouldInvalidateFullTextureOnVolumeModified =
const isSegmentationOverlay =
options.role === 'overlay' && payload.reference?.kind === 'segmentation';

if (!imageVolume) {
Expand Down Expand Up @@ -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<typeof setTimeout> | 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();
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export type PlanarVolumeSliceRendering = MountedRendering<{
maxImageIdIndex: number;
defaultVOIRange?: VOIRange;
dataPresentation?: PlanarDataPresentation;
isSegmentationOverlay?: boolean;
removeStreamingSubscriptions?: () => void;
}>;

Expand Down
291 changes: 291 additions & 0 deletions packages/tools/examples/labelmapMIPGeneric/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
},
});
Comment on lines +73 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard "Add Labelmap Representation" against re-adding an existing representation.

The delete handler checks getSegmentationRepresentations(viewportId2) before removing, but the add handler unconditionally calls addLabelmapRepresentationToViewport even if a representation already exists for viewportId2/segmentationId, risking duplicate representations or errors on repeated clicks.

🔧 Proposed fix
 addButtonToToolbar({
   title: 'Add Labelmap Representation',
   onClick: () => {
+    const representations =
+      segmentation.state.getSegmentationRepresentations(viewportId2);
+    if (representations && representations.length > 0) {
+      return;
+    }
     segmentation.addLabelmapRepresentationToViewport(viewportId2, [
       {
         segmentationId,
         config: {
           blendMode: BlendModes.LABELMAP_EDGE_PROJECTION_BLEND,
         },
       },
     ]);
   },
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/examples/labelmapMIPGeneric/index.ts` around lines 73 - 100,
The "Add Labelmap Representation" handler currently always calls
addLabelmapRepresentationToViewport, which can create duplicates for the same
viewportId2 and segmentationId on repeated clicks. Update the onClick logic to
first check segmentation.state.getSegmentationRepresentations(viewportId2) for
an existing Labelmap representation matching segmentationId, and only call
addLabelmapRepresentationToViewport when none is present, similar to the guard
already used in the delete handler.


/**
* 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: <Types.Point3>[0.2, 0, 0.2],
},
},
{
viewportId: viewportId2,
type: ViewportType.PLANAR_NEXT,
element: element2,
defaultOptions: {
orientation: Enums.OrientationAxis.SAGITTAL,
background: <Types.Point3>[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();
Loading
Loading