fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up (#5967)
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user