fix(SR): When loading DICOM SR, only one measurement is shown with no way to show others (#3228)

* fix: Make the cornerstone sR viewport show all measurements

* PR fixes

* PR fixes

* Add a DICOM SR hanging protocol

* Duplicate the hanging protocol for seg as well

* PR requested change

* PR requested changes

* PR fixes plus merge update fixes

* PR fixes and integration test fix

* PR - documentation
This commit is contained in:
Bill Wallace authored and GitHub committed 2023-04-25 17:25:10 -04:00
1 parent dc61d87238
commit 69d8e6a191
24 files changed
+457 -172

No files matched your search

@@ -53,6 +53,7 @@ function _getDisplaySetsFromSeries(
segments: {},
sopClassUids,
instance,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
@@ -3,7 +3,7 @@ import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getSopClassHandlerModule, { protocols } from './getSopClassHandlerModule';
import PanelSegmentation from './panels/PanelSegmentation';
import getHangingProtocolModule from './getHangingProtocolModule';
@@ -37,7 +37,7 @@ const extension = {
* iconName, iconLabel, label, component} object. Example of a panel module
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
*/
getPanelModule: ({ servicesManager, commandsManager, extensionManager }): Types.Panel[] => {
getPanelModule: ({ servicesManager, commandsManager, extensionManager }: Types.Extensions.ExtensionParams): Types.Panel[] => {
const wrappedPanelSegmentation = () => {
return (
<PanelSegmentation
@@ -58,6 +58,7 @@ const extension = {
},
];
},
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
return (
@@ -85,3 +86,7 @@ const extension = {
};
export default extension;
// Export the protocols separately to allow for extending it at compile time
// in other modules
export { protocols };
+2 -2
View File
@@ -46,7 +46,7 @@
"@babel/runtime": "^7.20.13",
"classnames": "^2.3.2",
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/tools": "^0.60.1"
"@cornerstonejs/core": "^0.42.2",
"@cornerstonejs/tools": "^0.61.11"
}
}
@@ -1,6 +1,6 @@
import { metaData, utilities } from '@cornerstonejs/core';
import OHIF from '@ohif/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters';
@@ -41,7 +41,6 @@ const _generateReport = (
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
return dataset;
};
@@ -104,7 +103,18 @@ const commandsModule = ({}) => {
additionalFindingTypes,
options
);
const { StudyInstanceUID } = naturalizedReport;
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
// The content sequence has 5 or more elements, of which
// the `[4]` element contains the annotation data, so this is
// checking that there is some annotation data present.
if (!ContentSequence?.[4].ContentSequence?.length) {
console.log(
'naturalizedReport missing imaging content',
naturalizedReport
);
throw new Error('Invalid report, no content');
}
await dataSource.store.dicom(naturalizedReport);
@@ -112,6 +122,11 @@ const commandsModule = ({}) => {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.warn(error);
@@ -1,12 +1,15 @@
import { SOPClassHandlerName, SOPClassHandlerId } from './id';
import { utils, classes } from '@ohif/core';
import { utils, classes, DisplaySetService, Types } from '@ohif/core';
import addMeasurement from './utils/addMeasurement';
import isRehydratable from './utils/isRehydratable';
import { adaptersSR } from '@cornerstonejs/adapters';
type InstanceMetadata = Types.InstanceMetadata;
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
const { ImageSet, MetadataProvider: metadataProvider } = classes;
// TODO ->
// Add SR thumbnail
// Make viewport
@@ -22,6 +25,17 @@ const sopClassUids = [
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
const validateSameStudyUID = (uid: string, instances): void => {
instances.forEach(it => {
if (it.StudyInstanceUID !== uid) {
console.warn('Not all instances have the same UID', uid, it);
throw new Error(
`Instances ${it.SOPInstanceUID} does not belong to ${uid}`
);
}
});
};
const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
@@ -50,15 +64,38 @@ const RELATIONSHIP_TYPE = {
const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';
/**
* Adds instances to the DICOM SR series, rather than creating a new
* series, so that as SR's are saved, they append to the series, and the
* key image display set gets updated as well, containing just the new series.
* @param instances is a list of instances from THIS series that are not
* in this DICOM SR Display Set already.
*/
function addInstances(
instances: InstanceMetadata[],
displaySetService: DisplaySetService
) {
this.instances.push(...instances);
utils.sortStudyInstances(this.instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
this.instance = this.instances[this.instances.length - 1];
this.isLoaded = false;
if (this.keyImageDisplaySet) {
this.load();
this.keyImageDisplaySet.updateInstances();
displaySetService.setDisplaySetMetadataInvalidated(
this.keyImageDisplaySet.displaySetInstanceUID
);
}
return this;
}
/**
* DICOM SR SOP Class Handler
* For all referenced images in the TID 1500/300 sections, add an image to the
* display (this is TODO - it is not the actual behaviour below unfortunately)
*
* This will only display and rehydrate the latest DICOM SR in the given series
* It would be possible to add the ability to view older series rehydrations
* in the future.
*
* display.
* @param instances is a set of instances all from the same series
* @param servicesManager is the services that can be used for creating
* @returns The list of display sets created for the given instances object
@@ -74,6 +111,9 @@ function _getDisplaySetsFromSeries(
}
utils.sortStudyInstances(instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
const instance = instances[instances.length - 1];
const {
@@ -86,11 +126,12 @@ function _getDisplaySetsFromSeries(
ConceptNameCodeSequence,
SOPClassUID,
} = instance;
validateSameStudyUID(instance.StudyInstanceUID, instances);
if (
!ConceptNameCodeSequence ||
ConceptNameCodeSequence.CodeValue !==
CodeNameCodeSequenceValues.ImagingMeasurementReport
CodeNameCodeSequenceValues.ImagingMeasurementReport
) {
console.log(
'Only support Imaging Measurement Report SRs (TID1500) for this renderer.'
@@ -111,14 +152,13 @@ function _getDisplaySetsFromSeries(
SOPClassHandlerId,
SOPClassUID,
instances,
// Others is a historical value used for instances which is deprecated and will be removed
others: instances,
referencedImages: null,
measurements: null,
isDerivedDisplaySet: true,
isLoaded: false,
sopClassUids,
instance,
addInstances,
};
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
@@ -327,7 +367,7 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
trackingUniqueIdentifier => {
const mergedContentSequence =
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
trackingUniqueIdentifier
];
const measurement = _processMeasurement(mergedContentSequence);
@@ -367,7 +407,7 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(
if (
mergedContentSequencesByTrackingUniqueIdentifiers[
trackingUniqueIdentifier
trackingUniqueIdentifier
] === undefined
) {
// Add the full ContentSequence
@@ -473,18 +513,18 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
CodeNameCodeSequenceValues.TrackingIdentifier
);
const Finding = mergedContentSequence.find(
const finding = mergedContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.Finding
);
const FindingSites = mergedContentSequence.filter(
const findingSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator ===
CodingSchemeDesignators.SRT &&
CodingSchemeDesignators.SRT &&
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.FindingSite
CodeNameCodeSequenceValues.FindingSite
);
const measurement = {
@@ -496,28 +536,28 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
};
if (
Finding &&
finding &&
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
Finding.ConceptCodeSequence.CodingSchemeDesignator
finding.ConceptCodeSequence.CodingSchemeDesignator
) &&
Finding.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
finding.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
) {
measurement.labels.push({
label: CORNERSTONE_FREETEXT_CODE_VALUE,
value: Finding.ConceptCodeSequence.CodeMeaning,
value: finding.ConceptCodeSequence.CodeMeaning,
});
}
// TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now.
if (FindingSites.length) {
const cornerstoneFreeTextFindingSite = FindingSites.find(
if (findingSites.length) {
const cornerstoneFreeTextFindingSite = findingSites.find(
FindingSite =>
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
FindingSite.ConceptCodeSequence.CodingSchemeDesignator
) &&
FindingSite.ConceptCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.CornerstoneFreeText
CodeNameCodeSequenceValues.CornerstoneFreeText
);
if (cornerstoneFreeTextFindingSite) {
@@ -633,17 +673,16 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item;
if (!ReferencedSOPSequence) return;
for (const ref of _getSequenceAsArray(ReferencedSOPSequence)) {
if (ref.ReferencedSOPClassUID) {
const { ReferencedSOPClassUID, ReferencedSOPInstanceUID } = ref;
if (item.hasOwnProperty('ReferencedSOPClassUID')) {
const {
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
} = ReferencedSOPSequence;
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
}
}
});
@@ -651,6 +690,7 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
}
function _getSequenceAsArray(sequence) {
if (!sequence) return [];
return Array.isArray(sequence) ? sequence : [sequence];
}
@@ -9,6 +9,7 @@ import preRegistration from './init';
import { id } from './id.js';
import toolNames from './tools/toolNames';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
const Component = React.lazy(() => {
return import(
@@ -57,8 +58,6 @@ const dicomSRExtension = {
},
getCommandsModule,
getSopClassHandlerModule,
getHangingProtocolModule,
// Include dynmically computed values such as toolNames not known till instantiation
getUtilityModule({ servicesManager }) {
return [
@@ -75,4 +74,4 @@ const dicomSRExtension = {
export default dicomSRExtension;
// Put static exports here so they can be type checked
export { hydrateStructuredReport, srProtocol };
export { hydrateStructuredReport, createReferencedImageDisplaySet, srProtocol };
@@ -4,7 +4,7 @@ export default function onModeEnter({ servicesManager }) {
const { displaySetService } = servicesManager.services;
const displaySetCache = displaySetService.getDisplaySetCache();
const srDisplaySets = displaySetCache.filter(
const srDisplaySets = [...displaySetCache.values()].filter(
ds => ds.SOPClassHandlerId === SOPClassHandlerId
);
@@ -0,0 +1,89 @@
import { DisplaySetService, classes } from '@ohif/core';
const ImageSet = classes.ImageSet;
const findInstance = (measurement, displaySetService: DisplaySetService) => {
const {
displaySetInstanceUID,
ReferencedSOPInstanceUID: sopUid,
} = measurement;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
if (!referencedDisplaySet.images) return;
return referencedDisplaySet.images.find(it => it.SOPInstanceUID === sopUid);
};
/** Finds references to display sets inside the measurements
* contained within the provided display set.
* @return an array of instances referenced.
*/
const findReferencedInstances = (
displaySetService: DisplaySetService,
displaySet
) => {
const instances = [];
const instanceById = {};
for (const measurement of displaySet.measurements) {
const { imageId } = measurement;
if (!imageId) continue;
if (instanceById[imageId]) continue;
const instance = findInstance(measurement, displaySetService);
if (!instance) {
console.log('Measurement', measurement, 'had no instances found');
continue;
}
instanceById[imageId] = instance;
instances.push(instance);
}
return instances;
};
/**
* Creates a new display set containing a single image instance for each
* referenced image.
*
* @param displaySetService
* @param displaySet - containing measurements referencing images.
* @returns A new (registered/active) display set containing the referenced images
*/
const createReferencedImageDisplaySet = (displaySetService, displaySet) => {
const instances = findReferencedInstances(displaySetService, displaySet);
// This will be a member function of the created image set
const updateInstances = function() {
this.images.splice(
0,
this.images.length,
...findReferencedInstances(displaySetService, displaySet)
);
this.numImageFrames = this.images.length;
};
const imageSet = new ImageSet(instances);
const instance = instances[0];
imageSet.setAttributes({
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
SeriesInstanceUID: imageSet.uid,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: `${displaySet.SeriesDescription} KO ${displaySet.instance.SeriesNumber}`,
Modality: 'KO',
isMultiFrame: false,
numImageFrames: instances.length,
SOPClassHandlerId: `@ohif/extension-default.sopClassHandlerModule.stack`,
isReconstructable: false,
madeInClient: true,
updateInstances,
});
displaySetService.addDisplaySets(imageSet);
return imageSet;
};
export default createReferencedImageDisplaySet;
@@ -15,6 +15,7 @@ import {
ViewportActionBar,
} from '@ohif/ui';
import hydrateStructuredReport from '../utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet';
const { formatDate } = utils;
@@ -59,7 +60,6 @@ function OHIFCornerstoneSRViewport(props) {
referencedDisplaySetMetadata,
setReferencedDisplaySetMetadata,
] = useState(null);
const [isHydrated, setIsHydrated] = useState(srDisplaySet.isHydrated);
const [element, setElement] = useState(null);
const { viewports, activeViewportIndex } = viewportGrid;
@@ -89,9 +89,9 @@ function OHIFCornerstoneSRViewport(props) {
{ servicesManager, extensionManager },
displaySetInstanceUID
);
const displaySets = displaySetService.getDisplaySetsForSeries(
SeriesInstanceUIDs[0]
);
const displaySets = srDisplaySet.keyImageDisplaySet
? [srDisplaySet.keyImageDisplaySet]
: displaySetService.getDisplaySetsForSeries(SeriesInstanceUIDs[0]);
if (displaySets.length) {
viewportGridService.setDisplaySetsForViewports([
{
@@ -293,8 +293,6 @@ function OHIFCornerstoneSRViewport(props) {
if (!srDisplaySet.isLoaded) {
srDisplaySet.load();
}
setIsHydrated(srDisplaySet.isHydrated);
const numMeasurements = srDisplaySet.measurements.length;
setMeasurementCount(numMeasurements);
}, [srDisplaySet]);
@@ -382,6 +380,7 @@ function OHIFCornerstoneSRViewport(props) {
label: viewportLabel,
useAltStyling: true,
studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber,
seriesDescription: SeriesDescription || '',
patientInformation: {
patientName: PatientName
@@ -424,6 +423,7 @@ OHIFCornerstoneSRViewport.propTypes = {
viewportIndex: PropTypes.number.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
customProps: PropTypes.object,
viewportOptions: PropTypes.object,
viewportLabel: PropTypes.string,
@@ -440,14 +440,18 @@ async function _getViewportReferencedDisplaySetData(
measurementSelected,
displaySetService
) {
const { measurements } = displaySet;
const measurement = measurements[measurementSelected];
if (!displaySet.keyImageDisplaySet) {
// Create a new display set, and preserve a reference to it here,
// so that it can be re-displayed and shown inside the SR viewport.
// This is only for ease of redisplay - the display set is stored in the
// usual manner in the display set service.
displaySet.keyImageDisplaySet = createReferencedImageDisplaySet(
displaySetService,
displaySet
);
}
const { displaySetInstanceUID } = measurement;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
const referencedDisplaySet = displaySet.keyImageDisplaySet;
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
+2 -2
View File
@@ -49,9 +49,9 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/core": "^0.42.2",
"@cornerstonejs/streaming-image-volume-loader": "^0.16.0",
"@cornerstonejs/tools": "^0.60.1",
"@cornerstonejs/tools": "^0.61.11",
"@kitware/vtk.js": "26.5.6",
"html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8",
@@ -550,6 +550,7 @@ function _checkForCachedJumpToMeasurementEvents(
const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID
);
if (!displaysUIDs?.length) return;
const measurementIdToJumpTo = measurementService.getJumpToMeasurement(
viewportIndex
@@ -561,7 +562,7 @@ function _checkForCachedJumpToMeasurementEvents(
measurementIdToJumpTo
);
if (displaysUIDs.includes(measurement.displaySetInstanceUID)) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(
measurement,
elementRef,
+1 -1
View File
@@ -93,7 +93,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
return (
<OHIFCornerstoneViewport
{...props}
ToolbarService={toolbarService}
toolbarService={toolbarService}
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
@@ -1,5 +1,10 @@
import { ServicesManager, Types } from '@ohif/core';
import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core';
import {
cache as cs3DCache,
Enums,
volumeLoader,
utilities as utils,
} from '@cornerstonejs/core';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import {
@@ -11,14 +16,14 @@ const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
class CornerstoneCacheService {
static REGISTRATION = {
name: 'cornerstoneCacheService',
altName: 'CornerstoneCacheService',
name: 'cornerstoneCacheService',
altName: 'CornerstoneCacheService',
create: ({
servicesManager,
}: Types.Extensions.ExtensionParams): CornerstoneCacheService => {
return new CornerstoneCacheService(servicesManager);
},
};
},
};
stackImageIds: Map<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = new Map();
@@ -91,7 +96,10 @@ class CornerstoneCacheService {
displaySetService
) {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
throw new Error('Invalidation of StackViewport is not supported yet');
return this._getCornerstoneStackImageIds(
displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID),
dataSource
);
}
// Todo: grab the volume and get the id from the viewport itself
@@ -203,6 +203,10 @@ class ViewportInfo {
orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
}
if (!toolGroupId) {
toolGroupId = DEFAULT_TOOLGROUP_ID;
}
this.setViewportOptions({
...viewportOptionsEntry,
viewportId: this.viewportId,
@@ -228,7 +232,8 @@ class ViewportInfo {
}
public getSyncGroups(): SyncGroup[] {
return this.viewportOptions.syncGroups || [];
this.viewportOptions.syncGroups ||= [];
return this.viewportOptions.syncGroups;
}
public getDisplaySetOptions(): Array<DisplaySetOptions> {
@@ -40,7 +40,7 @@ export default function findSRWithSameSeriesDescription(
SeriesTime,
SeriesNumber,
Modality,
InstanceNumber: sameSeries.others.length + 1,
InstanceNumber: sameSeries.instances.length + 1,
};
}
@@ -50,7 +50,7 @@ const _getDisplaySetsFromSeries = (
referencedImages: null,
measurements: null,
pdfUrl,
others: [instance],
instances: [instance],
thumbnailSrc: dataSource.retrieve.directURL({
instance,
defaultPath: '/thumbnail',
@@ -70,7 +70,7 @@ const _getDisplaySetsFromSeries = (
singlepart: 'video',
tag: 'PixelData',
}),
others: [instance],
instances: [instance],
thumbnailSrc: dataSource.retrieve.directURL({
instance,
defaultPath: '/thumbnail',
+2 -2
View File
@@ -32,8 +32,8 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"classnames": "^2.3.2",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/tools": "^0.60.1",
"@cornerstonejs/core": "^0.42.2",
"@cornerstonejs/tools": "^0.61.11",
"@ohif/extension-cornerstone-dicom-sr": "^3.0.0",
"dcmjs": "^0.29.5",
"lodash.debounce": "^4.17.21",