fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up (#5967)

This commit is contained in:
Ghadeer Albattarni authored and GitHub committed 2026-04-27 12:55:12 -04:00
1 parent c9c4f11b82
commit f8ccf9ff2e
23 files changed
+558 -39

No files matched your search

@@ -2096,6 +2096,15 @@ function commandsModule({
displaySetInstanceUIDs,
});
if (!updatedViewports?.length) {
return;
}
updatedViewports.forEach(({ viewportId: csViewportId }) => {
const csViewport = cornerstoneViewportService.getCornerstoneViewport(csViewportId);
csViewport?.setNeedsRender?.();
});
actions.setDisplaySetsForViewports({
viewportsToUpdate: updatedViewports.map(viewport => ({
viewportId: viewport.viewportId,
@@ -49,6 +49,47 @@ const EVENTS = {
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
const MIN_VOLUME_VIEWPORTS_TO_ENQUEUE_RESIZE = 6;
function getVolumeActorReferencedIds(viewport: Types.IVolumeViewport): string[] {
const actors = viewport.getActors?.() ?? [];
return actors
.filter(ac => ac.actor?.getClassName?.() === 'vtkVolume')
.map(ac => ac.referencedId)
.filter(Boolean) as string[];
}
function volumeIdPrefixesMatch(
existingIds: string[],
prefixLen: number,
targetIds: string[]
): boolean {
if (prefixLen > targetIds.length) {
return false;
}
for (let i = 0; i < prefixLen; i++) {
if (existingIds[i] !== targetIds[i]) {
return false;
}
}
return true;
}
/**
* Returns true when the viewport type matches a volume-based presentation (ORTHOGRAPHIC or VOLUME_3D).
*/
function viewportMatchesDesiredVolumePresentation(
viewport: Types.IViewport,
desiredViewportInfo: ViewportInfo
): boolean {
const desiredType = desiredViewportInfo.getViewportType();
if (viewport.type !== desiredType) {
return false;
}
return (
desiredType === csEnums.ViewportType.ORTHOGRAPHIC ||
desiredType === csEnums.ViewportType.VOLUME_3D
);
}
export const WITH_NAVIGATION = { withNavigation: true, withOrientation: false };
export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
@@ -1112,7 +1153,44 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
});
}
await viewport.setVolumes(volumeInputArray);
const baseVolumeInputs = filteredVolumeInputArray.map(({ volumeInput }) => volumeInput);
const nextBaseVolumeIds = baseVolumeInputs.map(v => v.volumeId);
const existingVolumeIds = getVolumeActorReferencedIds(viewport);
let skippedIdenticalBaseVolumes = false;
if (baseVolumeInputs.length) {
const singleBaseViewport = nextBaseVolumeIds.length === 1;
// Only skip setVolumes() when the viewport type already matches the desired OHIF type
// (ORTHOGRAPHIC / VOLUME_3D); otherwise a stack → MPR switch with the same volumeId
// would incorrectly skip rebuilding the viewport.
if (
singleBaseViewport &&
existingVolumeIds.length >= nextBaseVolumeIds.length &&
volumeIdPrefixesMatch(existingVolumeIds, nextBaseVolumeIds.length, nextBaseVolumeIds) &&
viewportMatchesDesiredVolumePresentation(viewport, viewportInfo)
) {
// Same primary volume already loaded (e.g. labelmap / extra actors after it) — avoid
// setVolumes(), which tears down all actors and blanks MPR during SEG hydrate.
skippedIdenticalBaseVolumes = true;
} else if (
existingVolumeIds.length &&
nextBaseVolumeIds.length > existingVolumeIds.length &&
volumeIdPrefixesMatch(existingVolumeIds, existingVolumeIds.length, nextBaseVolumeIds) &&
typeof viewport.addVolumes === 'function'
) {
const toAdd = baseVolumeInputs.slice(existingVolumeIds.length);
if (toAdd.length) {
await viewport.addVolumes(toAdd);
}
} else {
await viewport.setVolumes(baseVolumeInputs);
}
} else if (volumeInputArray.length) {
await viewport.setVolumes(volumeInputArray);
}
await this._addOverlayRepresentations(overlayProcessingResults);
viewport.render();
@@ -1124,8 +1202,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
});
this.setPresentations(viewport.id, presentations);
// Presentations apply segmentation (hydrated labelmap etc.) after the render above — redraw so
// every orthographic/3D tile shows the updated scene (fixes MPR siblings blank after SEG hydrate).
viewport.render();
if (!presentations.positionPresentation) {
if (!presentations.positionPresentation && !skippedIdenticalBaseVolumes) {
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
if (imageIndex !== undefined) {
@@ -135,16 +135,27 @@ const _getSegmentationPresentationId = ({
return;
}
const { displaySetService } = servicesManager.services;
const { displaySetInstanceUIDs, viewportOptions } = viewport;
// Match keys used by updateStoredSegmentationPresentation (referenced volume only).
// Including overlay UIDs (e.g. SEG) produced ids like "MR&SEG" while the store
// entry is under "MR", so hydrated segmentations never applied and viewports could mis-render.
const nonOverlayUIDs = displaySetInstanceUIDs.filter(uid => {
const ds = displaySetService.getDisplaySetByUID(uid);
return ds && !ds.isOverlayDisplaySet;
});
if (!nonOverlayUIDs.length) {
return;
}
let orientation = viewportOptions.orientation;
if (!orientation) {
// Calculate orientation from the viewport sample image
const displaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
displaySetInstanceUIDs[0]
);
const sampleImage = displaySet.images?.[0];
const displaySet = displaySetService.getDisplaySetByUID(nonOverlayUIDs[0]);
const sampleImage = displaySet?.images?.[0];
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
@@ -152,7 +163,7 @@ const _getSegmentationPresentationId = ({
const segmentationPresentationArr = [];
segmentationPresentationArr.push(...displaySetInstanceUIDs);
segmentationPresentationArr.push(...nonOverlayUIDs);
// Uncomment if unique indexing is needed
// addUniqueIndex(
@@ -262,6 +262,57 @@ describe('getUpdatedViewportsForSegmentation', () => {
expect(result).toEqual(mockUpdatedViewports);
});
it('should merge all grid viewports that reference the volume after hydration (e.g. 3D four-up)', () => {
const volumeUid = 'volume-1';
const viewports = new Map([
[
'vp-axial',
{
viewportId: 'vp-axial',
viewportOptions: { viewportId: 'vp-axial' },
displaySetInstanceUIDs: [volumeUid],
},
],
[
'vp-sagittal',
{
viewportId: 'vp-sagittal',
viewportOptions: { viewportId: 'vp-sagittal' },
displaySetInstanceUIDs: [volumeUid],
},
],
[
'vp-other-study',
{
viewportId: 'vp-other-study',
viewportOptions: { viewportId: 'vp-other-study' },
displaySetInstanceUIDs: ['other-volume'],
},
],
]);
mockViewportGridService.getState.mockReturnValue({
isHangingProtocolLayout: true,
viewports,
activeViewportId: 'vp-axial',
});
mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([
{ viewportId: 'vp-axial', displaySetInstanceUIDs: [volumeUid] },
]);
const result = getUpdatedViewportsForSegmentation({
viewportId: 'vp-axial',
servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager,
displaySetInstanceUIDs: [volumeUid],
});
expect(result).toEqual([
{ viewportId: 'vp-axial', displaySetInstanceUIDs: [volumeUid] },
{ viewportId: 'vp-sagittal', displaySetInstanceUIDs: [volumeUid] },
]);
});
it('should handle complex viewport structure', () => {
const complexViewport = {
viewportOptions: {
@@ -1,3 +1,63 @@
import type { Types } from '@ohif/core';
/**
* After SEG hydration we must refresh every viewport that shows the referenced volume so
* presentations (including segmentation) apply to all MPR/3D tiles. Hanging-protocol matching
* can return only the active viewport when protocol definitions omit viewportId (e.g. 3D four-up)
* or when layout state diverges from the protocol; this merges in all grid panes that already
* list that volume in `displaySetInstanceUIDs`.
*
* Only exact displaySetInstanceUID matches are used (no Frame-of-Reference inference): sibling
* MPR planes must already share the same referenced volume UID in grid state, or HP matching
* must list them; otherwise forcing a different UID onto a volume viewport can leave it blank.
*/
function mergeVolumeSharingViewports(
hangingProtocolUpdates: Types.HangingProtocol.ViewportUpdate[] | null | undefined,
volumeUid: string | undefined,
viewports: AppTypes.ViewportGrid.GridViewports
): Types.HangingProtocol.ViewportUpdate[] {
if (!volumeUid) {
return (hangingProtocolUpdates ?? []) as Types.HangingProtocol.ViewportUpdate[];
}
const byId = new Map<string, Types.HangingProtocol.ViewportUpdate>();
const add = (viewportId: string, uids: string[]) => {
if (!viewportId) {
return;
}
byId.set(viewportId, { viewportId, displaySetInstanceUIDs: uids });
};
if (Array.isArray(hangingProtocolUpdates)) {
for (const entry of hangingProtocolUpdates) {
const vid = entry.viewportId ?? entry.viewportOptions?.viewportId;
if (vid) {
add(vid, entry.displaySetInstanceUIDs?.length ? entry.displaySetInstanceUIDs : [volumeUid]);
}
}
}
viewports.forEach(vp => {
const uids = vp.displaySetInstanceUIDs || [];
if (uids.includes(volumeUid)) {
add(vp.viewportId, [volumeUid]);
}
});
const merged = Array.from(byId.values());
if (
merged.length === 0 &&
Array.isArray(hangingProtocolUpdates) &&
hangingProtocolUpdates.length > 0
) {
return hangingProtocolUpdates;
}
return merged;
}
function getUpdatedViewportsForSegmentation({
viewportId,
servicesManager,
@@ -5,7 +65,7 @@ function getUpdatedViewportsForSegmentation({
}: withAppTypes) {
const { hangingProtocolService, viewportGridService } = servicesManager.services;
const { isHangingProtocolLayout } = viewportGridService.getState();
const { isHangingProtocolLayout, viewports } = viewportGridService.getState();
const viewport = getTargetViewport({ viewportId, viewportGridService });
const targetViewportId = viewport.viewportOptions.viewportId;
@@ -16,7 +76,11 @@ function getUpdatedViewportsForSegmentation({
isHangingProtocolLayout
);
return updatedViewports;
if (updatedViewports == null) {
return updatedViewports;
}
return mergeVolumeSharingViewports(updatedViewports, displaySetInstanceUIDs[0], viewports);
}
const getTargetViewport = ({ viewportId, viewportGridService }) => {
@@ -67,8 +67,14 @@ export function configureViewportForLayerAddition(params: {
// If a viewport type was already set do not reset it.
if (!viewport.viewportOptions.viewportType) {
// Special handling for overlay display sets
if (requestedLayerDisplaySet.isOverlayDisplaySet) {
// If the live Cornerstone viewport is already orthographic (volume), keep it as
// 'volume'. Prevents accidentally downgrading a volume viewport to 'stack' when
// viewportOptions arrives without a viewportType (e.g. from the HP overlay path).
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (csViewport?.type === 'orthographic') {
viewport.viewportOptions.viewportType = 'volume';
// Special handling for overlay display sets
} else if (requestedLayerDisplaySet.isOverlayDisplaySet) {
// Do not force volume for SEG and RTSTRUCT if it and all the current display sets are for the same display set
const isSameDisplaySet = currentDisplaySetUIDs.every(uid => {
const currentDisplaySet = displaySetService.getDisplaySetByUID(uid);
@@ -131,7 +137,9 @@ export function configureViewportForLayerRemoval(params: {
viewport.viewportOptions = {};
}
viewport.viewportOptions.viewportType = 'volume';
const csViewportOrthographic =
cornerstoneViewportService.getCornerstoneViewport(viewportId)?.type === 'orthographic';
viewport.viewportOptions.viewportType = csViewportOrthographic ? 'volume' : 'stack';
// orientation
if (!viewport.viewportOptions.orientation) {