diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js index 774294332..de9212d84 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -53,6 +53,7 @@ function _getDisplaySetsFromSeries( segments: {}, sopClassUids, instance, + instances: [instance], wadoRoot, wadoUriRoot, wadoUri, diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index b77fb6677..22a6ec491 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -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 ( { 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 }; diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index a566c0c0d..2dc6b9edc 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -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" } } diff --git a/extensions/cornerstone-dicom-sr/src/commandsModule.js b/extensions/cornerstone-dicom-sr/src/commandsModule.js index 7b685ed44..f8c9459e4 100644 --- a/extensions/cornerstone-dicom-sr/src/commandsModule.js +++ b/extensions/cornerstone-dicom-sr/src/commandsModule.js @@ -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); diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts index 54a810a8d..a52234182 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts @@ -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]; } diff --git a/extensions/cornerstone-dicom-sr/src/index.tsx b/extensions/cornerstone-dicom-sr/src/index.tsx index 53d1217e7..9b87ea98c 100644 --- a/extensions/cornerstone-dicom-sr/src/index.tsx +++ b/extensions/cornerstone-dicom-sr/src/index.tsx @@ -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 }; diff --git a/extensions/cornerstone-dicom-sr/src/onModeEnter.js b/extensions/cornerstone-dicom-sr/src/onModeEnter.js index 61b6985ef..fc4b1efce 100644 --- a/extensions/cornerstone-dicom-sr/src/onModeEnter.js +++ b/extensions/cornerstone-dicom-sr/src/onModeEnter.js @@ -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 ); diff --git a/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts b/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts new file mode 100644 index 000000000..0abff2b3c --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/utils/createReferencedImageDisplaySet.ts @@ -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; diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index c3db19b8c..75b34893f 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -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 = { diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 55a5b76d9..526074be1 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -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", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 0a9d8ac89..f3e061900 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -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, diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 165d70cd9..29387df19 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -93,7 +93,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { return ( diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 7eb2d6751..447dfd654 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -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 = new Map(); volumeImageIds: Map = 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 diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 4f43aff23..c56af0836 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -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 { diff --git a/extensions/default/src/utils/findSRWithSameSeriesDescription.ts b/extensions/default/src/utils/findSRWithSameSeriesDescription.ts index 476343fb5..d50c76976 100644 --- a/extensions/default/src/utils/findSRWithSameSeriesDescription.ts +++ b/extensions/default/src/utils/findSRWithSameSeriesDescription.ts @@ -40,7 +40,7 @@ export default function findSRWithSameSeriesDescription( SeriesTime, SeriesNumber, Modality, - InstanceNumber: sameSeries.others.length + 1, + InstanceNumber: sameSeries.instances.length + 1, }; } diff --git a/extensions/dicom-pdf/src/getSopClassHandlerModule.js b/extensions/dicom-pdf/src/getSopClassHandlerModule.js index 949edfd42..99a9bae80 100644 --- a/extensions/dicom-pdf/src/getSopClassHandlerModule.js +++ b/extensions/dicom-pdf/src/getSopClassHandlerModule.js @@ -50,7 +50,7 @@ const _getDisplaySetsFromSeries = ( referencedImages: null, measurements: null, pdfUrl, - others: [instance], + instances: [instance], thumbnailSrc: dataSource.retrieve.directURL({ instance, defaultPath: '/thumbnail', diff --git a/extensions/dicom-video/src/getSopClassHandlerModule.js b/extensions/dicom-video/src/getSopClassHandlerModule.js index 31aebed9b..4be185e74 100644 --- a/extensions/dicom-video/src/getSopClassHandlerModule.js +++ b/extensions/dicom-video/src/getSopClassHandlerModule.js @@ -70,7 +70,7 @@ const _getDisplaySetsFromSeries = ( singlepart: 'video', tag: 'PixelData', }), - others: [instance], + instances: [instance], thumbnailSrc: dataSource.retrieve.directURL({ instance, defaultPath: '/thumbnail', diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 59ab9e3b9..b07b17d2e 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -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", diff --git a/platform/core/src/classes/MetadataProvider.js b/platform/core/src/classes/MetadataProvider.js index 32f482e74..02bf2b4e8 100644 --- a/platform/core/src/classes/MetadataProvider.js +++ b/platform/core/src/classes/MetadataProvider.js @@ -441,6 +441,7 @@ class MetadataProvider { } getUIDsFromImageID(imageId) { + if (!imageId) throw new Error('MetadataProvider::Empty imageId'); // TODO: adding csiv here is not really correct. Probably need to use // metadataProvider.addImageIdToUIDs(imageId, { // StudyInstanceUID, diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.ts b/platform/core/src/services/DisplaySetService/DisplaySetService.ts index 552e7936c..b77322b2c 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.ts +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.ts @@ -1,31 +1,40 @@ +import { InstanceMetadata } from '../../types'; import { PubSubService } from '../_shared/pubSubServiceInterface'; import EVENTS from './EVENTS'; -const displaySetCache = []; - -/** - * Find an instance in a list of instances, comparing by SOP instance UID - */ -const findInSet = (instance, list) => { - if (!list) return false; - for (const elem of list) { - if (!elem) continue; - if (elem === instance) return true; - if (elem.SOPInstanceUID === instance.SOPInstanceUID) return true; - } - return false; +export type DisplaySet = { + displaySetInstanceUID: string; + instances: InstanceMetadata[]; + StudyInstanceUID: string; + SeriesInstanceUID?: string; + numImages?: number; }; +const displaySetCache = new Map(); + /** - * Find an instance in a display set - * @returns true if found + * Filters the instances set by instances not in + * display sets. Done in O(n) time. */ -const findInstance = (instance, displaySets) => { - for (const displayset of displaySets) { - if (findInSet(instance, displayset.images)) return true; - if (findInSet(instance, displayset.others)) return true; - } - return false; +const filterInstances = ( + instances: InstanceMetadata[], + displaySets: DisplaySet[] +): InstanceMetadata[] => { + const dsInstancesSOP = new Set(); + displaySets.forEach(ds => { + const dsInstances = ds.instances; + if (!dsInstances) { + console.warn('No instances in', ds); + } else { + dsInstances.forEach(instance => + dsInstancesSOP.add(instance.SOPInstanceUID) + ); + } + }); + + return instances.filter( + instance => !dsInstancesSOP.has(instance.SOPInstanceUID) + ); }; export default class DisplaySetService extends PubSubService { @@ -38,6 +47,13 @@ export default class DisplaySetService extends PubSubService { }; public activeDisplaySets = []; + + protected activeDisplaySetsMap = new Map(); + + // Record if the active display sets changed - used to group change events so + // that fewer events need to be fired when creating multiple display sets + protected activeDisplaySetsChanged = false; + constructor() { super(EVENTS); } @@ -46,64 +62,85 @@ export default class DisplaySetService extends PubSubService { this.extensionManager = extensionManager; this.SOPClassHandlerIds = SOPClassHandlerIds; this.activeDisplaySets = []; + this.activeDisplaySetsMap.clear(); } - _addDisplaySetsToCache(displaySets) { + _addDisplaySetsToCache(displaySets: DisplaySet[]) { displaySets.forEach(displaySet => { - displaySetCache.push(displaySet); + displaySetCache.set(displaySet.displaySetInstanceUID, displaySet); }); } - _addActiveDisplaySets(displaySets) { - const activeDisplaySets = this.activeDisplaySets; + _addActiveDisplaySets(displaySets: DisplaySet[]) { + const { activeDisplaySets, activeDisplaySetsMap } = this; displaySets.forEach(displaySet => { - // This test makes adding display sets an N^2 operation, so it might - // become important to do this in an efficient manner for large - // numbers of display sets. - if (!activeDisplaySets.includes(displaySet)) { + if (!activeDisplaySetsMap.has(displaySet.displaySetInstanceUID)) { + this.activeDisplaySetsChanged = true; activeDisplaySets.push(displaySet); + activeDisplaySetsMap.set(displaySet.displayInstanceUID, displaySet); } }); } - getDisplaySetCache() { + /** + * Adds new display sets directly, as specified. + * Use this function when the display sets are created externally directly + * rather than using the default sop class handlers to create display sets. + */ + public addDisplaySets(...displaySets: DisplaySet[]): string[] { + this._addDisplaySetsToCache(displaySets); + this._addActiveDisplaySets(displaySets); + + // The activeDisplaySetsChanged flag is only seen if we add display sets + // so, don't broadcast the change if all the display sets were pre-existing. + this.activeDisplaySetsChanged = false; + this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, { + displaySetsAdded: displaySets, + options: { madeInClient: displaySets[0].madeInClient }, + }); + return displaySets; + } + + public getDisplaySetCache(): Map { return displaySetCache; } - getMostRecentDisplaySet() { + public getMostRecentDisplaySet(): DisplaySet { return this.activeDisplaySets[this.activeDisplaySets.length - 1]; } - getActiveDisplaySets() { + public getActiveDisplaySets(): DisplaySet[] { return this.activeDisplaySets; } - getDisplaySetsForSeries = SeriesInstanceUID => { - return displaySetCache.filter( - displaySet => displaySet.SeriesInstanceUID === SeriesInstanceUID + public getDisplaySetsForSeries = ( + seriesInstanceUID: string + ): DisplaySet[] => { + return [...displaySetCache.values()].filter( + displaySet => displaySet.SeriesInstanceUID === seriesInstanceUID ); }; - getDisplaySetForSOPInstanceUID( - SOPInstanceUID, - SeriesInstanceUID, - frameNumber - ) { - const displaySets = SeriesInstanceUID - ? this.getDisplaySetsForSeries(SeriesInstanceUID) - : this.getDisplaySetCache(); + public getDisplaySetForSOPInstanceUID( + sopInstanceUID: string, + seriesInstanceUID: string, + frameNumber?: number + ): DisplaySet { + const displaySets = seriesInstanceUID + ? this.getDisplaySetsForSeries(seriesInstanceUID) + : [...this.getDisplaySetCache().values()]; const displaySet = displaySets.find(ds => { return ( - ds.images && ds.images.some(i => i.SOPInstanceUID === SOPInstanceUID) + ds.images && ds.images.some(i => i.SOPInstanceUID === sopInstanceUID) ); }); return displaySet; } - setDisplaySetMetadataInvalidated(displaySetInstanceUID) { + public setDisplaySetMetadataInvalidated(displaySetInstanceUID: string): void { const displaySet = this.getDisplaySetByUID(displaySetInstanceUID); if (!displaySet) { @@ -117,19 +154,17 @@ export default class DisplaySetService extends PubSubService { ); } - deleteDisplaySet(displaySetInstanceUID) { - const { activeDisplaySets } = this; - - const displaySetCacheIndex = displaySetCache.findIndex( - ds => ds.displaySetInstanceUID === displaySetInstanceUID - ); + public deleteDisplaySet(displaySetInstanceUID) { + if (!displaySetInstanceUID) return; + const { activeDisplaySets, activeDisplaySetsMap } = this; const activeDisplaySetsIndex = activeDisplaySets.findIndex( ds => ds.displaySetInstanceUID === displaySetInstanceUID ); - displaySetCache.splice(displaySetCacheIndex, 1); + displaySetCache.delete(displaySetInstanceUID); activeDisplaySets.splice(activeDisplaySetsIndex, 1); + activeDisplaySetsMap.delete(displaySetInstanceUID); this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets); this._broadcastEvent(EVENTS.DISPLAY_SETS_REMOVED, { @@ -141,10 +176,8 @@ export default class DisplaySetService extends PubSubService { * @param {string} displaySetInstanceUID * @returns {object} displaySet */ - getDisplaySetByUID = displaySetInstanceUid => - displaySetCache.find( - displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUid - ); + public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => + displaySetCache.get(displaySetInstanceUid); /** * @@ -167,7 +200,7 @@ export default class DisplaySetService extends PubSubService { } // If array of instances => One instance. - let displaySetsAdded = []; + const displaySetsAdded = []; if (batch) { for (let i = 0; i < input.length; i++) { @@ -177,12 +210,12 @@ export default class DisplaySetService extends PubSubService { settings ); - displaySetsAdded = [...displaySetsAdded, displaySets]; + displaySetsAdded.push(...displaySets); } } else { const displaySets = this.makeDisplaySetForInstances(input, settings); - displaySetsAdded = displaySets; + displaySetsAdded.push(...displaySets); } const options = {}; @@ -191,10 +224,13 @@ export default class DisplaySetService extends PubSubService { options.madeInClient = true; } - // TODO: This is tricky. How do we know we're not resetting to the same/existing DSs? - // TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent unnecessary broadcasts? - if (displaySetsAdded && displaySetsAdded.length) { + if (this.activeDisplaySetsChanged) { + this.activeDisplaySetsChanged = false; this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets); + } + if (displaySetsAdded?.length) { + // The response from displaySetsAdded will only contain newly added + // display sets. this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, { displaySetsAdded, options, @@ -210,22 +246,45 @@ export default class DisplaySetService extends PubSubService { * the mode specific onModeExit is called before this method and should * store the active display sets and the cached data. */ - onModeExit() { - this.getDisplaySetCache().length = 0; + public onModeExit(): void { + this.getDisplaySetCache().clear(); this.activeDisplaySets.length = 0; + this.activeDisplaySetsMap.clear(); } - makeDisplaySetForInstances(instancesSrc, settings) { - let instances = instancesSrc; + /** + * Creates new display sets for the instances contained in instancesSrc + * according to the sop class handlers registered. + * This is idempotent in that calling it a second time with the + * same set of instances will not result in new display sets added. + * However, the response for the subsequent call will be empty as the data + * is already present. + * Calling it with some new instances and some existing instances will + * result in the new instances being added to existing display sets if + * they support the addInstances call, OR to new instances otherwise. + * Only the new instances are returned - the others are updated. + * + * @param instancesSrc are instances to add + * @param settings are settings to add + * @returns Array of the display sets added. + */ + public makeDisplaySetForInstances( + instancesSrc: InstanceMetadata[], + settings + ): DisplaySet[] { + // Some of the sop class handlers take a direct reference to instances + // so make sure it gets copied here so that they have their own ref + let instances = [...instancesSrc]; const instance = instances[0]; const existingDisplaySets = this.getDisplaySetsForSeries(instance.SeriesInstanceUID) || []; const SOPClassHandlerIds = this.SOPClassHandlerIds; - let allDisplaySets; + const allDisplaySets = []; - for (let i = 0; i < SOPClassHandlerIds.length; i++) { + // Iterate over the sop class handlers while there are still instances to add + for (let i = 0; i < SOPClassHandlerIds.length && instances.length; i++) { const SOPClassHandlerId = SOPClassHandlerIds[i]; const handler = this.extensionManager.getModuleEntry(SOPClassHandlerId); @@ -236,34 +295,81 @@ export default class DisplaySetService extends PubSubService { ); if (displaySets.length) { - this._addActiveDisplaySets(displaySets); - } else { - displaySets = handler.getDisplaySetsFromSeries(instances); + // This case occurs when there are already display sets, so remove + // any instances in existing display sets. + instances = filterInstances(instances, displaySets); + // See if an existing display set can add this instance to it, + // for example, if it is a new image to be added to the existing set + for (const ds of displaySets) { + const addedDs = ds.addInstances?.(instances, this); + if (addedDs) { + this.activeDisplaySetsChanged = true; + instances = filterInstances(instances, [addedDs]); + this._addActiveDisplaySets([addedDs]); + this.setDisplaySetMetadataInvalidated( + addedDs.displaySetInstanceUID + ); + } + // This means that all instances already existed or got added to + // existing display sets, and had an invalidated event fired + if (!instances.length) return allDisplaySets; + } - if (!displaySets || !displaySets.length) continue; - - // applying hp-defined viewport settings to the displaysets - displaySets.forEach(ds => { - Object.keys(settings).forEach(key => { - ds[key] = settings[key]; - }); - }); - - this._addDisplaySetsToCache(displaySets); - this._addActiveDisplaySets(displaySets); - - instances = instances.filter( - instance => !findInstance(instance, displaySets) - ); + if (!instances.length) { + // Everything is already added - this is just an update caused + // by something else + this._addActiveDisplaySets(displaySets); + return allDisplaySets; + } } - allDisplaySets = allDisplaySets - ? [...allDisplaySets, ...displaySets] - : displaySets; + // The instances array still contains some instances, so try + // creating additional display sets using the sop class handler + displaySets = handler.getDisplaySetsFromSeries(instances); - if (!instances.length) return allDisplaySets; + if (!displaySets || !displaySets.length) continue; + + // applying hp-defined viewport settings to the displaysets + displaySets.forEach(ds => { + Object.keys(settings).forEach(key => { + ds[key] = settings[key]; + }); + }); + + this._addDisplaySetsToCache(displaySets); + this._addActiveDisplaySets(displaySets); + + // It is possible that this SOP class handler handled some instances + // but there may need to be other instances handled by other handlers, + // so remove the handled instances + instances = filterInstances(instances, displaySets); + + allDisplaySets.push(...displaySets); } } return allDisplaySets; } + + /** + * Iterates over displaysets and invokes comparator for each element. + * It returns a list of items that has being succeed by comparator method. + * + * @param comparator - method to be used on the validation + * @returns list of displaysets + */ + public getDisplaySetsBy(comparator: (DisplaySet) => boolean): DisplaySet[] { + const result = []; + + if (typeof comparator !== 'function') { + throw new Error(`The comparator ${comparator} was not a function`); + } + + this.getActiveDisplaySets().forEach(displaySet => { + if (comparator(displaySet)) { + result.push(displaySet); + } + }); + + return result; + } } diff --git a/platform/core/src/services/HangingProtocolService/HPMatcher.js b/platform/core/src/services/HangingProtocolService/HPMatcher.js index a369f2460..b87c31e84 100644 --- a/platform/core/src/services/HangingProtocolService/HPMatcher.js +++ b/platform/core/src/services/HangingProtocolService/HPMatcher.js @@ -32,7 +32,7 @@ const match = ( // Allow for matching against current or prior specifically const prior = options?.studies?.[1]; const current = options?.studies?.[0]; - const instance = (metadataInstance.images || metadataInstance.others)?.[0]; + const instance = metadataInstance.instances?.[0]; const fromSrc = { prior, current, diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index 25df9b028..089a994f5 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -2,6 +2,7 @@ import * as Extensions from '../extensions/ExtensionManager'; import * as HangingProtocol from './HangingProtocol'; import Services from './Services'; import Hotkey from '../classes/Hotkey'; +import { DisplaySet } from '../services/DisplaySetService/DisplaySetService'; export * from '../services/CustomizationService/types'; // Separate out some generic types @@ -16,4 +17,4 @@ export * from './IPubSub'; * Export the types used within the various services and managers, but * not the services/managers themselves, which are exported at the top level. */ -export { Extensions, HangingProtocol, Services, Hotkey }; +export { Extensions, HangingProtocol, Services, Hotkey, DisplaySet }; diff --git a/platform/viewer/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js b/platform/viewer/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js index 95acd97de..d380d9544 100644 --- a/platform/viewer/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js +++ b/platform/viewer/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js @@ -59,7 +59,9 @@ describe('OHIF Measurement Panel', function() { cy.get('@viewportInfoTopRight').should('contains.text', '(14/'); // Click on first measurement item - cy.get('[data-cy="measurement-item"]').click(); + cy.get('[data-cy="measurement-item"]') + .eq(0) + .click(); cy.get('@viewportInfoTopRight').should('contains.text', '(1/'); cy.get('@viewportInfoTopRight').should('not.contains.text', '(14/'); diff --git a/yarn.lock b/yarn.lock index 5611ea7a2..113a1eb55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1451,6 +1451,14 @@ detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" +"@cornerstonejs/core@^0.42.2": + version "0.42.2" + resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.42.2.tgz#be69adbfd7b63718316a4f18fa55ca473506cee5" + integrity sha512-sLRqCOzR8cxBtQel9fltRti/rQRiYs1Wgx/CaYRPXlWID9Gj9os4j2PJHa70OiMIp4shMsF3jyVW7BPK0t6ZJQ== + dependencies: + detect-gpu "^4.0.45" + lodash.clonedeep "4.5.0" + "@cornerstonejs/streaming-image-volume-loader@^0.16.0": version "0.16.0" resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.16.0.tgz#513f868c285963fd2f2dbf54ed7840a2dc05e33a" @@ -1459,12 +1467,12 @@ "@cornerstonejs/core" "^0.40.0" cornerstone-wado-image-loader "^4.10.2" -"@cornerstonejs/tools@^0.60.1": - version "0.60.1" - resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.60.1.tgz#88cf32fe79c7fb714a99c1a63c25822233a4d231" - integrity sha512-0GsxN8INh8/1/uKH4pU1ZT8XLNn4Xi2GJug9CQBT6plfnppru//+P+xl+LEQbYVpsydD1iYoWzLe8ykPuIeQCA== +"@cornerstonejs/tools@^0.61.11": + version "0.61.11" + resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-0.61.11.tgz#dacd85967cd6ab22c27dd44c10b14664863631f9" + integrity sha512-TCUde2gmuyiyd0EXhoT4DhDZBHYy82MOiekGQe+IG24um3a7GNGJ7jOKBk7fVFxJCTAI2Cb6XI8K79h6BhuWdg== dependencies: - "@cornerstonejs/core" "^0.40.0" + "@cornerstonejs/core" "^0.42.2" lodash.clonedeep "4.5.0" lodash.get "^4.4.2"