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:
1 parent
dc61d87238
commit
69d8e6a191
24 files changed
+457
-172
No files matched your search
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user