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>
This commit is contained in:
Ghadeer Albattarni 2026-03-16 09:36:56 -04:00 committed by GitHub
parent 40a472a9b1
commit 7a38903b19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 178 additions and 34 deletions

View File

@ -80,7 +80,7 @@ export default function hydrateStructuredReport(
const { ReferencedSOPInstanceUID, imageId, frameNumber = 1 } = measurement;
const key = `${ReferencedSOPInstanceUID}:${frameNumber}`;
if (!sopInstanceUIDToImageId[key]) {
if (imageId && !sopInstanceUIDToImageId[key]) {
sopInstanceUIDToImageId[key] = imageId;
}
});
@ -118,41 +118,17 @@ export default function hydrateStructuredReport(
}
});
// Set the series touched as tracked.
const imageIds = [];
// TODO: notification if no hydratable?
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 (!imageIds.includes(imageId)) {
imageIds.push(imageId);
}
});
});
let targetStudyInstanceUID;
const SeriesInstanceUIDs = [];
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
if (!imageId) {
continue;
}
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
// 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) {
@ -160,6 +136,31 @@ export default function hydrateStructuredReport(
}
}
// 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.
@ -196,7 +197,7 @@ export default function hydrateStructuredReport(
toolDataForAnnotationType.forEach(toolData => {
toolData.uid = guid();
const referenceData = getReferenceData(toolData);
const { imageId } = referenceData;
const { referencedImageId } = referenceData;
const annotation = {
annotationUID: toolData.annotation.annotationUID,
@ -241,8 +242,8 @@ export default function hydrateStructuredReport(
locking.setAnnotationLocked(newAnnotationUID, true);
}
if (imageId && !imageIds.includes(imageId)) {
imageIds.push(imageId);
if (referencedImageId && !imageIds.includes(referencedImageId)) {
imageIds.push(referencedImageId);
}
});
});
@ -255,15 +256,64 @@ export default function hydrateStructuredReport(
};
}
/**
* 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, annotation) {
function chooseDisplaySet(displaySets, reference) {
if (!displaySets?.length) {
console.warn('No display set found for', annotation);
console.warn('No display set found for', reference);
return;
}
if (displaySets.length === 1) {

View File

@ -0,0 +1,94 @@
import { expect, navigateWithViewportArrow, test, visitStudy } from './utils';
import { expectRowSelected } from './utils/assertions';
import { press } from './utils/keyboardUtils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.7310.5101.860473186348887719777907797922';
const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000);
});
test('should navigate SR measurements with next/prev arrows after hydration for 3D SR', async ({
page,
DOMOverlayPageObject,
leftPanelPageObject,
rightPanelPageObject,
viewportPageObject,
}) => {
await rightPanelPageObject.toggle();
await rightPanelPageObject.measurementsPanel.select();
await leftPanelPageObject.loadSeriesByModality('SR');
await page.waitForTimeout(2000);
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
await page.waitForTimeout(2000);
const measurementCount = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
expect(measurementCount).toBeGreaterThan(1);
// Navigate to first image
await viewportPageObject.active.pane.click();
await press({ page, key: 'Home' });
await page.waitForTimeout(2000);
// first arrow click should navigate to second measurement
await navigateWithViewportArrow(viewportPageObject, 'next');
await expectRowSelected(rightPanelPageObject.measurementsPanel.panel.nthMeasurement(1));
await expect(viewportPageObject.active.svg('circle')).toBeVisible();
const secondAnnotation = viewportPageObject.active.nthAnnotation(0);
await expect(secondAnnotation.locator).toBeVisible();
await expect(secondAnnotation.text.locator).toBeVisible();
// navigate back to first measurement
await navigateWithViewportArrow(viewportPageObject, 'prev');
await expectRowSelected(rightPanelPageObject.measurementsPanel.panel.nthMeasurement(0));
await expect(viewportPageObject.active.svg('circle')).toBeVisible();
const firstAnnotation = viewportPageObject.active.nthAnnotation(0);
await expect(firstAnnotation.locator).toBeVisible();
await expect(firstAnnotation.text.locator).toBeVisible();
});
test('should keep arrows visible and functional after clicking measurement in right panel', async ({
page,
DOMOverlayPageObject,
leftPanelPageObject,
rightPanelPageObject,
viewportPageObject,
}) => {
await rightPanelPageObject.toggle();
await rightPanelPageObject.measurementsPanel.select();
await leftPanelPageObject.loadSeriesByModality('SR');
await page.waitForTimeout(2000);
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
await page.waitForTimeout(2000);
const measurementCount = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
expect(measurementCount).toBeGreaterThan(1);
// click on first measurement
await rightPanelPageObject.measurementsPanel.panel.nthMeasurement(0).click();
await expect(viewportPageObject.active.nthAnnotation(0).locator).toBeVisible();
await expect(viewportPageObject.active.navigationArrows.next.button).toBeVisible();
await expect(viewportPageObject.active.navigationArrows.prev.button).toBeVisible();
// navigate to second measurement
await navigateWithViewportArrow(viewportPageObject, 'next');
await expectRowSelected(rightPanelPageObject.measurementsPanel.panel.nthMeasurement(1));
await expect(viewportPageObject.active.nthAnnotation(0).locator).toBeVisible();
// navigate back to first measurement
await navigateWithViewportArrow(viewportPageObject, 'prev');
await expectRowSelected(rightPanelPageObject.measurementsPanel.panel.nthMeasurement(0));
await expect(viewportPageObject.active.nthAnnotation(0).locator).toBeVisible();
});