ohif-viewer/extensions/cornerstone-dicom-sr/src/utils/hydrateStructuredReport.ts
Ghadeer Albattarni 7a38903b19
fix(sr-hydration): enable hydration and arrow navigation for 3D SR measurements (#5887)
Joe is away, so approving based on the code having the requested change, and otherwise looking good/passing tests.

* fix(sr-hydration): enable hydration and arrows navigation for 3D SR measurements

* test: add automated test for SR measurement navigation with arrows after hydration

* add cross-study warning in the 3D branch

* test: address reviewer feedback for the test

* fix: support 3D and 2D annotations for SR hydration

* test: improve navigation to first image

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
2026-03-16 09:36:56 -04:00

396 lines
13 KiB
TypeScript

import { utilities, metaData, type Types } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import { vec3 } from 'gl-matrix';
import getLabelFromDCMJSImportedToolData from './getLabelFromDCMJSImportedToolData';
import { adaptersSR } from '@cornerstonejs/adapters';
import { annotation as CsAnnotation, type Types as ToolTypes } from '@cornerstonejs/tools';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
const { locking } = CsAnnotation;
const { guid } = OHIF.utils;
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const convertCode = (codingValues, code) => {
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') {
return;
}
const ref = `${code.CodingSchemeDesignator}:${code.CodeValue}`;
const ret = { ...codingValues[ref], ref, ...code, text: code.CodeMeaning };
return ret;
};
const convertSites = (codingValues, sites) => {
if (!sites || !sites.length) {
return;
}
const ret = [];
// Do as a loop to convert away from Proxy instances
for (let i = 0; i < sites.length; i++) {
// Deal with irregular conversion from dcmjs
const site = convertCode(codingValues, sites[i][0] || sites[i]);
if (site) {
ret.push(site);
}
}
return (ret.length && ret) || undefined;
};
/**
* Hydrates a structured report
* Handles 2d and 3d hydration from SCOORD and SCOORD3D points
* For 3D hydration, chooses a volume display set to display with
* FOr 2D hydration, chooses the (first) display set containing the referenced image.
*/
export default function hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager }: withAppTypes,
displaySetInstanceUID
) {
const dataSource = extensionManager.getActiveDataSource()[0];
const { measurementService, displaySetService, customizationService } = servicesManager.services;
const codingValues = customizationService.getCustomization('codingValues');
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
const {
StudyInstanceUID: studyUID,
SeriesInstanceUID: seriesUID,
instance: { SOPInstanceUID: sopUID },
} = displaySet;
// TODO -> We should define a strict versioning somewhere.
const mappings = measurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
if (!mappings || !mappings.length) {
throw new Error(
`Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.`
);
}
const instance = DicomMetadataStore.getInstance(studyUID, seriesUID, sopUID);
const sopInstanceUIDToImageId = {};
displaySet.measurements.forEach(measurement => {
const { ReferencedSOPInstanceUID, imageId, frameNumber = 1 } = measurement;
const key = `${ReferencedSOPInstanceUID}:${frameNumber}`;
if (imageId && !sopInstanceUIDToImageId[key]) {
sopInstanceUIDToImageId[key] = imageId;
}
});
// Mapping of legacy datasets is now directly handled by adapters module
const datasetToUse = instance;
// Use CS3D adapters to generate toolState.
let storedMeasurementByAnnotationType = MeasurementReport.generateToolState(
datasetToUse,
// NOTE: we need to pass in the imageIds to dcmjs since the we use them
// for the imageToWorld transformation. The following assumes that the order
// that measurements were added to the display set are the same order as
// the measurementGroups in the instance.
sopInstanceUIDToImageId,
metaData
);
const onBeforeSRHydration = customizationService.getCustomization('onBeforeSRHydration')?.value;
if (typeof onBeforeSRHydration === 'function') {
storedMeasurementByAnnotationType = onBeforeSRHydration({
storedMeasurementByAnnotationType,
displaySet,
});
}
// Filter what is found by DICOM SR to measurements we support.
const mappingDefinitions = mappings.map(m => m.annotationType);
const hydratableMeasurementsInSR = {};
Object.keys(storedMeasurementByAnnotationType).forEach(key => {
if (mappingDefinitions.includes(key)) {
hydratableMeasurementsInSR[key] = storedMeasurementByAnnotationType[key];
}
});
let targetStudyInstanceUID;
const SeriesInstanceUIDs = [];
// Set the series touched as tracked.
const imageIds = getImageIds(hydratableMeasurementsInSR, sopInstanceUIDToImageId);
for (const imageId of imageIds) {
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) {
SeriesInstanceUIDs.push(SeriesInstanceUID);
}
if (!targetStudyInstanceUID) {
targetStudyInstanceUID = StudyInstanceUID;
} else if (targetStudyInstanceUID !== StudyInstanceUID) {
console.warn('NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.');
}
}
// For 3d annotations there are no image IDs,
// so we need to find the display sets by frame of reference to get the SeriesInstanceUIDs
const frameOfReferenceUIDs = getFrameOfReferenceUIDs(
hydratableMeasurementsInSR,
sopInstanceUIDToImageId
);
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
const displaySetsFOR = displaySetService.getDisplaySetsBy(
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
);
const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
if (!ds) {
continue;
}
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
}
if (!targetStudyInstanceUID) {
targetStudyInstanceUID = ds.StudyInstanceUID;
} else if (targetStudyInstanceUID !== ds.StudyInstanceUID) {
console.warn('NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.');
}
}
/**
* Gets reference data for what frame of reference and the referenced
* image id, or for 3d measurements, the volumeId to apply this annotation to.
*/
function getReferenceData(toolData): ToolTypes.AnnotationMetadata {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
if (!imageId) {
return getReferenceData3D(toolData, servicesManager);
}
const instance = metaData.get('instance', imageId);
const {
FrameOfReferenceUID,
// SOPInstanceUID,
// SeriesInstanceUID,
// StudyInstanceUID,
} = instance;
return {
referencedImageId: imageId,
FrameOfReferenceUID,
};
}
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
toolData.uid = guid();
const referenceData = getReferenceData(toolData);
const { referencedImageId } = referenceData;
const annotation = {
annotationUID: toolData.annotation.annotationUID,
data: toolData.annotation.data,
predecessorImageId: toolData.predecessorImageId,
metadata: {
...referenceData,
toolName: annotationType,
},
};
utilities.updatePlaneRestriction(annotation.data.handles.points, annotation.metadata);
const source = measurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
annotation.data.label = getLabelFromDCMJSImportedToolData(toolData);
annotation.data.finding = convertCode(codingValues, toolData.finding?.[0]);
annotation.data.findingSites = convertSites(codingValues, toolData.findingSites);
annotation.data.findingSites?.forEach(site => {
if (site.type) {
annotation.data[site.type] = site;
}
});
const matchingMapping = mappings.find(m => m.annotationType === annotationType);
const newAnnotationUID = measurementService.addRawMeasurement(
source,
annotationType,
{ annotation },
matchingMapping.toMeasurementSchema,
dataSource
);
commandsManager.runCommand('updateMeasurement', {
uid: newAnnotationUID,
code: annotation.data.finding,
});
if (disableEditing) {
locking.setAnnotationLocked(newAnnotationUID, true);
}
if (referencedImageId && !imageIds.includes(referencedImageId)) {
imageIds.push(referencedImageId);
}
});
});
displaySet.isHydrated = true;
return {
StudyInstanceUID: targetStudyInstanceUID,
SeriesInstanceUIDs,
};
}
/**
* Gets the unique imageIds from hydratable measurements that have an imageId reference
* (i.e., 2D/SCOORD annotations).
*/
function getImageIds(hydratableMeasurementsInSR, sopInstanceUIDToImageId): string[] {
const imageIds: string[] = [];
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = toolData.annotation.data?.frameNumber || 1;
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
if (imageId && !imageIds.includes(imageId)) {
imageIds.push(imageId);
}
});
});
return imageIds;
}
/**
* Gets the unique FrameOfReferenceUIDs from hydratable measurements that have no imageId reference
* (i.e., 3D/SCOORD3D annotations). This excludes annotations handled by the getImageIds function.
*/
function getFrameOfReferenceUIDs(hydratableMeasurementsInSR, sopInstanceUIDToImageId): string[] {
const frameOfReferenceUIDs: string[] = [];
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
const frameNumber = toolData.annotation.data?.frameNumber || 1;
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
if (!imageId) {
const { FrameOfReferenceUID } = toolData.annotation.metadata;
if (FrameOfReferenceUID && !frameOfReferenceUIDs.includes(FrameOfReferenceUID)) {
frameOfReferenceUIDs.push(FrameOfReferenceUID);
}
}
});
});
return frameOfReferenceUIDs;
}
/**
* For 3d annotations, there are often several display sets which could
* be used to display the annotation. Choose the first annotation with the
* same frame of reference that is reconstructable, or the first display set
* otherwise.
*/
function chooseDisplaySet(displaySets, reference) {
if (!displaySets?.length) {
console.warn('No display set found for', reference);
return;
}
if (displaySets.length === 1) {
return displaySets[0];
}
const volumeDs = displaySets.find(ds => ds.isReconstructable);
if (volumeDs) {
return volumeDs;
}
return displaySets[0];
}
/**
* Gets the additional reference data appropriate for a 3d reference.
* This will choose a volume id, frame of reference and a plane restriction.
*/
function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
const { FrameOfReferenceUID } = toolData.annotation.metadata;
const { points } = toolData.annotation.data.handles;
const { displaySetService } = servicesManager.services;
const displaySetsFOR = displaySetService.getDisplaySetsBy(
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID
);
if (!displaySetsFOR.length || !points?.length) {
return {
FrameOfReferenceUID,
};
}
const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
const cameraView = chooseCameraView(ds, points);
const viewReference = {
...cameraView,
volumeId: ds.displaySetInstanceUID,
FrameOfReferenceUID,
};
utilities.updatePlaneRestriction(points, viewReference);
return viewReference;
}
/**
* Chooses a possible camera view - right now this is fairly basic,
* just setting the unknowns to null.
*/
function chooseCameraView(_ds, points) {
const selectedPoints = choosePoints(points);
const cameraFocalPoint = <Point3>centerOf(selectedPoints);
// These are sufficient to be null for now and can be set on first view
let viewPlaneNormal: Types.Point3 = null;
let viewUp: Types.Point3 = null;
return {
cameraFocalPoint,
viewPlaneNormal,
viewUp,
};
}
function centerOf(points) {
const scale = 1 / points.length;
const center = vec3.create();
for (const point of points) {
vec3.scaleAndAdd(center, center, point, scale);
}
return center;
}
function choosePoints(points) {
if (points.length === 1 || points.length === 2) {
return points;
}
const firstIndex = 0;
const secondIndex = Math.ceil(points.length / 4);
const thirdIndex = Math.ceil(points.length / 2);
// TODO - check if colinear, if so try to find another 3 points.
const newPoints = [points[firstIndex], points[secondIndex], points[thirdIndex]];
return newPoints;
}