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
@@ -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';

Expand All @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>
>();

/**
* 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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
convertMapperToNotSharedMapper,
volumeLoader,
eventTarget,
createVolumeActor,
type Types,
} from '@cornerstonejs/core';
import { Events, SegmentationRepresentations } from '../../../enums';
Expand Down Expand Up @@ -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);

Expand All @@ -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, {
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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
);
Expand All @@ -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,
Expand Down
Loading