feat: Add WSI bulk data annotations support (ANN) (#4972)

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
Igor OctavianoandBill Wallace authored and GitHub committed 2025-08-20 08:13:30 -04:00
1 parent 0164ad5519
commit 2ed6818171
29 files changed
+461 -265

No files matched your search

@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.32.5",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/adapters": "^3.32.12",
"@cornerstonejs/core": "^3.32.12",
"@kitware/vtk.js": "32.12.0",
"react-color": "^2.19.3"
}
@@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.32.5",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/adapters": "^3.32.12",
"@cornerstonejs/core": "^3.32.12",
"@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.32.5",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/tools": "^3.32.5",
"@cornerstonejs/adapters": "^3.32.12",
"@cornerstonejs/core": "^3.32.12",
"@cornerstonejs/tools": "^3.32.12",
"classnames": "^2.3.2"
}
}
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/tools": "^3.32.5",
"@cornerstonejs/core": "^3.32.12",
"@cornerstonejs/tools": "^3.32.12",
"classnames": "^2.3.2"
}
}
+7 -7
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.32.5",
"@cornerstonejs/dicom-image-loader": "^3.32.12",
"@ohif/core": "3.12.0-beta.7",
"@ohif/ui": "3.12.0-beta.7",
"dcmjs": "0.43.1",
@@ -51,12 +51,12 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^3.32.5",
"@cornerstonejs/ai": "^3.32.5",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/labelmap-interpolation": "^3.32.5",
"@cornerstonejs/polymorphic-segmentation": "^3.32.5",
"@cornerstonejs/tools": "^3.32.5",
"@cornerstonejs/adapters": "^3.32.12",
"@cornerstonejs/ai": "^3.32.12",
"@cornerstonejs/core": "^3.32.12",
"@cornerstonejs/labelmap-interpolation": "^3.32.12",
"@cornerstonejs/polymorphic-segmentation": "^3.32.12",
"@cornerstonejs/tools": "^3.32.12",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "32.12.0",
"html2canvas": "^1.4.1",
+4 -1
View File
@@ -55,6 +55,10 @@ export default async function init({
extensionManager,
appConfig,
}: withAppTypes): Promise<void> {
// Use a public library path of PUBLIC_URL plus the component name
// This safely separates components that are loaded as-is.
window.PUBLIC_LIB_URL ||= './${component}/';
// Note: this should run first before initializing the cornerstone
// DO NOT CHANGE THE ORDER
@@ -195,7 +199,6 @@ export default async function init({
commandsManager.runCommand('jumpToMeasurementViewport', { measurement, annotationUID, evt });
});
// When a custom image load is performed, update the relevant viewports
hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
@@ -0,0 +1,113 @@
import OHIF, { DicomMetadataStore } from '@ohif/core';
import loadAnnotation from './utils/loadAnnotation';
import getSourceDisplaySet from './utils/getSourceDisplaySet';
const { utils } = OHIF;
const SOP_CLASS_UIDS = {
MICROSCOPY_BULK_SIMPLE_ANNOTATION: '1.2.840.10008.5.1.4.1.1.91.1',
};
const SOPClassHandlerId =
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopyANNSopClassHandler';
function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const { displaySetService, microscopyService } = servicesManager.services;
// Sort instances by date/time in ascending order (oldest first)
const sortedInstances = [...instances].sort((a, b) => {
const dateA = `${a.ContentDate}${a.ContentTime}`;
const dateB = `${b.ContentDate}${b.ContentTime}`;
return dateA.localeCompare(dateB);
});
// Get the most recent instance (last in the sorted array)
const instance = sortedInstances[sortedInstances.length - 1];
const naturalizedDataset = DicomMetadataStore.getSeries(
instance.StudyInstanceUID,
instance.SeriesInstanceUID
).instances[0];
const {
SeriesDescription,
ContentDate,
ContentTime,
SeriesNumber,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SOPClassUID,
} = instance;
const displaySet = {
isOverlayDisplaySet: true,
plugin: 'microscopy',
Modality: 'ANN',
thumbnailSrc: null,
altImageText: 'Microscopy Annotation',
displaySetInstanceUID: utils.uuidv4(),
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription,
// Map the content date/time to the series date/time, these are only used for filtering.
SeriesDate: ContentDate,
SeriesTime: ContentTime,
SeriesNumber,
instance,
metadata: naturalizedDataset,
isDerived: true,
isLoading: false,
isLoaded: false,
loadError: false,
};
displaySet.load = function () {
return loadAnnotation({
microscopyService,
displaySet,
extensionManager,
servicesManager,
}).catch(error => {
displaySet.isLoaded = false;
displaySet.loadError = true;
throw new Error(error);
});
};
displaySet.getSourceDisplaySet = function () {
let allDisplaySets = [];
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
studyMetadata.series.forEach(series => {
const displaySets = displaySetService.getDisplaySetsForSeries(series.SeriesInstanceUID);
allDisplaySets = allDisplaySets.concat(displaySets);
});
const ds = getSourceDisplaySet(allDisplaySets, displaySet);
return ds;
};
return [displaySet];
}
export default function getDicomMicroscopyANNSopClassHandler({
servicesManager,
extensionManager,
}) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return {
name: 'DicomMicroscopyANNSopClassHandler',
sopClassUids: [SOP_CLASS_UIDS.MICROSCOPY_BULK_SIMPLE_ANNOTATION],
getDisplaySetsFromSeries,
};
}
@@ -62,6 +62,7 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager)
} = instance;
const displaySet = {
isOverlayDisplaySet: true,
plugin: 'microscopy',
Modality: 'SR',
altImageText: 'Microscopy SR',
@@ -172,7 +172,7 @@ function DicomMicroscopyViewport({
microscopyService.clearAnnotations();
let smDisplaySet = displaySet;
if (displaySet.Modality === 'SR') {
if (displaySet.isOverlayDisplaySet) {
// for SR displaySet, let's load the actual image displaySet
smDisplaySet = displaySet.getSourceDisplaySet();
}
@@ -180,7 +180,7 @@ function DicomMicroscopyViewport({
await loadViewer(smDisplaySet.others);
if (displaySet.Modality === 'SR') {
if (displaySet.isOverlayDisplaySet && !displaySet.isLoaded && !displaySet.isLoading) {
displaySet.load(smDisplaySet);
}
},
@@ -205,8 +205,8 @@ function DicomMicroscopyViewport({
microscopyService.clearAnnotations();
// loading SR
if (displaySet.Modality === 'SR') {
// loading SR - only if not already loaded and not currently loading
if (displaySet.isOverlayDisplaySet && !displaySet.isLoaded && !displaySet.isLoading) {
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
+6 -2
View File
@@ -7,6 +7,7 @@ import { Types } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui-next';
import getDicomMicroscopySRSopClassHandler from './DicomMicroscopySRSopClassHandler';
import getDicomMicroscopyANNSopClassHandler from './DicomMicroscopyANNSopClassHandler';
import MicroscopyService from './services/MicroscopyService';
import { useResizeDetector } from 'react-resize-detector';
import debounce from 'lodash.debounce';
@@ -43,7 +44,7 @@ const extension: Types.Extensions.Extension = {
* {name, component} object. Example of a viewport module is the CornerstoneViewport
* that is provided by the Cornerstone extension in OHIF.
*/
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
getViewportModule({ servicesManager }) {
/**
*
* @param props {*}
@@ -150,7 +151,10 @@ const extension: Types.Extensions.Extension = {
* Examples include the default sop class handler provided by the default extension
*/
getSopClassHandlerModule(params) {
return [getDicomMicroscopySRSopClassHandler(params)];
return [
getDicomMicroscopySRSopClassHandler(params),
getDicomMicroscopyANNSopClassHandler(params),
];
},
getPanelModule,
@@ -22,7 +22,7 @@ export default class MicroscopyService extends PubSubService {
return {
name: 'microscopyService',
altName: 'MicroscopyService',
create: (props) => {
create: props => {
return new MicroscopyService(props);
},
};
@@ -71,7 +71,7 @@ export default class MicroscopyService extends PubSubService {
}
public importDicomMicroscopyViewer(): Promise<any> {
return this.peerImport("dicom-microscopy-viewer");
return this.peerImport('dicom-microscopy-viewer');
}
/**
@@ -260,6 +260,11 @@ export default class MicroscopyService extends PubSubService {
return Array.from(this.managedViewers).filter(filter);
}
getManagedViewersForViewport(viewportId) {
const filter = managedViewer => managedViewer.viewportId === viewportId;
return Array.from(this.managedViewers).filter(filter);
}
/**
* Restores the created annotations for the viewer being added
*
@@ -290,6 +295,13 @@ export default class MicroscopyService extends PubSubService {
* @returns {ViewerManager} managed viewer
*/
addViewer(viewer, viewportId, container, studyInstanceUID, seriesInstanceUID) {
// Check if a viewer already exists for this viewportId
const existingViewer = Array.from(this.managedViewers).find(mv => mv.viewportId === viewportId);
if (existingViewer) {
// If a viewer exists, remove it first
this.removeViewer(existingViewer.viewer);
}
const managedViewer = new ViewerManager(
viewer,
viewportId,
@@ -355,6 +367,10 @@ export default class MicroscopyService extends PubSubService {
}
});
if (recentDisplaySet.isLoading) {
return;
}
recentDisplaySet.isLoading = true;
recentDisplaySet.load(smDisplaySet);
@@ -9,6 +9,9 @@ const DCM_CODE_VALUES = {
SHORT_AXIS: 'G-A186',
LONG_AXIS: 'G-A185',
ELLIPSE_AREA: 'G-D7FE', // TODO: Remove this
ANNOTATION: '121071',
ANNOTATION_GROUP: '121072',
ANNOTATION_LABEL: '121073',
};
export default DCM_CODE_VALUES;
@@ -6,7 +6,15 @@
* @returns
*/
export default function getSourceDisplaySet(allDisplaySets, microscopySRDisplaySet) {
const { ReferencedFrameOfReferenceUID } = microscopySRDisplaySet;
const { ReferencedFrameOfReferenceUID, metadata } = microscopySRDisplaySet;
if (metadata.ReferencedSeriesSequence) {
const { ReferencedSeriesSequence } = metadata;
const referencedSeries = ReferencedSeriesSequence[0];
const { SeriesInstanceUID } = referencedSeries;
const displaySets = allDisplaySets.filter(ds => ds.SeriesInstanceUID === SeriesInstanceUID);
return displaySets[0];
}
const otherDisplaySets = allDisplaySets.filter(
ds => ds.displaySetInstanceUID !== microscopySRDisplaySet.displaySetInstanceUID
@@ -0,0 +1,95 @@
import getDicomWebClient from './dicomWebClient';
/**
* Loads and displays DICOM Microscopy Bulk Simple Annotations.
*
* This utility function:
* 1. Retrieves series metadata from a DICOMweb server using study and series instance UIDs
* 2. Converts metadata into MicroscopyBulkSimpleAnnotations objects
* 3. Adds annotations to the viewer in groups (identified by AnnotationGroupUID)
* 4. Applies a consistent yellow color ([255, 234, 0]) to all annotation groups
* 5. Makes the annotation groups visible in the viewer
*
* @param {Object} params - The parameters object
* @param {Object} params.microscopyService - Service for handling microscopy operations
* @param {Object} params.displaySet - The display set containing metadata
* @param {Object} params.extensionManager - Manager for extensions
* @param {Object} params.servicesManager - Manager for services
* @returns {Promise} A promise that resolves with the loaded display set
*/
export default function loadAnnotation({
microscopyService,
displaySet,
extensionManager,
servicesManager,
}) {
const { uiNotificationService } = servicesManager.services;
return new Promise(async (resolve, reject) => {
try {
displaySet.isLoading = true;
const { metadata } = displaySet;
const dicomMicroscopyModule = await microscopyService.importDicomMicroscopyViewer();
const client = getDicomWebClient({
extensionManager,
servicesManager,
});
const viewportId = servicesManager.services.viewportGridService.getActiveViewportId();
const managedViewers = microscopyService.getManagedViewersForViewport(viewportId);
const managedViewer = managedViewers[0];
client
.retrieveSeriesMetadata({
studyInstanceUID: metadata.StudyInstanceUID,
seriesInstanceUID: metadata.SeriesInstanceUID,
})
.then(async retrievedMetadata => {
const annotations = retrievedMetadata.map(
metadata =>
new dicomMicroscopyModule.metadata.MicroscopyBulkSimpleAnnotations({ metadata })
);
uiNotificationService.show({
message: 'Loading annotations...',
type: 'info',
});
await Promise.all(
annotations.map(async ann => {
try {
await managedViewer.viewer.addAnnotationGroups(ann);
ann.AnnotationGroupSequence.forEach(item => {
const annotationGroupUID = item.AnnotationGroupUID;
managedViewer.viewer.setAnnotationGroupStyle(annotationGroupUID, {
color: [255, 234, 0],
});
});
ann.AnnotationGroupSequence.forEach(item => {
const annotationGroupUID = item.AnnotationGroupUID;
managedViewer.viewer.showAnnotationGroup(annotationGroupUID);
});
} catch (error) {
console.error('failed to add annotation groups:', error);
uiNotificationService.show({
title: 'Error loading annotations',
message: error.message,
type: 'error',
});
}
})
);
displaySet.isLoaded = true;
displaySet.isLoading = false;
resolve(displaySet);
});
} catch (error) {
console.error('Error loading annotation:', error);
reject(error);
}
});
}
+2 -2
View File
@@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/tools": "^3.32.5",
"@cornerstonejs/core": "^3.32.12",
"@cornerstonejs/tools": "^3.32.12",
"@ohif/core": "3.12.0-beta.7",
"@ohif/extension-cornerstone-dicom-sr": "3.12.0-beta.7",
"@ohif/extension-default": "3.12.0-beta.7",
@@ -24,7 +24,7 @@ export default function PanelStudyBrowserTracking({
const checkDirtyMeasurements = displaySetInstanceUID => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet.Modality === 'SR') {
if (displaySet.Modality === 'SR' || displaySet.Modality === 'ANN') {
const activeViewportId = viewportGridService.getActiveViewportId();
sendTrackedMeasurementsEvent('CHECK_DIRTY', {
viewportId: activeViewportId,
+2 -2
View File
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^3.32.5",
"@cornerstonejs/tools": "^3.32.5",
"@cornerstonejs/core": "^3.32.12",
"@cornerstonejs/tools": "^3.32.12",
"@ohif/core": "3.12.0-beta.7",
"@ohif/extension-cornerstone": "3.12.0-beta.7",
"@ohif/extension-default": "3.12.0-beta.7",