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

@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.7.16",
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/adapters": "^3.7.17",
"@cornerstonejs/core": "^3.7.17",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.7.16",
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/adapters": "^3.7.17",
"@cornerstonejs/core": "^3.7.17",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
+3 -3
View File
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.7.16",
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/tools": "^3.7.16",
"@cornerstonejs/adapters": "^3.7.17",
"@cornerstonejs/core": "^3.7.17",
"@cornerstonejs/tools": "^3.7.17",
"classnames": "^2.3.2"
}
}
@@ -77,28 +77,4 @@ export default function init({
SRRectangleROI: dashedLine,
global: {},
});
measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportId, measurement, isConsumed }) => {
if (isConsumed) {
return;
}
try {
const currentViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { viewPlaneNormal } = currentViewport.getCamera();
const referencedImageId = csToolsUtils.getClosestImageIdForStackViewport(
currentViewport as StackViewport,
measurement.points[0],
viewPlaneNormal
);
const imageIndex = (currentViewport as StackViewport)
.getImageIds()
.indexOf(referencedImageId);
csUtils.jumpToSlice(currentViewport.element, { imageIndex });
} catch (error) {
console.warn('Unable to jump to image based on measurement coordinate', error);
}
}
);
}
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/tools": "^3.7.16",
"@cornerstonejs/core": "^3.7.17",
"@cornerstonejs/tools": "^3.7.17",
"classnames": "^2.3.2"
}
}
+6 -6
View File
@@ -38,7 +38,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/core": "3.10.0-beta.140",
"@ohif/ui": "3.10.0-beta.140",
"dcmjs": "*",
@@ -51,11 +51,11 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.7.16",
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/labelmap-interpolation": "^3.7.16",
"@cornerstonejs/polymorphic-segmentation": "^3.7.16",
"@cornerstonejs/tools": "^3.7.16",
"@cornerstonejs/adapters": "^3.7.17",
"@cornerstonejs/core": "^3.7.17",
"@cornerstonejs/labelmap-interpolation": "^3.7.17",
"@cornerstonejs/polymorphic-segmentation": "^3.7.17",
"@cornerstonejs/tools": "^3.7.17",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "32.12.0",
"html2canvas": "^1.4.1",
@@ -23,12 +23,6 @@ import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewport
const STACK = 'stack';
/**
* Caches the jump to measurement operation, so that if display set is shown,
* it can jump to the measurement.
*/
let cacheJumpToMeasurementEvent;
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
// Then we don't need to worry about the re-renders if the props change.
const OHIFCornerstoneViewport = React.memo(
@@ -99,6 +93,7 @@ const OHIFCornerstoneViewport = React.memo(
cornerstoneCacheService,
viewportActionCornersService,
customizationService,
measurementService,
} = servicesManager.services;
const [viewportDialogState] = useViewportDialog();
@@ -263,14 +258,6 @@ const OHIFCornerstoneViewport = React.memo(
const presentations = getViewportPresentations(viewportId, viewportOptions);
let measurement;
if (cacheJumpToMeasurementEvent?.viewportId === viewportId) {
measurement = cacheJumpToMeasurementEvent.measurement;
// Delete the position presentation so that viewport navigates direct
presentations.positionPresentation = null;
cacheJumpToMeasurementEvent = null;
}
// Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component
// Used for segmentation hydration right now, since the logic to decide whether
// a viewport needs to render a segmentation lives inside the CornerstoneViewportService
@@ -288,10 +275,6 @@ const OHIFCornerstoneViewport = React.memo(
displaySetOptions,
presentations
);
if (measurement) {
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
}
};
loadViewportData();
@@ -312,16 +295,13 @@ const OHIFCornerstoneViewport = React.memo(
return;
}
const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents(
elementRef,
viewportId,
servicesManager
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
event => handleJumpToMeasurement(event, elementRef, viewportId, cornerstoneViewportService)
);
_checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager);
return () => {
unsubscribeFromJumpToMeasurementEvents();
unsubscribe();
};
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
@@ -427,99 +407,52 @@ const OHIFCornerstoneViewport = React.memo(
areEqual
);
function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) {
const { measurementService, cornerstoneViewportService } = servicesManager.services;
// Helper function to handle jumping to measurements
function handleJumpToMeasurement(event, elementRef, viewportId, cornerstoneViewportService) {
const { measurement, isConsumed } = event;
if (!measurement || isConsumed) {
return;
}
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
props => {
cacheJumpToMeasurementEvent = props;
const { viewportId: jumpId, measurement, isConsumed } = props;
if (!measurement || isConsumed) {
return;
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
// Decide on which viewport should handle this
cacheJumpToMeasurementEvent.cornerstoneViewport =
cornerstoneViewportService.getViewportIdToJump(jumpId, {
displaySetInstanceUID: measurement.displaySetInstanceUID,
...measurement.metadata,
referencedImageId:
measurement.referencedImageId || measurement.metadata?.referencedImageId,
});
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) {
return;
}
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
const enabledElement = getEnabledElement(elementRef.current);
if (!enabledElement) {
return;
}
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
const { metadata, displaySetInstanceUID } = measurement;
const viewportDisplaySets = cornerstoneViewportService.getViewportDisplaySets(viewportId);
const showingDisplaySet = viewportDisplaySets.find(
ds => ds.displaySetInstanceUID === displaySetInstanceUID
);
return unsubscribe;
}
let metadataToUse = metadata;
// if it is not showing the displaySet we need to remove the FOR from the metadata
if (!showingDisplaySet) {
metadataToUse = {
...metadata,
FrameOfReferenceUID: undefined,
};
}
// Check if there is a queued jumpToMeasurement event
function _checkForCachedJumpToMeasurementEvents(
elementRef,
viewportId,
displaySets,
servicesManager
) {
if (!cacheJumpToMeasurementEvent) {
return;
}
if (cacheJumpToMeasurementEvent.isConsumed) {
cacheJumpToMeasurementEvent = null;
return;
}
const displaysUIDs = displaySets.map(displaySet => displaySet.displaySetInstanceUID);
if (!displaysUIDs?.length) {
// Todo: make it work with cases where we want to define FOR based measurements too
if (!viewport.isReferenceViewable(metadataToUse, WITH_NAVIGATION)) {
return;
}
// Jump to measurement if the measurement exists
const { measurement } = cacheJumpToMeasurementEvent;
if (measurement && elementRef) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
try {
viewport.setViewReference(metadata);
viewport.render();
} catch (e) {
console.warn('Unable to apply', metadata, e);
}
}
function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) {
const { viewportGridService } = servicesManager.services;
const targetElement = targetElementRef.current;
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly
// setCornerstoneMeasurementActive(measurement);
viewportGridService.setActiveViewportId(viewportId);
const enabledElement = getEnabledElement(targetElement);
if (enabledElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
const { metadata } = measurement;
if (!viewport.isReferenceViewable(metadata, WITH_NAVIGATION)) {
console.log("Reference isn't viewable, postponing until updated");
return;
}
try {
viewport.setViewReference(metadata);
} catch (e) {
console.warn('Unable to apply', metadata, e);
}
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
// Jump to measurement consumed, remove.
cacheJumpToMeasurementEvent?.consume?.();
cacheJumpToMeasurementEvent = null;
}
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
event?.consume?.();
}
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
+13 -11
View File
@@ -215,6 +215,7 @@ function commandsModule({
viewportId,
displaySetInstanceUID,
referencedImageId,
options,
}) => {
const presentations = cornerstoneViewportService.getPresentations(viewportId);
const { positionPresentationStore, setPositionPresentation, getPositionPresentationId } =
@@ -226,16 +227,18 @@ function commandsModule({
([key, value]) => key.includes(displaySetInstanceUID) && value.viewportId === viewportId
)?.[0];
if (previousReferencedDisplaySetStoreKey) {
const presentationData = referencedImageId
? {
...presentations.positionPresentation,
viewReference: {
referencedImageId,
},
}
: presentations.positionPresentation;
// Create presentation data with referencedImageId and options if provided
const presentationData = referencedImageId
? {
...presentations.positionPresentation,
viewReference: {
referencedImageId,
...options,
},
}
: presentations.positionPresentation;
if (previousReferencedDisplaySetStoreKey) {
setPositionPresentation(previousReferencedDisplaySetStoreKey, presentationData);
return;
}
@@ -243,13 +246,12 @@ function commandsModule({
// if not found means we have not visited that referencedDisplaySetInstanceUID before
// so we need to grab the positionPresentationId directly from the store,
// Todo: this is really hacky, we should have a better way for this
const positionPresentationId = getPositionPresentationId({
displaySetInstanceUIDs: [displaySetInstanceUID],
viewportId,
});
setPositionPresentation(positionPresentationId, presentations.positionPresentation);
setPositionPresentation(positionPresentationId, presentationData);
},
getNearbyToolData({ nearbyToolData, element, canvasCoordinates }) {
return nearbyToolData ?? cstUtils.getAnnotationNearPoint(element, canvasCoordinates);
+1 -3
View File
@@ -173,9 +173,6 @@ const cornerstoneExtension: Types.Extensions.Extension = {
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
// const onNewImageHandler = jumpData => {
// commandsManager.runCommand('jumpToImage', jumpData);
// };
const { toolbarService } = servicesManager.services;
return (
@@ -192,6 +189,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
{
name: 'cornerstone',
component: ExtendedOHIFCornerstoneViewport,
isReferenceViewable: props => utils.isReferenceViewable({ ...props, servicesManager }),
},
];
},
+12
View File
@@ -92,6 +92,7 @@ export default async function init({
hangingProtocolService,
viewportGridService,
segmentationService,
measurementService,
} = servicesManager.services;
window.services = servicesManager.services;
@@ -177,6 +178,17 @@ export default async function init({
initCineService(servicesManager);
initStudyPrefetcherService(servicesManager);
[
measurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
measurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
].forEach(event => {
measurementService.subscribe(event, evt => {
const { measurement } = evt;
const { uid: annotationUID } = measurement;
cornerstoneTools.annotation.selection.setAnnotationSelected(annotationUID, true);
});
});
segmentationService.subscribeDebounced(
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
async ({ segmentationId }) => {
@@ -38,7 +38,7 @@ const EVENTS = {
VIEWPORT_VOLUMES_CHANGED: 'event::cornerstoneViewportService:viewportVolumesChanged',
};
export const WITH_NAVIGATION = { withNavigation: true, withOrientation: true };
export const WITH_NAVIGATION = { withNavigation: true, withOrientation: false };
/**
* Handles cornerstone viewport logic including enabling, disabling, and
@@ -851,14 +851,18 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
viewport.setProperties(properties, volumeId);
});
viewport.render();
this.setPresentations(viewport.id, presentations, viewportInfo);
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
if (!presentations.positionPresentation) {
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
if (imageIndex !== undefined) {
csUtils.jumpToSlice(viewport.element, {
imageIndex,
});
if (imageIndex !== undefined) {
csUtils.jumpToSlice(viewport.element, {
imageIndex,
});
}
}
viewport.render();
@@ -1177,6 +1181,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
});
}
/**
* Gets the display sets for a given viewport
* @param viewportId - The ID of the viewport to get display sets for
* @returns Array of display sets for the viewport
*/
public getViewportDisplaySets(viewportId: string): OhifTypes.DisplaySet[] {
const { displaySetService } = this.servicesManager.services;
const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewportId) || [];
return displaySetInstanceUIDs
.map(uid => displaySetService.getDisplaySetByUID(uid))
.filter(Boolean);
}
}
export default CornerstoneViewportService;
@@ -1,7 +1,9 @@
import { handleSegmentChange } from './segmentUtils';
import { isReferenceViewable } from './isReferenceViewable';
const utils = {
handleSegmentChange,
isReferenceViewable,
};
export default utils;
@@ -0,0 +1,98 @@
import { vec3 } from 'gl-matrix';
import { Enums } from '@cornerstonejs/core';
import OrientationAxis = Enums.OrientationAxis;
export const isReferenceViewable = ({
viewportId,
reference,
viewportOptions,
servicesManager,
}) => {
const { cornerstoneViewportService, displaySetService } = servicesManager.services;
if (!viewportOptions) {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
// we can make a customization for this to allow
const isViewable = viewport.isReferenceViewable(reference, {
withNavigation: true,
});
return isViewable;
}
if (viewportOptions.viewportType === 'stack') {
// we only need the viewport to include the referenced imageId
const displaySet = displaySetService.getDisplaySetByUID(reference.displaySetInstanceUID);
const imageIds = displaySet.instances.map(instance => instance.imageId);
return imageIds.includes(reference.referencedImageId);
}
// for the volume viewports, we need to check orientation
const { orientation } = viewportOptions;
// Todo: handle hanging protocols that have acquisition orientation
const closestOrientation = getClosestOrientationFromIOP(
displaySetService,
reference.displaySetInstanceUID
);
return closestOrientation === orientation;
};
/**
* Get the plane (orientation) to which the ImageOrientationPatient is most closely aligned
*
* @param displaySetService
* @param displaySetInstanceUID
* @returns orientation
*/
export default function getClosestOrientationFromIOP(
displaySetService,
displaySetInstanceUID
): OrientationAxis {
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 = {
[OrientationAxis.AXIAL]: vec3.fromValues(0, 0, 1),
[OrientationAxis.CORONAL]: vec3.fromValues(0, 1, 0),
[OrientationAxis.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 as OrientationAxis;
}
@@ -82,7 +82,7 @@ function ViewerLayout({
);
}
return { entry, content: entry.component };
return { entry };
};
useEffect(() => {
@@ -107,6 +107,7 @@ function ViewerLayout({
return {
component: entry.component,
isReferenceViewable: entry.isReferenceViewable,
displaySetsToDisplay: viewportComponent.displaySetsToDisplay,
};
};
@@ -14,6 +14,7 @@ const getViewportModule = ({
{
name: 'chartViewport',
component: LineChartViewport,
isReferenceViewable: () => false,
},
];
};
+2 -2
View File
@@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^3.7.16",
"@cornerstonejs/tools": "^3.7.16",
"@cornerstonejs/core": "^3.7.17",
"@cornerstonejs/tools": "^3.7.17",
"@ohif/core": "3.10.0-beta.140",
"@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140",
"@ohif/extension-default": "3.10.0-beta.140",
@@ -268,12 +268,20 @@ function TrackedMeasurementsContextProvider(
displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID &&
displaySet.isRehydratable === true
) {
console.log('sending event...', trackedMeasurements);
sendTrackedMeasurementsEvent('PROMPT_HYDRATE_SR', {
const params = {
displaySetInstanceUID: displaySet.displaySetInstanceUID,
SeriesInstanceUID: displaySet.SeriesInstanceUID,
viewportId: activeViewportId,
});
};
// Check if we should bypass the confirmation prompt
const disableConfirmationPrompts = appConfig?.disableConfirmationPrompts;
if (disableConfirmationPrompts) {
sendTrackedMeasurementsEvent('HYDRATE_SR', params);
} else {
sendTrackedMeasurementsEvent('PROMPT_HYDRATE_SR', params);
}
}
}
};
@@ -284,6 +292,7 @@ function TrackedMeasurementsContextProvider(
sendTrackedMeasurementsEvent,
servicesManager.services,
viewports,
appConfig,
]);
useEffect(() => {
@@ -57,7 +57,6 @@ const machineConfiguration = {
actions: ['setPreviousState'],
},
],
// Unused? We may only do PROMPT_HYDRATE_SR now?
SET_TRACKED_SERIES: [
{
target: 'tracking',
@@ -1,4 +1,5 @@
import React from 'react';
import { utils } from '@ohif/extension-cornerstone';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/TrackedCornerstoneViewport');
@@ -28,6 +29,7 @@ function getViewportModule({ servicesManager, commandsManager, extensionManager
{
name: 'cornerstone-tracked',
component: ExtendedOHIFCornerstoneTrackingViewport,
isReferenceViewable: props => utils.isReferenceViewable({ ...props, servicesManager }),
},
];
}