fix(window level): The window level value is not displayed by default on all the viewports when selecting common/custom layout and TMTV. (#5865)

* fix(window level): Set up listener for viewport availability such that the initial window level can be read and displayed.

* PR feedback.

* PR feedback.

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
Joe Boccanfuso 2026-03-13 10:12:07 -04:00 committed by GitHub
parent 54d9ea39bf
commit fe1ecfe0cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 188 additions and 153 deletions

View File

@ -67,10 +67,9 @@ function CustomizableViewportOverlay({
}) {
const { cornerstoneViewportService, customizationService, toolGroupService, displaySetService } =
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [annotationState, setAnnotationState] = useState(0);
const { isViewportBackgroundLight: isLight } = useViewportRendering(viewportId);
const { isViewportBackgroundLight: isLight, windowLevel: voi } = useViewportRendering(viewportId);
const { imageIndex } = imageSliceData;
// Historical usage defined the overlays as separate items due to lack of
@ -110,30 +109,6 @@ function CustomizableViewportOverlay({
};
}, [viewportData, viewportId, instanceNumber, cornerstoneViewportService]);
/**
* Updating the VOI when the viewport changes its voi
*/
useEffect(() => {
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
if (!range) {
return;
}
const { lower, upper } = range;
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
setVOI({ windowCenter, windowWidth });
};
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
return () => {
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
};
}, [viewportId, viewportData, voi, element]);
const annotationModified = useCallback(evt => {
if (evt.detail.annotation.metadata.toolName === UltrasoundPleuraBLineTool.toolName) {
// Update the annotation state to trigger a re-render
@ -226,6 +201,7 @@ function CustomizableViewportOverlay({
scale,
instanceNumber,
annotationState,
isLight,
]
);
@ -402,6 +378,7 @@ function OverlayItem(props) {
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
const { title } = customization;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
@ -410,6 +387,7 @@ function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
<div
className="overlay-item flex flex-row"
style={{ color: customization?.color }}
title={title}
>
<span className="mr-0.5 shrink-0 opacity-[0.70]">W:</span>
<span className="mr-2.5 shrink-0">{windowWidth.toFixed(0)}</span>
@ -443,11 +421,13 @@ function InstanceNumberOverlayItem({
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
const { title } = customization;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
title={title}
>
<span>
{instanceNumber !== undefined && instanceNumber !== null ? (

View File

@ -25,6 +25,7 @@ export default {
{
id: 'WindowLevel',
inheritsFrom: 'ohif.overlayItem.windowLevel',
title: 'Window Level',
},
{
id: 'ZoomLevel',
@ -39,6 +40,7 @@ export default {
{
id: 'InstanceNumber',
inheritsFrom: 'ohif.overlayItem.instanceNumber',
title: 'Instance Number',
},
],
};

View File

@ -10,6 +10,7 @@ import {
BaseVolumeViewport,
cache,
} from '@cornerstonejs/core';
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
import { WindowLevelPreset } from '../types/WindowLevel';
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
@ -104,11 +105,6 @@ const opacityToLinear = (opacityValue: number): number => {
return Math.pow(opacityValue, 1.0 / GAMMA);
};
const is3DViewport = ({ viewportId, cornerstoneViewportService }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
return viewport instanceof VolumeViewport3D;
};
/**
* Hook to access window level functionality for a specific viewport
*
@ -124,15 +120,17 @@ export function useViewportRendering(
const { cornerstoneViewportService, colorbarService, customizationService } =
servicesManager.services;
const [is3DVolume, setIs3DVolume] = useState(
is3DViewport({ viewportId, cornerstoneViewportService })
);
const [hasColorbar, setHasColorbar] = useState(colorbarService.hasColorbar(viewportId));
const [colorbarPosition, setColorbarPosition] = useState<ColorbarPositionType>(
options?.location ? getPosition(options.location) : 'bottom'
);
const [voiRange, setVoiRange] = useState<{ lower: number; upper: number } | undefined>();
const voiRangeRef = React.useRef<{ lower: number; upper: number } | undefined>();
// Viewport from service; kept in state so we can subscribe to VIEWPORT_DATA_CHANGED when null and re-run effects when it becomes available
const [viewport, setViewport] = useState<Types.IViewport | null>(() =>
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
);
const [is3DVolume, setIs3DVolume] = useState(viewport instanceof VolumeViewport3D);
const [opacity, setOpacityState] = useState<number | undefined>();
const [opacityLinear, setOpacityLinearState] = useState<number | undefined>();
const [threshold, setThresholdState] = useState<number | undefined>();
@ -178,6 +176,34 @@ export function useViewportRendering(
);
}, [viewportDisplaySets, presets]);
// Keep viewport in state; when not available, subscribe to VIEWPORT_DATA_CHANGED so we set it when the viewport is ready
useEffect(() => {
if (!viewportId) {
setViewport(null);
return;
}
const vp = cornerstoneViewportService.getCornerstoneViewport(viewportId);
setViewport(vp ?? null);
if (vp) {
return;
}
const { unsubscribe } = cornerstoneViewportService.subscribe(
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
({ viewportId: eventViewportId }) => {
if (eventViewportId !== viewportId) {
return;
}
const next = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (next) {
setViewport(next);
}
}
);
return () => {
unsubscribe();
};
}, [viewportId, cornerstoneViewportService]);
// Calculate pixel value range for the active display set
useEffect(() => {
if (!activeDisplaySetInstanceUID) {
@ -189,17 +215,15 @@ export function useViewportRendering(
return;
}
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!csViewport) {
if (!viewport) {
return;
}
if (!(csViewport instanceof BaseVolumeViewport)) {
if (!(viewport instanceof BaseVolumeViewport)) {
return;
}
const volumeIds = csViewport.getAllVolumeIds();
const volumeIds = viewport.getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID));
if (!volumeId) {
@ -207,7 +231,7 @@ export function useViewportRendering(
}
// only handle volume viewports for now
const imageData = csViewport.getImageData(volumeId);
const imageData = viewport.getImageData(volumeId);
if (!imageData) {
return;
@ -220,7 +244,7 @@ export function useViewportRendering(
const range = voxelManager.getRange();
setPixelValueRange({ min: range[0], max: range[1] });
}, [activeDisplaySetInstanceUID, displaySetService, cornerstoneViewportService, viewportId]);
}, [activeDisplaySetInstanceUID, displaySetService, viewport]);
// Get the presets specifically for the active display set
const activeDisplaySetPresets = useMemo(() => {
@ -240,55 +264,47 @@ export function useViewportRendering(
}, [allWindowLevelPresets, activeDisplaySetInstanceUID]);
useEffect(() => {
setIs3DVolume(is3DViewport({ viewportId, cornerstoneViewportService }));
setIs3DVolume(viewport instanceof VolumeViewport3D);
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
// Initialize the VOI range from the viewport
if (viewport && activeDisplaySetInstanceUID) {
try {
let properties;
if (viewport instanceof StackViewport) {
properties = viewport.getProperties();
if (properties.voiRange) {
setVoiRange(properties.voiRange);
voiRangeRef.current = properties.voiRange;
}
} else if (viewport instanceof BaseVolumeViewport) {
// For volume viewports, find the actor for the active display set
const volumeIds = viewport.getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID));
if (volumeId) {
properties = viewport.getProperties(volumeId);
if (properties?.voiRange) {
setVoiRange(properties.voiRange);
voiRangeRef.current = properties.voiRange;
}
// Get opacity from colormap if available
if (properties?.colormap?.opacity !== undefined) {
const isArray = Array.isArray(properties.colormap.opacity);
const opacity = isArray
? properties.colormap.opacity.reduce((max, current) => Math.max(max, current), 0)
: properties.colormap.opacity;
setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity));
}
// Get threshold from colormap if available
if (properties?.colormap && properties.colormap.threshold !== undefined) {
setThresholdState(properties.colormap.threshold);
}
}
}
} catch (error) {
console.error('Error initializing VOI range:', error);
}
if (!viewport || !activeDisplaySetInstanceUID) {
return;
}
}, [cornerstoneViewportService, viewportId, activeDisplaySetInstanceUID]);
try {
const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID);
const properties =
dataId != null
? (viewport as Types.IBaseVolumeViewport).getProperties(dataId)
: viewport.getProperties();
if (!properties) {
return;
}
if (properties.voiRange) {
setVoiRange(properties.voiRange);
voiRangeRef.current = properties.voiRange;
}
if (properties.colormap?.opacity !== undefined) {
const opacityVal = properties.colormap.opacity;
const opacity = Array.isArray(opacityVal)
? (opacityVal as unknown as number[]).reduce(
(max, current) => Math.max(max, current),
0
)
: opacityVal;
setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity));
}
if (properties.colormap?.threshold !== undefined) {
setThresholdState(properties.colormap.threshold);
}
} catch (error) {
console.error('Error initializing VOI range:', error);
}
}, [activeDisplaySetInstanceUID, viewport]);
useEffect(() => {
if (!viewportId) {
@ -313,19 +329,11 @@ export function useViewportRendering(
}, [colorbarService, viewportId]);
useEffect(() => {
if (!viewportId || !activeDisplaySetInstanceUID) {
return;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
if (!activeDisplaySetInstanceUID || !viewport?.element) {
return;
}
const element = viewport.element;
if (!element) {
return;
}
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
@ -369,7 +377,7 @@ export function useViewportRendering(
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
element.removeEventListener(Enums.Events.COLORMAP_MODIFIED, updateColormap);
};
}, [viewportId, activeDisplaySetInstanceUID, cornerstoneViewportService, opacityToLinear]);
}, [activeDisplaySetInstanceUID, viewport, opacityToLinear]);
const validateActiveDisplaySet = useCallback(() => {
if (!activeDisplaySetInstanceUID) {
@ -560,16 +568,11 @@ export function useViewportRendering(
context: 'CORNERSTONE',
});
},
[commandsManager, viewportId, validateActiveDisplaySet]
[commandsManager, validateActiveDisplaySet, viewport, viewportId]
);
const setOpacity = useCallback(
(opacityValue: number) => {
if (!viewportId) {
return;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
return;
}
@ -607,7 +610,7 @@ export function useViewportRendering(
viewport.render();
},
[cornerstoneViewportService, viewportId, validateActiveDisplaySet, opacityToLinear]
[validateActiveDisplaySet, opacityToLinear, viewport]
);
const setOpacityLinear = useCallback(
@ -621,11 +624,6 @@ export function useViewportRendering(
const setThreshold = useCallback(
(thresholdValue: number) => {
if (!viewportId) {
return;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
return;
}
@ -653,18 +651,16 @@ export function useViewportRendering(
viewport.render();
},
[cornerstoneViewportService, viewportId, validateActiveDisplaySet]
[validateActiveDisplaySet, viewport]
);
// Get the current colormap for the active display set
const colormap = useMemo(() => {
if (!viewportId || !activeDisplaySetInstanceUID || !viewportDisplaySets?.length) {
if (!activeDisplaySetInstanceUID || !viewportDisplaySets?.length) {
return null;
}
try {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return null;
}
@ -711,13 +707,7 @@ export function useViewportRendering(
colorbarProperties?.colormaps?.[0]
);
}
}, [
cornerstoneViewportService,
viewportId,
activeDisplaySetInstanceUID,
viewportDisplaySets,
colorbarProperties?.colormaps,
]);
}, [activeDisplaySetInstanceUID, viewportDisplaySets, colorbarProperties?.colormaps, viewport]);
// 3D volume rendering functions
const setVolumeRenderingPreset = useCallback(
@ -801,7 +791,9 @@ export function useViewportRendering(
setWindowLevel,
setVOIRange,
voiRange,
windowLevel: utilities.windowLevel.toWindowLevel(voiRange?.lower, voiRange?.upper),
windowLevel: voiRange
? utilities.windowLevel.toWindowLevel(voiRange?.lower, voiRange?.upper)
: { windowCenter: null, windowWidth: null },
// Colorbar functions
hasColorbar,

View File

@ -1,6 +1,7 @@
import { PubSubService, Types as OhifTypes } from '@ohif/core';
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
import { getRenderingEngine } from '@cornerstonejs/core';
import { getDataIdForViewport } from '../../utils/getDataIdForViewport';
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
export default class ColorbarService extends PubSubService {
@ -38,25 +39,6 @@ export default class ColorbarService extends PubSubService {
this.servicesManager = servicesManager;
}
/**
* Gets the appropriate data ID for a viewport and display set
* @param viewport - The viewport instance
* @param displaySetInstanceUID - The display set instance UID to identify data
* @returns The appropriate data ID for the viewport type (volumeId for volume viewports, undefined for stack)
*/
private getDataIdForViewport(viewport, displaySetInstanceUID: string): string | undefined {
// For volume viewports, find the matching volumeId
if (viewport.getAllVolumeIds) {
const volumeIds = viewport.getAllVolumeIds() || [];
return volumeIds.length > 0
? volumeIds.find(id => id.includes(displaySetInstanceUID)) || undefined
: undefined;
}
// For other viewports, no specific dataId is needed for now
return undefined;
}
/**
* Adds a colorbar to a specific viewport identified by `viewportId`, using the provided `displaySetInstanceUIDs` and `options`.
* This method prepares the colorbar state that will be used by the ViewportColorbarsContainer component.
@ -92,7 +74,7 @@ export default class ColorbarService extends PubSubService {
return;
}
const dataId = this.getDataIdForViewport(viewport, displaySetInstanceUID);
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
const colormap = properties?.colormap;
@ -246,7 +228,7 @@ export default class ColorbarService extends PubSubService {
}
// Get the appropriate dataId for this viewport/displaySet combination
const dataId = this.getDataIdForViewport(viewport, displaySetInstanceUID);
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
// Set properties with or without dataId based on what the viewport supports
viewport.setProperties({ colormap }, dataId);

View File

@ -0,0 +1,22 @@
/**
* Resolves the data ID (e.g. volumeId) for a viewport and display set.
* For viewports with multiple volumes/actors, returns the id that matches the display set; otherwise undefined.
* Use this to call viewport.getProperties(dataId) in a viewport-type-agnostic way.
*
* @param viewport - Viewport instance (stack, volume, or future types with optional getAllVolumeIds)
* @param displaySetInstanceUID - Display set instance UID to match
* @returns volumeId (or equivalent) for multi-actor viewports, undefined for single-actor
*/
export function getDataIdForViewport(
viewport: unknown,
displaySetInstanceUID: string
): string | undefined {
const vp = viewport as { getAllVolumeIds?: () => string[] };
if (typeof vp.getAllVolumeIds !== 'function') {
return undefined;
}
const volumeIds = vp.getAllVolumeIds() || [];
return volumeIds.length > 0
? volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined
: undefined;
}

View File

@ -117,7 +117,7 @@ test('checks if image would jump when clicked on a measurement item', async ({
rightPanelPageObject,
viewportPageObject,
}) => {
const viewportInfoBottomRight = viewportPageObject.active.overlayText.bottomRight;
const viewportInfoBottomRight = viewportPageObject.active.overlayText.bottomRight.instanceNumber;
// Image loads on slice 1, confirm on slice 1 then add measurement
await expect(viewportInfoBottomRight).toContainText('1/', { timeout: 10000 });

View File

@ -44,7 +44,7 @@ test('checks saved segmentations loads and jumps to slices', async ({
rightPanelPageObject,
viewportPageObject,
}) => {
const viewportInfoBottomRight = viewportPageObject.active.overlayText.bottomRight;
const viewportInfoBottomRight = viewportPageObject.active.overlayText.bottomRight.instanceNumber;
// Image loads on slice 1, confirm on slice 1
await expect(viewportInfoBottomRight).toContainText('1/', { timeout: 10000 });

View File

@ -0,0 +1,40 @@
import { expect, test, visitStudy } from './utils';
test('should display the window level text as an overlay in various viewport layouts', async ({
page,
mainToolbarPageObject,
viewportPageObject,
}) => {
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095258.1';
const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000);
await expect(viewportPageObject.getNth(0).overlayText.bottomLeft.windowLevel).toBeVisible();
await mainToolbarPageObject.layoutSelection.click();
await page.getByTestId('Layout-3-0').click();
for (let i = 0; i < 3; i++) {
await expect(viewportPageObject.getNth(i).overlayText.bottomLeft.windowLevel).toBeVisible();
}
await mainToolbarPageObject.layoutSelection.click();
await page.getByTestId('Layout-1-1').click();
for (let i = 0; i < 3; i++) {
await expect(viewportPageObject.getNth(i).overlayText.bottomLeft.windowLevel).toBeVisible();
}
});
test('should display the window level text as an overlay in the various TMTV viewports', async ({
page,
viewportPageObject,
}) => {
const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339';
const mode = 'tmtv';
await visitStudy(page, studyInstanceUID, mode, 2000);
for (let i = 0; i < 9; i++) {
await expect(viewportPageObject.getNth(i).overlayText.bottomLeft.windowLevel).toBeVisible();
}
});

View File

@ -17,6 +17,23 @@ type NormalizedDragParams = {
config?: { button?: 'left' | 'right' | 'middle'; delay?: number; steps?: number };
};
export interface IOverlayText {
get windowLevel(): Locator;
get instanceNumber(): Locator;
}
function overlayTextFactory(viewport: Locator, id: string): IOverlayText {
const locator = viewport.getByTestId(id);
return {
get windowLevel() {
return locator.getByTitle('Window Level');
},
get instanceNumber() {
return locator.getByTitle('Instance Number');
},
};
}
export interface IViewportPageObject {
nthAnnotation(nth: number): {
locator: Locator;
@ -46,10 +63,10 @@ export interface IViewportPageObject {
bottomMid: Locator;
};
overlayText: {
topLeft: Locator;
topRight: Locator;
bottomLeft: Locator;
bottomRight: Locator;
topLeft: IOverlayText;
topRight: IOverlayText;
bottomLeft: IOverlayText;
bottomRight: IOverlayText;
};
overlayMenu: {
dataOverlay: DataOverlayPageObject;
@ -122,10 +139,10 @@ export class ViewportPageObject {
private getOverlayText(viewport: Locator) {
return {
topLeft: viewport.getByTestId('viewport-overlay-top-left'),
topRight: viewport.getByTestId('viewport-overlay-top-right'),
bottomLeft: viewport.getByTestId('viewport-overlay-bottom-left'),
bottomRight: viewport.getByTestId('viewport-overlay-bottom-right'),
topLeft: overlayTextFactory(viewport, 'viewport-overlay-top-left'),
topRight: overlayTextFactory(viewport, 'viewport-overlay-top-right'),
bottomLeft: overlayTextFactory(viewport, 'viewport-overlay-bottom-left'),
bottomRight: overlayTextFactory(viewport, 'viewport-overlay-bottom-right'),
};
}