feat: Add WSI bulk data annotations support (ANN) (#4972)
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user