fix(segmentation): restrict overlay segmentation menu to same frame of reference as viewport background display set (#5900)

- Add FrameOfReferenceUID to SEG and RTSTRUCT displaySet in SOP Class Handlers so the FOR is available for filtering
- Sync optimisticOverlayDisplaySets when background display set changes so the overlay menu reflects the correct state after a background switch
- Add FOR matching guard to the hydrate segmentation synchronizer to prevent the hydration synchronizer from blindly mirroring segmentations from a source viewport to a target viewport if their primary Frames of Reference do not align.
- fix segmentation overlay order reversal on viewport re-render
This commit is contained in:
Ghadeer Albattarni 2026-04-02 08:52:23 -04:00 committed by GitHub
parent a875d7de1f
commit b9029ef6f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 223 additions and 56 deletions

View File

@ -56,6 +56,7 @@ function _getDisplaySetsFromSeries(
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
frameOfReferenceUID: null,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
@ -95,6 +96,9 @@ function _getDisplaySetsFromSeries(
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
displaySet.frameOfReferenceUID =
instance.ReferencedFrameOfReferenceSequence?.[0]?.FrameOfReferenceUID;
const { displaySetService } = servicesManager.services;
const referencedDisplaySets =
displaySetService.getDisplaySetsForReferences(referencedSeriesSequence);
@ -113,6 +117,7 @@ function _getDisplaySetsFromSeries(
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
displaySet.frameOfReferenceUID = addedDisplaySet.frameOfReferenceUID;
unsubscribe();
}
}
@ -121,6 +126,7 @@ function _getDisplaySetsFromSeries(
const [referencedDisplaySet] = referencedDisplaySets;
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
displaySet.frameOfReferenceUID = referencedDisplaySet.frameOfReferenceUID;
}
displaySet.load = ({ headers, createSegmentation = true }) =>

View File

@ -30,6 +30,7 @@ function _getDisplaySetsFromSeries(
SeriesDate,
StructureSetDate,
SOPClassUID,
FrameOfReferenceUID,
wadoRoot,
wadoUri,
wadoUriRoot,
@ -49,6 +50,7 @@ function _getDisplaySetsFromSeries(
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
frameOfReferenceUID: FrameOfReferenceUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
@ -104,6 +106,7 @@ function _getDisplaySetsFromSeries(
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
displaySet.frameOfReferenceUID = addedDisplaySet.frameOfReferenceUID;
unsubscribe();
}
}
@ -111,6 +114,7 @@ function _getDisplaySetsFromSeries(
} else {
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
displaySet.frameOfReferenceUID = referencedDisplaySet.frameOfReferenceUID;
}
displaySet.load = async ({ headers }) =>

View File

@ -230,7 +230,7 @@ async function _load(
function _measurementBelongsToDisplaySet({ measurement, displaySet }) {
return (
measurement.coords[0].ReferencedFrameOfReferenceSequence === displaySet.FrameOfReferenceUID
measurement.coords[0].ReferencedFrameOfReferenceSequence === displaySet.frameOfReferenceUID
);
}

View File

@ -145,7 +145,7 @@ export default function hydrateStructuredReport(
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
const displaySetsFOR = displaySetService.getDisplaySetsBy(
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
ds => ds.frameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
);
const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
if (!ds) {
@ -335,7 +335,7 @@ function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
const { points } = toolData.annotation.data.handles;
const { displaySetService } = servicesManager.services;
const displaySetsFOR = displaySetService.getDisplaySetsBy(
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID
ds => ds.frameOfReferenceUID === FrameOfReferenceUID
);
if (!displaySetsFOR.length || !points?.length) {
return {

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Button,
Icons,
@ -43,6 +43,10 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
const [thresholdOpacityEnabled, setThresholdOpacityEnabled] = useState(false);
useEffect(() => {
setOptimisticOverlayDisplaySets(overlayDisplaySets);
}, [backgroundDisplaySet?.displaySetInstanceUID, overlayDisplaySets]);
/**
* Change the background display set
*/
@ -482,7 +486,10 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
}
}}
>
<SelectTrigger className="flex-1">
<SelectTrigger
className="flex-1"
data-cy={`overlay-background-ds-select-${viewportId}`}
>
<SelectValue>
{(
backgroundDisplaySet?.SeriesDescription ||

View File

@ -80,8 +80,8 @@ export function getEnhancedDisplaySets({ viewportId, services }) {
// Check if Frame of Reference matches
if (
displaySet.FrameOfReferenceUID &&
displaySet.FrameOfReferenceUID !== backgroundDisplaySet.FrameOfReferenceUID
displaySet.frameOfReferenceUID &&
displaySet.frameOfReferenceUID !== backgroundDisplaySet.frameOfReferenceUID
) {
return {
...displaySet,

View File

@ -82,7 +82,7 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager)
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
FrameOfReferenceUID,
frameOfReferenceUID: FrameOfReferenceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription: SeriesDescription || 'Microscopy Data',

View File

@ -66,7 +66,15 @@ const segmentationRepresentationModifiedCallback = async (
const sharedDisplaySetExists = isAnyDisplaySetCommon(sourceDisplaySetUIDs, targetDisplaySetUIDs);
if (!sharedDisplaySetExists && !viewport.getFrameOfReferenceUID()) {
const targetFrameOfReferenceUID = viewport.getFrameOfReferenceUID();
const sourceFrameOfReferenceUID =
getEnabledElementByViewportId(sourceViewportId)?.viewport?.getFrameOfReferenceUID();
if (!sharedDisplaySetExists && !targetFrameOfReferenceUID) {
return;
}
if (!sharedDisplaySetExists && targetFrameOfReferenceUID !== sourceFrameOfReferenceUID) {
return;
}

View File

@ -870,28 +870,21 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
}
return viewport.setStack(imageIds, initialImageIndexToUse).then(() => {
viewport.setProperties({ ...properties });
this.setPresentations(viewport.id, presentations, viewportInfo);
await viewport.setStack(imageIds, initialImageIndexToUse);
viewport.setProperties({ ...properties });
this.setPresentations(viewport.id, presentations, viewportInfo);
if (overlayProcessingResults?.length) {
overlayProcessingResults.forEach(overlayProcessingResult => {
if (overlayProcessingResult?.addOverlayFn) {
overlayProcessingResult.addOverlayFn();
}
});
}
await this._addOverlayRepresentations(overlayProcessingResults);
if (displayArea) {
viewport.setDisplayArea(displayArea);
}
if (rotation) {
viewport.setProperties({ rotation });
}
if (flipHorizontal) {
viewport.setCamera({ flipHorizontal: true });
}
});
if (displayArea) {
viewport.setDisplayArea(displayArea);
}
if (rotation) {
viewport.setProperties({ rotation });
}
if (flipHorizontal) {
viewport.setCamera({ flipHorizontal: true });
}
}
private _getInitialImageIndexForViewport(
@ -1120,14 +1113,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
await viewport.setVolumes(volumeInputArray);
if (overlayProcessingResults?.length) {
overlayProcessingResults.forEach(({ addOverlayFn }) => {
if (addOverlayFn) {
addOverlayFn();
}
});
}
await this._addOverlayRepresentations(overlayProcessingResults);
viewport.render();
volumesProperties.forEach(({ properties, volumeId }) => {
@ -1193,7 +1179,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
private addOverlayRepresentationForDisplaySet(
displaySet: OhifTypes.DisplaySet,
viewport: Types.IViewport
) {
): Promise<void> {
const { segmentationService } = this.servicesManager.services;
const segmentationId = displaySet.displaySetInstanceUID;
@ -1203,18 +1189,36 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
: csToolsEnums.SegmentationRepresentations.Contour;
const { predecessorImageId } = displaySet;
segmentationService.addSegmentationRepresentation(viewport.id, {
segmentationId,
predecessorImageId,
type: representationType,
config: {
blendMode:
viewport?.getBlendMode?.() === 1 ? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND : undefined,
},
});
const segmentationRepresentationPromise = segmentationService.addSegmentationRepresentation(
viewport.id,
{
segmentationId,
predecessorImageId,
type: representationType,
config: {
blendMode:
viewport?.getBlendMode?.() === 1
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
: undefined,
},
}
);
// store the segmentation presentation id in the viewport info
this.storePresentation({ viewportId: viewport.id });
return segmentationRepresentationPromise;
}
private async _addOverlayRepresentations(
overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise<void> }>
): Promise<void> {
if (!overlayProcessingResults?.length) {
return;
}
for (const overlayProcessingResult of overlayProcessingResults) {
if (overlayProcessingResult?.addOverlayFn) {
await overlayProcessingResult.addOverlayFn();
}
}
}
// Todo: keepCamera is an interim solution until we have a better solution for
@ -1272,7 +1276,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
if (viewport instanceof ECGViewport) {
return this._setEcgViewport(viewport as unknown as Types.IECGViewport, viewportData as StackViewportData);
return this._setEcgViewport(
viewport as unknown as Types.IECGViewport,
viewportData as StackViewportData
);
}
return this._setOtherViewport(

View File

@ -113,7 +113,7 @@ const makeDisplaySet = (instances, index) => {
label:
instance.SeriesDescription ||
`${i18n.t('Series')} ${instance.SeriesNumber} - ${i18n.t(instance.Modality)}`,
FrameOfReferenceUID: instance.FrameOfReferenceUID,
frameOfReferenceUID: instance.FrameOfReferenceUID,
});
const imageIds = dataSource.getImageIdsForDisplaySet(imageSet);

View File

@ -329,16 +329,16 @@ export default class MicroscopyService extends PubSubService {
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
const { FrameOfReferenceUID, othersFrameOfReferenceUID } = smDisplaySet;
const { frameOfReferenceUID, othersFrameOfReferenceUID } = smDisplaySet;
if (!studyMetadata) {
return;
}
let derivedDisplaySets = FrameOfReferenceUID
let derivedDisplaySets = frameOfReferenceUID
? displaySets.filter(
ds =>
ds.ReferencedFrameOfReferenceUID === FrameOfReferenceUID ||
ds.ReferencedFrameOfReferenceUID === frameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
othersFrameOfReferenceUID.includes(ds.ReferencedFrameOfReferenceUID)
)

View File

@ -22,7 +22,7 @@ export default function getSourceDisplaySet(allDisplaySets, microscopySRDisplayS
const referencedDisplaySet = otherDisplaySets.find(
displaySet =>
displaySet.Modality === 'SM' &&
(displaySet.FrameOfReferenceUID === ReferencedFrameOfReferenceUID ||
(displaySet.frameOfReferenceUID === ReferencedFrameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
displaySet.othersFrameOfReferenceUID.includes(ReferencedFrameOfReferenceUID))
);

View File

@ -57,6 +57,12 @@ export type DisplaySet = {
*/
referencedDisplaySetInstanceUID?: string;
/**
* The FrameOfReferenceUID shared by every frame within this display set.
* It will be undefined if the frames do not all share the same Frame of Reference.
*/
frameOfReferenceUID?: string;
SeriesDate?: string;
SeriesTime?: string;
instance?: InstanceMetadata;

View File

@ -0,0 +1,98 @@
import { test, visitStudy, expect } from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.12201.1091.126683095609223531686845324113579088978';
const mode = 'segmentation';
await visitStudy(page, studyInstanceUID, mode, 2000);
});
test('should only show segmentations matching the background Frame of Reference', async ({
page,
viewportPageObject,
}) => {
const segmentationLabelsFOR1 = ['Segmentation FOR1SEG', 'FoR 1 RTstructRTSTRUCT'];
const segmentationLabelsFOR2 = ['Segmentation FOR2SEG', 'FoR 2 RTstructRTSTRUCT'];
const dataOverlay = (await viewportPageObject.getNth(0)).overlayMenu.dataOverlay;
await dataOverlay.toggle();
await dataOverlay.openAddSegmentationDropdown();
const segmentationLabels = await dataOverlay.getDropdownOptionLabels();
expect(segmentationLabels.sort()).toEqual([...segmentationLabelsFOR1].sort());
await page.keyboard.press('Escape');
await dataOverlay.changeBackgroundDisplaySet('CT Std (FoR 2)-CT');
await dataOverlay.clickSelectSegmentation();
const segmentationLabelsAfterBgChange = await dataOverlay.getDropdownOptionLabels();
expect(segmentationLabelsAfterBgChange).not.toEqual(segmentationLabelsFOR1);
expect(segmentationLabelsAfterBgChange.sort()).toEqual([...segmentationLabelsFOR2].sort());
});
test('should only sync segmentations to viewports with same Frame of Reference', async ({
viewportPageObject,
}) => {
const segmentationFOR1Label = 'Segmentation FOR1';
const viewportA = await viewportPageObject.getNth(0);
const viewportB = await viewportPageObject.getNth(1);
const viewportC = await viewportPageObject.getNth(2);
const dataOverlayA = viewportA.overlayMenu.dataOverlay;
const dataOverlayB = viewportB.overlayMenu.dataOverlay;
const dataOverlayC = viewportC.overlayMenu.dataOverlay;
// In FOR1 viewport A: add a segmentation FOR1 overlay
await viewportA.pane.hover();
await dataOverlayA.toggle();
await dataOverlayA.addSegmentation(segmentationFOR1Label);
await expect(dataOverlayA.getOverlaySegmentationRow(segmentationFOR1Label)).toBeVisible();
await dataOverlayA.toggle();
await expect(dataOverlayA.menu).not.toBeVisible();
// In another FOR1 viewport B: segmentation FOR1 should be visible (synced)
await viewportB.pane.hover();
await dataOverlayB.toggle();
const activeSegmentationViewportB = dataOverlayB.getOverlaySegmentationRow(segmentationFOR1Label);
await expect(activeSegmentationViewportB).toBeVisible();
await expect(activeSegmentationViewportB).toHaveText(segmentationFOR1Label.toUpperCase());
await dataOverlayB.toggle();
await expect(dataOverlayB.menu).not.toBeVisible();
// In FOR2 viewport C: segmentation FOR1 should NOT be visible
await viewportC.pane.hover();
await dataOverlayC.toggle();
await expect(dataOverlayC.getOverlaySegmentationRow(segmentationFOR1Label)).not.toBeVisible();
await dataOverlayC.toggle();
await expect(dataOverlayC.menu).not.toBeVisible();
});
test('should preserve segmentation order after menu close and reopen', async ({
viewportPageObject,
}) => {
const segmentation1Label = 'Segmentation FOR1';
const segmentation2Label = 'FoR 1 RTstruct';
const dataOverlay = (await viewportPageObject.getNth(0)).overlayMenu.dataOverlay;
await dataOverlay.toggle();
await dataOverlay.addSegmentation(segmentation1Label);
await expect(dataOverlay.getOverlaySegmentationRow(segmentation1Label)).toBeVisible();
await dataOverlay.addSegmentation(segmentation2Label);
await expect(dataOverlay.getOverlaySegmentationRow(segmentation2Label)).toBeVisible();
await dataOverlay.toggle();
await expect(dataOverlay.menu).not.toBeVisible();
// Reopen menu and verify segmentation order is preserved
await dataOverlay.toggle();
await expect(dataOverlay.overlaySegmentationRows).toHaveCount(2);
await expect(dataOverlay.overlaySegmentationRows.nth(0)).toHaveText(
segmentation1Label.toUpperCase()
);
await expect(dataOverlay.overlaySegmentationRows.nth(1)).toHaveText(
segmentation2Label.toUpperCase()
);
});

View File

@ -9,13 +9,16 @@ export class DataOverlayPageObject {
this.viewportId = viewportId;
}
get menu() {
return this.page.getByTestId(`viewport-data-overlay-menu-${this.viewportId}`);
}
async toggle() {
await this.page.getByTestId(`dataOverlayMenu-${this.viewportId}-btn`).click();
}
async addSegmentation(segmentation: string) {
await this.page.getByTestId(`AddSegmentationDataOverlay-${this.viewportId}`).click();
await this.page.getByText('SELECT A SEGMENTATION').click();
await this.openAddSegmentationDropdown();
await this.page.getByTestId(segmentation).click();
}
@ -28,4 +31,32 @@ export class DataOverlayPageObject {
await this.page.getByTestId(`overlay-ds-more-button-${segmentation}`).click();
await this.page.getByTestId(`overlay-ds-remove-button-${segmentation}`).click();
}
async openAddSegmentationDropdown() {
await this.page.getByTestId(`AddSegmentationDataOverlay-${this.viewportId}`).click();
await this.clickSelectSegmentation();
}
async clickSelectSegmentation() {
await this.page.getByText('SELECT A SEGMENTATION').click();
}
async getDropdownOptionLabels(): Promise<string[]> {
const options = this.page.getByRole('option');
const labels = (await options.allTextContents()).map(text => text.trim()).filter(Boolean);
return labels;
}
getOverlaySegmentationRow(label: string) {
return this.menu.getByTestId(`overlay-ds-select-value-${label.toUpperCase()}`);
}
get overlaySegmentationRows() {
return this.menu.locator('[data-cy^="overlay-ds-select-value-"]');
}
async changeBackgroundDisplaySet(backgroundLabel: string) {
await this.page.getByTestId(`overlay-background-ds-select-${this.viewportId}`).click();
await this.page.getByTestId(backgroundLabel).click();
}
}