fix: jump to measurement forever (#4867)

This commit is contained in:
Alireza authored and GitHub committed 2025-03-28 10:53:04 -04:00
1 parent eafa2efda5
commit d7bd36544a
39 files changed
+731 -448

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.7.16",
"@cornerstonejs/dicom-image-loader": "^3.7.17",
"@emotion/serialize": "^1.1.3",
"@ohif/core": "3.10.0-beta.140",
"@ohif/extension-cornerstone": "3.10.0-beta.140",
+93 -31
View File
@@ -1,14 +1,13 @@
import React, { useEffect, useCallback, useRef } from 'react';
import React, { useEffect, useCallback, useRef, useMemo } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { Types, MeasurementService } from '@ohif/core';
import { ViewportGrid, ViewportPane } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui-next';
import EmptyViewport from './EmptyViewport';
import classNames from 'classnames';
import { useAppConfig } from '@state';
function ViewerViewportGrid(props: withAppTypes) {
const { servicesManager, viewportComponents = [], dataSource } = props;
const { servicesManager, viewportComponents = [], dataSource, commandsManager } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const [appConfig] = useAppConfig();
@@ -167,17 +166,51 @@ function ViewerViewportGrid(props: withAppTypes) {
useEffect(() => {
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportId, measurement, isConsumed }) => {
event => {
const { viewportId, measurement, isConsumed } = event;
if (isConsumed) {
return;
}
// This occurs when no viewport has elected to consume the event
// so we need to change layouts into a layout which can consume
// the event.
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
const { viewports } = viewportGridService.getState();
// Check if any viewport can display this measurement
let canAnyViewportDisplayMeasurement = false;
viewports.forEach((viewport, id) => {
const displaySetInstanceUIDs = viewport.displaySetInstanceUIDs || [];
const viewportHasDisplaySet = displaySetInstanceUIDs.includes(
referencedDisplaySetInstanceUID
);
// Extract metadata and prepare reference
const { FrameOfReferenceUID, ...metadataRest } = measurement.metadata;
const reference = {
...(viewportHasDisplaySet ? measurement.metadata : metadataRest),
displaySetInstanceUID: referencedDisplaySetInstanceUID,
};
// Check if viewport can display the reference
if (
viewport.isReferenceViewable?.({
viewportId: id,
reference,
})
) {
canAnyViewportDisplayMeasurement = true;
}
});
if (canAnyViewportDisplayMeasurement) {
// Let the viewports handle the jump
return;
}
// Need to change layouts since no viewport consumed the event
const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID);
if (!updatedViewports[0]) {
if (!updatedViewports?.[0]) {
console.warn(
'ViewportGrid::Unable to navigate to viewport containing',
referencedDisplaySetInstanceUID
@@ -185,24 +218,35 @@ function ViewerViewportGrid(props: withAppTypes) {
return;
}
// Arbitrarily assign the viewport to element 0
// TODO - this should perform a search to find the most suitable viewport.
updatedViewports[0] = { ...updatedViewports[0] };
const [viewport] = updatedViewports;
// Find the viewport that can display the measurement
const viewport = updatedViewports.find(viewport => {
const gridViewport = viewportGridService.getViewportState(viewport.viewportId);
return gridViewport.isReferenceViewable?.({
viewportId: viewport.viewportId,
reference: {
...measurement.metadata,
displaySetInstanceUID: referencedDisplaySetInstanceUID,
},
viewportOptions: gridViewport.viewportOptions || {},
});
});
// Copy the viewport options to prevent modifying the internal data
viewport.viewportOptions = {
...viewport.viewportOptions,
orientation: 'acquisition',
// The preferred way to jump to the measurement view is to set the
// view reference, as this can hold information such as the orientation
// or zoom level required to display an annotation. The metadata attribute
// of the measurement is a viewReference, so use it to show the measurement.
// Longer term this should clear the view reference data
viewReference: measurement.metadata,
viewportType: measurement.metadata.volumeId ? 'volume' : null,
};
if (!viewport) {
console.warn('No suitable viewport found for displaying measurement');
return;
}
// Update stored position presentation
commandsManager.run('updateStoredPositionPresentation', {
viewportId: viewport.viewportId,
displaySetInstanceUID: referencedDisplaySetInstanceUID,
referencedImageId: measurement.referencedImageId,
options: {
...measurement.metadata,
},
});
event.consume();
viewportGridService.setDisplaySetsForViewports(updatedViewports);
}
);
@@ -210,7 +254,7 @@ function ViewerViewportGrid(props: withAppTypes) {
return () => {
unsubscribe();
};
}, [viewports]);
}, [viewports, _getUpdatedViewports]);
const onDropHandler = (viewportId, { displaySetInstanceUID }) => {
const customOnDropHandler = customizationService.getCustomization('customOnDropHandler');
@@ -229,6 +273,9 @@ function ViewerViewportGrid(props: withAppTypes) {
});
};
// Store previous isReferenceViewable values to avoid infinite loops
const prevReferenceViewableMap = useRef(new Map());
const getViewportPanes = useCallback(() => {
const viewportPanes = [];
@@ -260,12 +307,27 @@ function ViewerViewportGrid(props: withAppTypes) {
return !displaySet?.unsupported;
});
const ViewportComponent = _getViewportComponent(
const { component: ViewportComponent, isReferenceViewable } = _getViewportComponent(
displaySets,
viewportComponents,
uiNotificationService
);
// Only update isReferenceViewable if it's changed to avoid render loops
// We need to handle both function and non-function values
if (viewportId) {
const prevValue = prevReferenceViewableMap.current.get(viewportId);
const isFunction = typeof isReferenceViewable === 'function';
const isSameFunction = isFunction && typeof prevValue === 'function';
// For non-functions, compare directly. For functions, we treat them as always different
// (this is conservative but safe)
if (!isSameFunction && prevValue !== isReferenceViewable) {
viewportGridService.setIsReferenceViewable(viewportId, isReferenceViewable);
prevReferenceViewableMap.current.set(viewportId, isReferenceViewable);
}
}
// look inside displaySets to see if they need reRendering
const displaySetsNeedsRerendering = displaySets.some(displaySet => {
return displaySet.needsRerendering;
@@ -381,7 +443,7 @@ function ViewerViewportGrid(props: withAppTypes) {
function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) {
if (!displaySets || !displaySets.length) {
return EmptyViewport;
return { component: EmptyViewport, isReferenceViewable: () => false };
}
// Todo: Do we have a viewport that has two different SOPClassHandlerIds?
@@ -395,8 +457,8 @@ function _getViewportComponent(displaySets, viewportComponents, uiNotificationSe
throw new Error('displaySetsToDisplay is null');
}
if (viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)) {
const { component } = viewportComponents[i];
return component;
const { component, isReferenceViewable } = viewportComponents[i];
return { component, isReferenceViewable };
}
}
@@ -407,7 +469,7 @@ function _getViewportComponent(displaySets, viewportComponents, uiNotificationSe
type: 'error',
});
return EmptyViewport;
return { component: EmptyViewport, isReferenceViewable: () => false };
}
export default ViewerViewportGrid;
+1 -1
View File
@@ -37,7 +37,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "^3.7.16",
"@cornerstonejs/dicom-image-loader": "^3.7.17",
"@ohif/ui": "3.10.0-beta.140",
"cornerstone-math": "0.1.9",
"dicom-parser": "^1.8.21"
@@ -670,8 +670,11 @@ class MeasurementService extends PubSubService {
measurement,
});
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
// Important: we should broadcast the layout event first, since
// in the layout there might be a viewport that we can match and choose
// and jump in it before we decide on changing the orientation of different viewports
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, consumableEvent);
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
}
_getSourceUID(name, version) {
@@ -38,6 +38,10 @@ class ViewportGridService extends PubSubService {
this.presentationIdProviders.set(id, provider);
}
public setIsReferenceViewable(viewportId: string, isReferenceViewable: boolean): void {
this.serviceImplementation._setIsReferenceViewable(viewportId, isReferenceViewable);
}
public getPresentationId(id: string, viewportId: string): string | null {
const state = this.getState();
const viewport = state.viewports.get(viewportId);
@@ -93,7 +97,12 @@ class ViewportGridService extends PubSubService {
set: setImplementation,
getNumViewportPanes: getNumViewportPanesImplementation,
setViewportIsReady: setViewportIsReadyImplementation,
setIsReferenceViewable: setIsReferenceViewableImplementation,
getViewportState: getViewportStateImplementation,
}): void {
if (getViewportStateImplementation) {
this.serviceImplementation._getViewportState = getViewportStateImplementation;
}
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
@@ -123,6 +132,9 @@ class ViewportGridService extends PubSubService {
if (setViewportIsReadyImplementation) {
this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation;
}
if (setIsReferenceViewableImplementation) {
this.serviceImplementation._setIsReferenceViewable = setIsReferenceViewableImplementation;
}
}
public publishViewportsReady() {
@@ -148,8 +160,7 @@ class ViewportGridService extends PubSubService {
}
public getViewportState(viewportId: string) {
const state = this.getState();
return state.viewports.get(viewportId);
return this.serviceImplementation._getViewportState(viewportId);
}
public setViewportIsReady(viewportId, callback) {
@@ -45,6 +45,10 @@ export interface ViewportGridState {
activeViewportId: string | null;
layout: Layout;
isHangingProtocolLayout: boolean;
isReferenceViewable: (props: {
viewportId: string;
reference: Record<string, unknown>;
}) => boolean;
viewports: GridViewports;
}
@@ -0,0 +1,54 @@
import { vec3 } from 'gl-matrix';
/**
* Get the plane (orientation) to which the ImageOrientationPatient is most closely aligned
*
* @param displaySetService
* @param displaySetInstanceUID
* @returns orientation
*/
export default function getClosestOrientationFromIOP(displaySetService, displaySetInstanceUID) {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
const imageOrientationPatient = displaySet.instances[0].ImageOrientationPatient as Array<number>;
// ImageOrientationPatient must be an array of length 6.
if (imageOrientationPatient?.length !== 6) {
return;
}
// Take cross product to get vector coming "out" of image plane
const rowCosineVec = vec3.fromValues(
imageOrientationPatient[0],
imageOrientationPatient[1],
imageOrientationPatient[2]
);
const colCosineVec = vec3.fromValues(
imageOrientationPatient[3],
imageOrientationPatient[4],
imageOrientationPatient[5]
);
const scanAxisNormal = vec3.cross(vec3.create(), rowCosineVec, colCosineVec);
// Define the reference vectors for axial, coronal, and sagittal planes
const unitVectors = {
axial: vec3.fromValues(0, 0, 1),
coronal: vec3.fromValues(0, 1, 0),
sagittal: vec3.fromValues(1, 0, 0),
};
// Compute dot products for each reference plane
// Because all vectors are normalized, dot product is bounded between -1 and 1
let maxDot = 0;
let maxOrientation: string = '';
for (const [k, v] of Object.entries(unitVectors)) {
// Absolute value of dot product because we only care about alignment with the axis
// For example, dot product of -1 for a given axis means perfect alignment
// but the image is pointing in the "opposite" direction
const res = Math.abs(vec3.dot(scanAxisNormal, v));
if (res > maxDot) {
maxDot = res;
maxOrientation = k;
}
}
return maxOrientation;
}
-53
View File
@@ -1,53 +0,0 @@
import * as utils from './index';
describe('Top level exports', () => {
test('should export the modules ', () => {
const expectedExports = [
'guid',
'ObjectPath',
'absoluteUrl',
'seriesSortCriteria',
'sortBy',
'sortStudy',
'sortBySeriesDate',
'sortStudyInstances',
'sortStudySeries',
'sortingCriteria',
'splitComma',
'getSplitParam',
'isLowPriorityModality',
'writeScript',
'debounce',
'downloadCSVReport',
'imageIdToURI',
'roundNumber',
'b64toBlob',
'sopClassDictionary',
'createStudyBrowserTabs',
'formatDate',
'formatTime',
'formatPN',
'generateAcceptHeader',
'isEqualWithin',
//'loadAndCacheDerivedDisplaySets',
'isDisplaySetReconstructable',
'isImage',
'urlUtil',
'makeDeferred',
'makeCancelable',
'hotkeys',
'Queue',
'isDicomUid',
'resolveObjectPath',
'hierarchicalListUtils',
'progressTrackingUtils',
'uuidv4',
'addAccessors',
'MeasurementFilters',
].sort();
const exports = Object.keys(utils.default).sort();
expect(exports).toEqual(expectedExports);
});
});
+3 -1
View File
@@ -40,7 +40,7 @@ import { splitComma, getSplitParam } from './splitComma';
import { createStudyBrowserTabs } from './createStudyBrowserTabs';
import { sopClassDictionary } from './sopClassDictionary';
import * as MeasurementFilters from './measurementFilters';
import getClosestOrientationFromIOP from './getClosestOrientationFromIOP';
// Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager.
@@ -86,6 +86,7 @@ const utils = {
generateAcceptHeader,
createStudyBrowserTabs,
MeasurementFilters,
getClosestOrientationFromIOP,
};
export {
@@ -120,6 +121,7 @@ export {
generateAcceptHeader,
createStudyBrowserTabs,
MeasurementFilters,
getClosestOrientationFromIOP,
};
export default utils;
@@ -141,6 +141,19 @@ interface ViewportGridProviderProps {
export function ViewportGridProvider({ children, service }: ViewportGridProviderProps) {
const viewportGridReducer = (state: AppTypes.ViewportGrid.State, action) => {
switch (action.type) {
case 'SET_IS_REFERENCE_VIEWABLE': {
const { viewportId, isReferenceViewable } = action.payload;
const viewports = new Map(state.viewports);
const viewport = viewports.get(viewportId);
if (!viewport) {
return;
}
viewports.set(viewportId, {
...viewport,
isReferenceViewable,
});
return { ...state, viewports };
}
case 'SET_ACTIVE_VIEWPORT_ID': {
return { ...state, ...{ activeViewportId: action.payload } };
}
@@ -378,6 +391,13 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
[dispatch]
);
const setIsReferenceViewable = useCallback(
(viewportId, isReferenceViewable) => {
dispatch({ type: 'SET_IS_REFERENCE_VIEWABLE', payload: { viewportId, isReferenceViewable } });
},
[dispatch]
);
const setDisplaySetsForViewports = useCallback(
viewports =>
dispatch({
@@ -449,6 +469,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
[dispatch]
);
const getViewportState = useCallback(
viewportId => {
const { viewports } = viewportGridState;
return viewports.get(viewportId);
},
[viewportGridState]
);
const getNumViewportPanes = useCallback(() => {
const { layout, viewports } = viewportGridState;
const { numRows, numCols } = layout;
@@ -466,12 +494,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
getState,
setActiveViewportId,
setDisplaySetsForViewports,
setIsReferenceViewable,
setLayout,
reset,
onModeExit: reset,
set,
getNumViewportPanes,
setViewportIsReady,
getViewportState,
getGridViewportsReady,
});
}
@@ -480,12 +510,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
service,
setActiveViewportId,
setDisplaySetsForViewports,
setIsReferenceViewable,
setLayout,
reset,
set,
getNumViewportPanes,
setViewportIsReady,
getGridViewportsReady,
getViewportState,
]);
// run many of the calls through the service itself since we want to publish events
@@ -494,7 +526,10 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
setActiveViewportId: index => service.setActiveViewportId(index),
setDisplaySetsForViewport: props => service.setDisplaySetsForViewports([props]),
setDisplaySetsForViewports: props => service.setDisplaySetsForViewports(props),
setIsReferenceViewable: (viewportId, isReferenceViewable) =>
service.setIsReferenceViewable(viewportId, isReferenceViewable),
setLayout: layout => service.setLayout(layout),
getViewportState: viewportId => service.getViewportState(viewportId),
reset: () => service.reset(),
set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events
getNumViewportPanes,