From e1d366e1e176d52dbaf3bb7d03fe4ea829bfe2ac Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 7 Sep 2022 11:59:11 -0400 Subject: [PATCH] feat: make hanging protocol work on displaySets instead of series (#2837) * feat(HP): Apply HP to display sets, fix race condition * fix: displaySetService no event not needed (#2912) * fix various styles and renamings * feat: refactored hp service * fix: use HP service event for viewport grid * remove unnecessary doc * fix test * apply review comment * fix: segmentation creation Co-authored-by: Alireza --- .../CornerstoneViewportService.ts | 32 +- .../src/services/ViewportService/Viewport.ts | 2 - .../src/utils/interleaveCenterLoader.ts | 6 +- .../src/utils/interleaveTopToBottom.ts | 2 +- .../default/src/DicomWebDataSource/index.js | 13 +- .../default/src/getHangingProtocolModule.js | 2 + extensions/tmtv/src/Panels/PanelPetSUV.tsx | 57 +- extensions/tmtv/src/commandsModule.js | 39 +- jest.config.base.js | 4 - modes/tmtv/src/index.js | 8 +- .../DicomMetadataStore/DicomMetadataStore.js | 7 + .../DicomMetadataStore/StudyMetadata.ts | 7 + .../DicomMetadataStore/createStudyMetadata.js | 12 + .../services/DisplaySetService/IDisplaySet.ts | 7 + .../HangingProtocolService/HPMatcher.js | 47 +- .../HangingProtocolService.test.js | 166 +++++ ...olService.js => HangingProtocolService.ts} | 367 +++++++--- .../HangingProtocolService/ProtocolEngine.js | 634 +----------------- .../HangingProtocolService/lib/validator.js | 73 +- .../lib/validator.test.js | 49 ++ .../services/data/DisplaySetService.md | 36 +- .../services/data/DisplaySetService.md | 5 +- .../services/data/HangingProtocolService.md | 182 +++-- .../version-3.0/platform/services/pubsub.md | 2 +- .../viewer/src/components/ViewportGrid.tsx | 47 +- platform/viewer/src/routes/Mode/Mode.tsx | 66 +- 26 files changed, 893 insertions(+), 979 deletions(-) create mode 100644 platform/core/src/services/DicomMetadataStore/StudyMetadata.ts create mode 100644 platform/core/src/services/DisplaySetService/IDisplaySet.ts create mode 100644 platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js rename platform/core/src/services/HangingProtocolService/{HangingProtocolService.js => HangingProtocolService.ts} (58%) create mode 100644 platform/core/src/services/HangingProtocolService/lib/validator.test.js diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 89ee22f4e..85c6a4a55 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -39,6 +39,8 @@ class CornerstoneViewportService implements IViewportService { renderingEngine: Types.IRenderingEngine | null; viewportsInfo: Map; viewportGridResizeObserver: ResizeObserver | null; + // TODO - get the right type here. + hangingProtocolService: object; /** * Service-specific @@ -59,7 +61,7 @@ class CornerstoneViewportService implements IViewportService { this.listeners = {}; this.EVENTS = EVENTS; const { HangingProtocolService } = servicesManager.services; - this.HangingProtocolService = HangingProtocolService; + this.hangingProtocolService = HangingProtocolService; Object.assign(this, pubSubServiceInterface); // } @@ -166,12 +168,14 @@ class CornerstoneViewportService implements IViewportService { const viewportInfo = this.viewportsInfo.get(viewportIndex); viewportInfo.setRenderingEngineId(renderingEngine.id); - const { viewportOptions, displaySetOptions } = - this._getViewportAndDisplaySetOptions( - publicViewportOptions, - publicDisplaySetOptions, - viewportInfo - ); + const { + viewportOptions, + displaySetOptions, + } = this._getViewportAndDisplaySetOptions( + publicViewportOptions, + publicDisplaySetOptions, + viewportInfo + ); viewportInfo.setViewportOptions(viewportOptions); viewportInfo.setDisplaySetOptions(displaySetOptions); @@ -265,7 +269,6 @@ class CornerstoneViewportService implements IViewportService { viewportInfo: ViewportInfo ) { const displaySetOptions = viewportInfo.getDisplaySetOptions(); - const { imageIds, initialImageIndex } = viewportData; let initialImageIndexToUse = initialImageIndex; @@ -352,7 +355,7 @@ class CornerstoneViewportService implements IViewportService { // (This call may or may not create sub-requests for series metadata) const volumeInputArray = []; const displaySetOptionsArray = viewportInfo.getDisplaySetOptions(); - const { HangingProtocolService } = this; + const { hangingProtocolService } = this; for (let i = 0; i < viewportData.imageIds.length; i++) { const imageIds = viewportData.imageIds[i]; @@ -384,11 +387,11 @@ class CornerstoneViewportService implements IViewportService { } if ( - HangingProtocolService.hasCustomImageLoadStrategy() && - !HangingProtocolService.customImageLoadPerformed + hangingProtocolService.hasCustomImageLoadStrategy() && + !hangingProtocolService.customImageLoadPerformed ) { // delegate the volume loading to the hanging protocol service if it has a custom image load strategy - return HangingProtocolService.runImageLoadStrategy({ + return hangingProtocolService.runImageLoadStrategy({ viewportId: viewport.id, volumeInputArray, }); @@ -415,8 +418,9 @@ class CornerstoneViewportService implements IViewportService { ) { const { index, preset } = initialImageOptions; - const { numberOfSlices } = - csUtils.getImageSliceDataForVolumeViewport(viewport); + const { numberOfSlices } = csUtils.getImageSliceDataForVolumeViewport( + viewport + ); const imageIndex = this._getInitialImageIndex( numberOfSlices, diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index e6e30c83d..ecc1a21bb 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -16,7 +16,6 @@ export type ViewportOptions = { viewportId: string; orientation?: Types.Orientation; background?: Types.Point3; - initialView?: string; syncGroups?: SyncGroup[]; initialImageOptions?: InitialImageOptions; customViewportProps?: Record; @@ -28,7 +27,6 @@ export type PublicViewportOptions = { viewportId?: string; orientation?: string; background?: Types.Point3; - initialView?: string; syncGroups?: SyncGroup[]; initialImageOptions?: InitialImageOptions; customViewportProps?: Record; diff --git a/extensions/cornerstone/src/utils/interleaveCenterLoader.ts b/extensions/cornerstone/src/utils/interleaveCenterLoader.ts index dcf19ffd7..d0ea08fbc 100644 --- a/extensions/cornerstone/src/utils/interleaveCenterLoader.ts +++ b/extensions/cornerstone/src/utils/interleaveCenterLoader.ts @@ -8,8 +8,8 @@ const viewportIdVolumeInputArrayMap = new Map(); /** * This function caches the volumeUIDs until all the volumes inside the - * hangging protocol are initialized. Then it goes through the imageIds - * of the volumes, and interleav them, in order for the volumes to be loaded + * hanging protocol are initialized. Then it goes through the imageIds + * of the volumes, and interleave them, in order for the volumes to be loaded * together from middle to the start and the end. * @param {Object} props image loading properties from Cornerstone ViewportService * @returns @@ -17,7 +17,7 @@ const viewportIdVolumeInputArrayMap = new Map(); export default function interleaveCenterLoader({ data: { viewportId, volumeInputArray }, displaySetsMatchDetails, - matchDetails, + viewportMatchDetails: matchDetails, }) { viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray); diff --git a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts index 2c7df60bc..1f1bed3f0 100644 --- a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts +++ b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts @@ -16,7 +16,7 @@ const viewportIdVolumeInputArrayMap = new Map(); export default function interleaveTopToBottom({ data: { viewportId, volumeInputArray }, displaySetsMatchDetails, - matchDetails, + viewportMatchDetails: matchDetails, }) { viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray); diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index cecd1c8cc..98e9a19a6 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -473,12 +473,13 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) { DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); - const numberOfSeries = seriesPromises.length; - seriesPromises.forEach(async (seriesPromise, index) => { - const instances = await seriesPromise; - storeInstances(instances); - if (index === numberOfSeries - 1) setSuccessFlag(); - }); + const seriesDeliveredPromises = seriesPromises.map(promise => + promise.then(instances => { + storeInstances(instances); + }) + ); + await Promise.all(seriesDeliveredPromises); + setSuccessFlag(); }, deleteStudyMetadataPromise, getImageIdsForDisplaySet(displaySet) { diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index c85fac71a..0d3d6b1a8 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -23,7 +23,9 @@ const defaultProtocol = { displaySets: [ { id: 'displaySet', + // Unused currently imageMatchingRules: [], + // Matches displaysets, NOT series seriesMatchingRules: [], studyMatchingRules: [], }, diff --git a/extensions/tmtv/src/Panels/PanelPetSUV.tsx b/extensions/tmtv/src/Panels/PanelPetSUV.tsx index 3e6f102fe..0666f90a9 100644 --- a/extensions/tmtv/src/Panels/PanelPetSUV.tsx +++ b/extensions/tmtv/src/Panels/PanelPetSUV.tsx @@ -28,32 +28,32 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) { DisplaySetService, ToolGroupService, ToolBarService, + HangingProtocolService, } = servicesManager.services; const [metadata, setMetadata] = useState(DEFAULT_MEATADATA); const [ptDisplaySet, setPtDisplaySet] = useState(null); - const handleMetadataChange = useCallback( - metadata => { - setMetadata(prevState => { - const newState = { ...prevState }; - Object.keys(metadata).forEach(key => { - if (typeof metadata[key] === 'object') { - newState[key] = { - ...prevState[key], - ...metadata[key], - }; - } else { - newState[key] = metadata[key]; - } - }); - return newState; + const handleMetadataChange = metadata => { + setMetadata(prevState => { + const newState = { ...prevState }; + Object.keys(metadata).forEach(key => { + if (typeof metadata[key] === 'object') { + newState[key] = { + ...prevState[key], + ...metadata[key], + }; + } else { + newState[key] = metadata[key]; + } }); - }, - [metadata] - ); + return newState; + }); + }; - const getMatchingPTDisplaySet = useCallback(() => { - const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet'); + const getMatchingPTDisplaySet = viewportMatchDetails => { + const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet', { + viewportMatchDetails, + }); if (!ptDisplaySet) { return; @@ -67,16 +67,16 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) { ptDisplaySet, metadata, }; - }, []); + }; useEffect(() => { - const displaySets = DisplaySetService.activeDisplaySets; - + const displaySets = DisplaySetService.getActiveDisplaySets(); + const { viewportMatchDetails } = HangingProtocolService.getMatchDetails(); if (!displaySets.length) { return; } - const displaySetInfo = getMatchingPTDisplaySet(); + const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails); if (!displaySetInfo) { return; @@ -89,15 +89,14 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) { // get the patientMetadata from the StudyInstanceUIDs and update the state useEffect(() => { - const { unsubscribe } = DisplaySetService.subscribe( - DisplaySetService.EVENTS.DISPLAY_SETS_ADDED, - () => { - const displaySetInfo = getMatchingPTDisplaySet(); + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.PROTOCOL_CHANGED, + ({ viewportMatchDetails }) => { + const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails); if (!displaySetInfo) { return; } - const { ptDisplaySet, metadata } = displaySetInfo; setPtDisplaySet(ptDisplaySet); setMetadata(metadata); diff --git a/extensions/tmtv/src/commandsModule.js b/extensions/tmtv/src/commandsModule.js index e2633cca8..28abe8709 100644 --- a/extensions/tmtv/src/commandsModule.js +++ b/extensions/tmtv/src/commandsModule.js @@ -41,9 +41,9 @@ const commandsModule = ({ } function _getMatchedViewportsToolGroupIds() { - const [matchedViewports] = HangingProtocolService.getState(); + const { viewportMatchDetails } = HangingProtocolService.getMatchDetails(); const toolGroupIds = []; - matchedViewports.forEach(({ viewportOptions }) => { + viewportMatchDetails.forEach(({ viewportOptions }) => { const { toolGroupId } = viewportOptions; if (toolGroupIds.indexOf(toolGroupId) === -1) { toolGroupIds.push(toolGroupId); @@ -54,33 +54,30 @@ const commandsModule = ({ } const actions = { - getMatchingPTDisplaySet: () => { + getMatchingPTDisplaySet: ({ viewportMatchDetails }) => { // Todo: this is assuming that the hanging protocol has successfully matched // the correct PT. For future, we should have a way to filter out the PTs // that are in the viewer layout (but then we have the problem of the attenuation // corrected PT vs the non-attenuation correct PT) - const matches = HangingProtocolService.getDisplaySetsMatchDetails(); - - const matchedSeriesInstanceUIDs = Array.from(matches.values()).map( - ({ SeriesInstanceUID }) => SeriesInstanceUID - ); let ptDisplaySet = null; - for (const SeriesInstanceUID of matchedSeriesInstanceUIDs) { - const displaySets = DisplaySetService.getDisplaySetsForSeries( - SeriesInstanceUID + for (const matched of viewportMatchDetails) { + const { displaySetsInfo } = matched; + const displaySets = displaySetsInfo.map(({ displaySetInstanceUID }) => + DisplaySetService.getDisplaySetByUID(displaySetInstanceUID) ); if (!displaySets || displaySets.length === 0) { continue; } - const displaySet = displaySets[0]; - if (displaySet.Modality !== 'PT') { - continue; - } + ptDisplaySet = displaySets.find( + displaySet => displaySet.Modality === 'PT' + ); - ptDisplaySet = displaySet; + if (ptDisplaySet) { + break; + } } return ptDisplaySet; @@ -121,7 +118,10 @@ const commandsModule = ({ createNewLabelmapFromPT: async () => { // Create a segmentation of the same resolution as the source data // using volumeLoader.createAndCacheDerivedVolume. - const ptDisplaySet = actions.getMatchingPTDisplaySet(); + const { viewportMatchDetails } = HangingProtocolService.getMatchDetails(); + const ptDisplaySet = actions.getMatchingPTDisplaySet({ + viewportMatchDetails, + }); if (!ptDisplaySet) { UINotificationService.error('No matching PT display set found'); @@ -565,8 +565,11 @@ const commandsModule = ({ }, setFusionPTColormap: ({ toolGroupId, colormap }) => { const toolGroup = ToolGroupService.getToolGroup(toolGroupId); + const { viewportMatchDetails } = HangingProtocolService.getMatchDetails(); - const ptDisplaySet = actions.getMatchingPTDisplaySet(); + const ptDisplaySet = actions.getMatchingPTDisplaySet({ + viewportMatchDetails, + }); if (!ptDisplaySet) { return; diff --git a/jest.config.base.js b/jest.config.base.js index b9c22e271..afb3bdd26 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -6,10 +6,6 @@ module.exports = { verbose: true, roots: ['/src'], - transform: { - '^.+\\.js$': 'babel-jest', - '^.+\\.jsx$': 'babel-jest', - }, testMatch: ['/src/**/*.test.js'], testPathIgnorePatterns: ['/node_modules/'], moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index 734020e9e..f77422e94 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -100,17 +100,19 @@ function modeFactory({ modeConfiguration }) { // For fusion toolGroup we need to add the volumeIds for the crosshairs // since in the fusion viewport we don't want both PT and CT to render MIP // when slabThickness is modified - const matches = HangingProtocolService.getDisplaySetsMatchDetails(); + const { + displaySetMatchDetails, + } = HangingProtocolService.getMatchDetails(); setCrosshairsConfiguration( - matches, + displaySetMatchDetails, toolNames, ToolGroupService, DisplaySetService ); setFusionActiveVolume( - matches, + displaySetMatchDetails, toolNames, ToolGroupService, DisplaySetService diff --git a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js index 5380dd726..bf1aab1b6 100644 --- a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js +++ b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js @@ -211,7 +211,14 @@ const BaseImplementation = { let study = _getStudy(StudyInstanceUID); if (!study) { study = createStudyMetadata(StudyInstanceUID); + // Will typically be undefined with a compliant DICOMweb server, reset later study.StudyDescription = seriesSummaryMetadata[0].StudyDescription; + seriesSummaryMetadata.forEach(item => { + if (study.ModalitiesInStudy.indexOf(item.Modality) === -1) { + study.ModalitiesInStudy.push(item.Modality); + } + }); + study.NumberOfStudyRelatedSeries = seriesSummaryMetadata.length; _model.studies.push(study); } diff --git a/platform/core/src/services/DicomMetadataStore/StudyMetadata.ts b/platform/core/src/services/DicomMetadataStore/StudyMetadata.ts new file mode 100644 index 000000000..c391d09b0 --- /dev/null +++ b/platform/core/src/services/DicomMetadataStore/StudyMetadata.ts @@ -0,0 +1,7 @@ +/** Defines a typescript type for study metadata */ +interface StudyMetadata { + readonly StudyInstanceUID: string; + StudyDescription?: string; +} + +export default StudyMetadata; diff --git a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js index bb72dc140..ce59aff66 100644 --- a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js +++ b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js @@ -4,6 +4,7 @@ function createStudyMetadata(StudyInstanceUID) { return { StudyInstanceUID, StudyDescription: '', + ModalitiesInStudy: [], isLoaded: false, series: [], /** @@ -13,6 +14,9 @@ function createStudyMetadata(StudyInstanceUID) { */ addInstanceToSeries: function(instance) { const { SeriesInstanceUID } = instance; + if (!this.StudyDescription) { + this.StudyDescription = instance.StudyDescription; + } const existingSeries = this.series.find( s => s.SeriesInstanceUID === SeriesInstanceUID ); @@ -22,16 +26,24 @@ function createStudyMetadata(StudyInstanceUID) { } else { const series = createSeriesMetadata([instance]); this.series.push(series); + const { Modality } = series; + if (this.ModalitiesInStudy.indexof(Modality) === -1) { + this.ModalitiesInStudy.push(Modality); + } } }, /** * * @param {object[]} instances * @param {string} instances[].SeriesInstanceUID + * @param {string} instances[].StudyDescription * @returns {bool} true if series were added; false if series already exist */ addInstancesToSeries: function(instances) { const { SeriesInstanceUID } = instances[0]; + if (!this.StudyDescription) { + this.StudyDescription = instances[0].StudyDescription; + } const existingSeries = this.series.find( s => s.SeriesInstanceUID === SeriesInstanceUID ); diff --git a/platform/core/src/services/DisplaySetService/IDisplaySet.ts b/platform/core/src/services/DisplaySetService/IDisplaySet.ts new file mode 100644 index 000000000..3d7dc2252 --- /dev/null +++ b/platform/core/src/services/DisplaySetService/IDisplaySet.ts @@ -0,0 +1,7 @@ +interface IDisplaySet { + displaySetInstanceUID: string; + StudyInstanceUID: string; + SeriesInstanceUID?: string; +} + +export default IDisplaySet; diff --git a/platform/core/src/services/HangingProtocolService/HPMatcher.js b/platform/core/src/services/HangingProtocolService/HPMatcher.js index 08b66daaf..48a552973 100644 --- a/platform/core/src/services/HangingProtocolService/HPMatcher.js +++ b/platform/core/src/services/HangingProtocolService/HPMatcher.js @@ -4,10 +4,18 @@ import validate from './lib/validator'; * Match a Metadata instance against rules using Validate.js for validation. * @param {InstanceMetadata} metadataInstance Metadata instance object * @param {Array} rules Array of MatchingRules instances (StudyMatchingRule|SeriesMatchingRule|ImageMatchingRule) for the match + * @param {object} options is an object containing additional information + * @param {object[]} options.studies is a list of all the studies + * @param {object[]} options.displaySets is a list of the display sets * @return {Object} Matching Object with score and details (which rule passed or failed) */ -const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { - const options = { +const match = ( + metadataInstance, + rules, + customAttributeRetrievalCallbacks, + options +) => { + const validateOptions = { format: 'grouped', }; @@ -16,18 +24,32 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { failed: [], }; + const readValues = {}; + let requiredFailed = false; let score = 0; rules.forEach(rule => { - const attribute = rule.attribute; - + const { attribute } = rule; // Do not use the custom attribute from the metadataInstance since it is subject to change if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) { - const customAttribute = customAttributeRetrievalCallbacks[attribute]; - metadataInstance[attribute] = customAttribute.callback(metadataInstance); + readValues[attribute] = customAttributeRetrievalCallbacks[ + attribute + ].callback(metadataInstance, options); + } else { + readValues[attribute] = + metadataInstance[attribute] ?? + ((metadataInstance.images || metadataInstance.others || [])[0] || {})[ + attribute + ]; } + console.log( + 'Test', + attribute, + readValues[attribute], + JSON.stringify(rule.constraint) + ); // Format the constraint as required by Validate.js const testConstraint = { [attribute]: rule.constraint, @@ -35,13 +57,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { // Create a single attribute object to be validated, since metadataInstance is an // instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata) - let attributeValue = metadataInstance[attribute]; - if (attributeValue === undefined) { - if (attribute === 'NumberOfStudyRelatedSeries') { - attributeValue = metadataInstance.series?.length; - } - // Add other computable values such as modalities in study - } + let attributeValue = readValues[attribute]; const attributeMap = { [attribute]: attributeValue, }; @@ -49,7 +65,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { // Use Validate.js to evaluate the constraints on the specified metadataInstance let errorMessages; try { - errorMessages = validate(attributeMap, testConstraint, [options]); + errorMessages = validate(attributeMap, testConstraint, [validateOptions]); } catch (e) { errorMessages = ['Something went wrong during validation.', e]; } @@ -58,8 +74,7 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { // If no errorMessages were returned, then validation passed. // Add the rule's weight to the total score - score += parseInt(rule.weight, 10); - + score += parseInt(rule.weight || 1, 10); // Log that this rule passed in the matching details object details.passed.push({ rule, diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js new file mode 100644 index 000000000..d39610f24 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js @@ -0,0 +1,166 @@ +import HangingProtocolServiceClass from './HangingProtocolService'; + +const testProtocol = { + id: 'test', + locked: true, + hasUpdatedPriorsInformation: false, + name: 'Default', + createdDate: '2021-02-23T19:22:08.894Z', + modifiedDate: '2021-02-23T19:22:08.894Z', + availableTo: {}, + editableBy: {}, + toolGroupIds: ['ctToolGroup', 'ptToolGroup'], + imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter" + protocolMatchingRules: [ + { + id: 'wauZK2QNEfDPwcAQo', + weight: 1, + attribute: 'StudyDescription', + constraint: { + contains: { + value: 'PETCT', + }, + }, + required: false, + }, + ], + stages: [ + { + id: 'hYbmMy3b7pz7GLiaT', + name: 'default', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + displaySets: [ + { + id: 'displaySet', + seriesMatchingRules: [ + { + weight: 1, + attribute: 'Modality', + constraint: { + equals: 'CT', + }, + required: true, + }, + { + weight: 1, + attribute: 'numImageFrames', + constraint: { + greaterThan: 10, + }, + }, + ], + studyMatchingRules: [], + }, + ], + viewports: [ + { + viewportOptions: { + viewportId: 'ctAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'ctToolGroup', + customViewportOptions: { + initialScale: 2.5, + }, + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'displaySet', + }, + ], + }, + ], + }, + ], + numberOfPriorsReferenced: -1, +}; + +const studyMatch = { + StudyInstanceUID: 'studyMatch', + StudyDescription: 'A PETCT study type', +}; + +const displaySet1 = { + ...studyMatch, + SeriesInstanceUID: 'ds1', + displaySetInstanceUID: 'displaySet1', + numImageFrames: 11, + Modality: 'CT', +}; + +const displaySet2 = { + ...displaySet1, + SeriesInstanceUID: 'ds2', + displaySetInstanceUID: 'displaySet2', + Modality: 'PT', +}; + +const displaySet3 = { + ...displaySet1, + numImageFrames: 3, + displaySetInstanceUID: 'displaySet3', +}; + +const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1]; + +describe('HangingProtocolService', () => { + const commandsManager = {}; + const hps = new HangingProtocolServiceClass(commandsManager); + let initialScaling; + + beforeAll(() => { + hps.addProtocols([testProtocol]); + }); + + it('has one protocol', () => { + expect(hps.getProtocols().length).toBe(1); + }); + + describe('run', () => { + it('matches best image match', () => { + hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets }); + const { + hpAlreadyApplied, + viewportMatchDetails, + displaySetMatchDetails, + } = hps.getMatchDetails(); + expect(hpAlreadyApplied).toMatchObject([false]); + expect(viewportMatchDetails.length).toBe(1); + expect(viewportMatchDetails[0]).toMatchObject({ + viewportOptions: { + viewportId: 'ctAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'ctToolGroup', + }, + // Matches ds1 because it matches 2 rules, a required and an optional + // ds2 fails to match required and ds3 fails to match an optional. + displaySetsInfo: [ + { + SeriesInstanceUID: 'ds1', + displaySetInstanceUID: 'displaySet1', + displaySetOptions: {}, + }, + ], + }); + }); + }); +}); diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.js b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts similarity index 58% rename from platform/core/src/services/HangingProtocolService/HangingProtocolService.js rename to platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 1af9cc05e..573ae0617 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.js +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1,53 +1,110 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; -import sortBy from '../../utils/sortBy.js'; +import sortBy from '../../utils/sortBy'; import ProtocolEngine from './ProtocolEngine'; +import StudyMetadata from '../DicomMetadataStore/StudyMetadata'; +import IDisplaySet from '../DisplaySetService/IDisplaySet'; const EVENTS = { STAGE_CHANGE: 'event::hanging_protocol_stage_change', + PROTOCOL_CHANGED: 'event::hanging_protocol_changed', NEW_LAYOUT: 'event::hanging_protocol_new_layout', CUSTOM_IMAGE_LOAD_PERFORMED: 'event::hanging_protocol_custom_image_load_performed', }; +type ViewportOptions = { + orientation: string; + toolGroupId: string; + viewportId: string; + viewportType: string; + initialImageOptions: Record; + syncGroups: Record; +}; + +type ViewportMatchDetails = { + viewportOptions: ViewportOptions; + displaySetsInfo: { + SeriesInstanceUID: string; + displaySetInstanceUID: string; + displaySetOptions: Record; + }; +}; + +type DisplaySetMatchDetails = { + SeriesInstanceUID: string; + StudyInstanceUID: string; + displaySetInstanceUID: string; + matchDetails: any; + matchingScore: number; + sortingInfo: any; +}; + +type HangingProtocolMatchDetails = { + displaySetMatchDetails: Map; + viewportMatchDetails: ViewportMatchDetails[]; + hpAlreadyApplied: boolean[]; +}; + class HangingProtocolService { + studies: StudyMetadata[]; + protocols: Record[]; + protocol: Record; + stage: number; + _commandsManager: Record; + protocolEngine: ProtocolEngine; + hpAlreadyApplied: boolean[] = []; + customViewportSettings = []; + displaySets: IDisplaySet[] = []; + activeStudy: Record; + debugLogging: false; + + customAttributeRetrievalCallbacks = { + NumberOfStudyRelatedSeries: { + name: 'The number of series in the study', + callback: metadata => + metadata.NumberOfStudyRelatedSeries ?? metadata.series?.length, + }, + NumberOfSeriesRelatedInstances: { + name: 'The number of instances in the display set', + callback: metadata => metadata.numImageFrames, + }, + ModalitiesInStudy: { + name: 'Gets the array of the modalities for the series', + callback: metadata => + metadata.ModalitiesInStudy ?? + (metadata.series || []).reduce((prev, curr) => { + const { Modality } = curr; + if (Modality && prev.indexOf(Modality) == -1) prev.push(Modality); + return prev; + }, []), + }, + }; + listeners = {}; + registeredImageLoadStrategies = {}; + activeImageLoadStrategyName = null; + customImageLoadPerformed = false; + + /** + * displaySetMatchDetails = + * DisplaySetId is the id defined in the hangingProtocol object itself + * and match is an object that contains information about + */ + displaySetMatchDetails: Map = new Map(); + + /** + * An array that contains for each viewport (viewportIndex) specified in the + * hanging protocol, an object of the form + */ + viewportMatchDetails = [] as ViewportMatchDetails[]; + constructor(commandsManager) { this._commandsManager = commandsManager; this.protocols = []; - this.ProtocolEngine = undefined; + this.protocolEngine = undefined; this.protocol = undefined; this.stage = undefined; - /** - * An array that contains for each viewport (viewportIndex) specified in the - * hanging protocol, an object of the form - * - * { - * viewportOptions, - * displaySetsInfo, // contains array of [ { SeriesInstanceUID, displaySetOPtions}, ... ] - * } - */ - this.matchDetails = []; - /** - * displaySetMatchDetails = - * DisplaySetId is the id defined in the hangingProtocol - * match is an object that contains information about - * - * { - * SeriesInstanceUID, - * StudyInstanceUID, - * matchDetails, - * matchingScore, - * sortingInfo - * } - */ - this.displaySetMatchDetails = new Map(); - this.hpAlreadyApplied = []; + this.studies = []; - this.customViewportSettings = []; - this.customAttributeRetrievalCallbacks = {}; - this.listeners = {}; - this.registeredImageLoadStrategies = {}; - this.activeImageLoadStrategyName = null; - this.customImageLoadPerformed = false; Object.defineProperty(this, 'EVENTS', { value: EVENTS, writable: false, @@ -57,27 +114,27 @@ class HangingProtocolService { Object.assign(this, pubSubServiceInterface); } - reset() { + public reset() { this.studies = []; this.protocols = []; this.hpAlreadyApplied = []; - this.matchDetails = []; + this.viewportMatchDetails = []; // this.ProtocolEngine.reset() } - getDisplaySetsMatchDetails() { - return this.displaySetMatchDetails; + public getMatchDetails(): HangingProtocolMatchDetails { + return { + viewportMatchDetails: this.viewportMatchDetails, + displaySetMatchDetails: this.displaySetMatchDetails, + hpAlreadyApplied: this.hpAlreadyApplied, + }; } - getState() { - return [this.matchDetails, this.hpAlreadyApplied]; - } - - getProtocols() { + public getProtocols() { return this.protocols; } - addProtocols(protocols) { + public addProtocols(protocols) { protocols.forEach(protocol => { if (this.protocols.indexOf(protocol) === -1) { this.protocols.push(this._validateProtocol(protocol)); @@ -85,21 +142,37 @@ class HangingProtocolService { }); } - run(studyMetaData, protocol) { - if (!this.studies.includes(studyMetaData)) { - this.studies.push(studyMetaData); - } - // copy here so we don't mutate it - const metaData = Object.assign({}, studyMetaData); + /** + * Run the hanging protocol decisions tree on the active study, + * studies list and display sets, firing a hanging protocol event when + * complete to indicate the hanging protocol is ready. + * + * @param params is the dataset to run the hanging protocol on. + * @param params.activeStudy is the "primary" study to hang This may or may + * not be displayed by the actual viewports. + * @param params.studies is the list of studies to hang + * @param params.displaySets is the list of display sets associated with + * the studies to display in viewports. + * @param protocol is a specific protocol to apply. + * @returns + */ + public run({ studies, displaySets, activeStudy }, protocol) { + this.studies = [...studies]; + this.displaySets = displaySets; + this.activeStudy = activeStudy || studies[0]; - this.ProtocolEngine = new ProtocolEngine( + this.protocolEngine = new ProtocolEngine( this.protocols, this.customAttributeRetrievalCallbacks ); // if there is no pre-defined protocol if (!protocol || protocol.id === undefined) { - const matchedProtocol = this.ProtocolEngine.run(metaData); + const matchedProtocol = this.protocolEngine.run({ + studies: this.studies, + activeStudy, + displaySets, + }); this._setProtocol(matchedProtocol); return; } @@ -112,7 +185,7 @@ class HangingProtocolService { * and its callback has been added to the HangingProtocolService * @returns {boolean} true */ - hasCustomImageLoadStrategy() { + public hasCustomImageLoadStrategy() { return ( this.activeImageLoadStrategyName !== null && this.registeredImageLoadStrategies[ @@ -121,7 +194,7 @@ class HangingProtocolService { ); } - getCustomImageLoadPerformed() { + public getCustomImageLoadPerformed() { return this.customImageLoadPerformed; } @@ -130,13 +203,13 @@ class HangingProtocolService { * @param {string} name strategy name * @param {Function} callback image loader callback */ - registerImageLoadStrategy(name, callback) { + public registerImageLoadStrategy(name, callback) { if (callback instanceof Function && name) { this.registeredImageLoadStrategies[name] = callback; } } - setHangingProtocolAppliedForViewport(i) { + public setHangingProtocolAppliedForViewport(i) { this.hpAlreadyApplied[i] = true; } @@ -147,18 +220,21 @@ class HangingProtocolService { * @param attributeId The ID used to refer to the attribute (e.g. 'timepointType') * @param attributeName The name of the attribute to be displayed (e.g. 'Timepoint Type') * @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image) + * @param options to add to the "this" object for the custom attribute retriever */ - addCustomAttribute(attributeId, attributeName, callback) { + public addCustomAttribute(attributeId, attributeName, callback, options) { this.customAttributeRetrievalCallbacks[attributeId] = { + ...options, + id: attributeId, name: attributeName, - callback: callback, + callback, }; } /** * Switches to the next protocol stage in the display set sequence */ - nextProtocolStage() { + public nextProtocolStage() { console.log('ProtocolEngine::nextProtocolStage'); if (!this._setCurrentProtocolStage(1)) { @@ -169,7 +245,7 @@ class HangingProtocolService { /** * Switches to the previous protocol stage in the display set sequence */ - previousProtocolStage() { + public previousProtocolStage() { console.log('ProtocolEngine::previousProtocolStage'); if (!this._setCurrentProtocolStage(-1)) { @@ -187,8 +263,8 @@ class HangingProtocolService { ]; const loadedData = loader({ data, - displaySetsMatchDetails: this.getDisplaySetsMatchDetails(), - matchDetails: this.matchDetails, + displaySetsMatchDetails: this.displaySetMatchDetails, + viewportMatchDetails: this.viewportMatchDetails, }); // if loader successfully re-arranged the data with the custom strategy @@ -253,6 +329,12 @@ class HangingProtocolService { } } this._updateViewports(); + + this._broadcastChange(this.EVENTS.PROTOCOL_CHANGED, { + viewportMatchDetails: this.viewportMatchDetails, + displaySetMatchDetails: this.displaySetMatchDetails, + hpAlreadyApplied: this.hpAlreadyApplied, + }); } /** @@ -280,9 +362,13 @@ class HangingProtocolService { return this.protocol.stages[this.stage]; } + /** + * Updates the viewports with the selected protocol stage. + */ _updateViewports() { // Make sure we have an active protocol with a non-empty array of display sets if (!this._getNumProtocolStages()) { + console.log('No protocol stages - nothing to display'); return; } @@ -301,6 +387,7 @@ class HangingProtocolService { !stageModel.displaySets || !stageModel.viewports.length ) { + console.log('Stage cannot be applied', stageModel); return; } @@ -311,6 +398,7 @@ class HangingProtocolService { // If no such layout properties exist, stop here. const layoutProps = stageModel.viewportStructure.properties; if (!layoutProps) { + console.log('No viewportStructure.properties in', stageModel); return; } @@ -324,12 +412,13 @@ class HangingProtocolService { }); // Matching the displaySets - // Note: this is happening before displaySets are created. Here, displaySet - // only contains the information of the id of the displaySet to be matched - // based on some rules + stageModel.displaySets.forEach(displaySet => { - const { bestMatch } = this._matchImages(displaySet); + const { bestMatch, matchingScores } = this._matchImages(displaySet); this.displaySetMatchDetails.set(displaySet.id, bestMatch); + if (bestMatch) { + bestMatch.matchingScores = matchingScores; + } }); // Loop through each viewport @@ -339,30 +428,42 @@ class HangingProtocolService { // DisplaySets for the viewport, Note: this is not the actual displaySet, // but it is a info to locate the displaySet from the displaySetService - let displaySetsInfo = []; - viewport.displaySets.forEach(({ id, options: displaySetOptions }) => { - const viewportDisplaySet = this.displaySetMatchDetails.get(id); + const displaySetsInfo = []; + viewport.displaySets.forEach( + ({ id, displaySetIndex = 0, options: displaySetOptions }) => { + const viewportDisplaySetMain = this.displaySetMatchDetails.get(id); + // Use the display set index to allow getting the "next" match, eg + // matching all display sets, and get the displaySetIndex'th item + const viewportDisplaySet = + !viewportDisplaySetMain || displaySetIndex === 0 + ? viewportDisplaySetMain + : viewportDisplaySetMain.matchingScores[displaySetIndex]; - if (viewportDisplaySet) { - const { SeriesInstanceUID } = viewportDisplaySet; + if (viewportDisplaySet) { + const { + SeriesInstanceUID, + displaySetInstanceUID, + } = viewportDisplaySet; - const displaySetInfo = { - SeriesInstanceUID, - displaySetOptions, - }; + const displaySetInfo = { + SeriesInstanceUID, + displaySetInstanceUID, + displaySetOptions, + }; - displaySetsInfo.push(displaySetInfo); - } else { - console.warn( - ` + displaySetsInfo.push(displaySetInfo); + } else { + console.warn( + ` The hanging protocol viewport is requesting to display ${id} displaySet that is not matched based on the provided criteria (e.g. matching rules). ` - ); + ); + } } - }); + ); - this.matchDetails[viewportIndex] = { + this.viewportMatchDetails[viewportIndex] = { viewportOptions, displaySetsInfo, }; @@ -370,50 +471,77 @@ class HangingProtocolService { } // Match images given a list of Studies and a Viewport's image matching reqs - _matchImages(displaySet) { - console.log('ProtocolEngine::matchImages'); - + _matchImages(displaySetRules) { // TODO: matching is applied on study and series level, instance // level matching needs to be added in future // Todo: handle fusion viewports by not taking the first displaySet rule for the viewport - const { studyMatchingRules, seriesMatchingRules } = displaySet; + const { + studyMatchingRules = [], + seriesMatchingRules, + findAll = false, + } = displaySetRules; const matchingScores = []; let highestStudyMatchingScore = 0; let highestSeriesMatchingScore = 0; + console.log( + 'ProtocolEngine::matchImages', + studyMatchingRules, + seriesMatchingRules + ); this.studies.forEach(study => { - const studyMatchDetails = this.ProtocolEngine.findMatch( + const studyDisplaySets = this.displaySets.filter( + it => it.StudyInstanceUID === study.StudyInstanceUID + ); + const studyMatchDetails = this.protocolEngine.findMatch( study, - studyMatchingRules + studyMatchingRules, + { studies: this.studies, displaySets: studyDisplaySets } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed - if ( - studyMatchDetails.requiredFailed === true || - studyMatchDetails.score < highestStudyMatchingScore - ) { + if (studyMatchDetails.requiredFailed === true) { return; } highestStudyMatchingScore = studyMatchDetails.score; - study.series.forEach(aSeries => { - const seriesMatchDetails = this.ProtocolEngine.findMatch( - aSeries, - seriesMatchingRules + this.debug( + 'study', + study.StudyInstanceUID, + 'display sets #', + this.displaySets.length + ); + this.displaySets.forEach(displaySet => { + const { + StudyInstanceUID, + SeriesInstanceUID, + displaySetInstanceUID, + } = displaySet; + if (StudyInstanceUID !== study.StudyInstanceUID) return; + const seriesMatchDetails = this.protocolEngine.findMatch( + displaySet, + seriesMatchingRules, + { studies: this.studies, instance: displaySet.images?.[0] } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed - if ( - seriesMatchDetails.requiredFailed === true || - seriesMatchDetails.score < highestSeriesMatchingScore - ) { + if (seriesMatchDetails.requiredFailed === true) { + this.debug( + 'Display set required failed', + displaySet, + seriesMatchingRules + ); return; } - highestSeriesMatchingScore = seriesMatchDetails.score; + this.debug('Found displaySet for rules', displaySet); + highestSeriesMatchingScore = Math.max( + seriesMatchDetails.score, + highestSeriesMatchingScore + ); const matchDetails = { passed: [], @@ -438,21 +566,27 @@ class HangingProtocolService { seriesMatchDetails.score + studyMatchDetails.score; const imageDetails = { - StudyInstanceUID: study.StudyInstanceUID, - SeriesInstanceUID: aSeries.SeriesInstanceUID, + StudyInstanceUID, + SeriesInstanceUID, + displaySetInstanceUID, matchingScore: totalMatchScore, matchDetails: matchDetails, sortingInfo: { score: totalMatchScore, study: study.StudyInstanceUID, - series: parseInt(aSeries.SeriesNumber), + series: parseInt(displaySet.SeriesNumber), }, }; + this.debug('Adding display set', displaySet, imageDetails); matchingScores.push(imageDetails); }); }); + if (matchingScores.length === 0) { + console.log('No match found'); + } + // Sort the matchingScores const sortingFunction = sortBy( { @@ -473,7 +607,11 @@ class HangingProtocolService { const bestMatch = matchingScores[0]; - console.log('ProtocolEngine::matchImages bestMatch', bestMatch); + console.log( + 'ProtocolEngine::matchImages bestMatch', + bestMatch, + matchingScores + ); return { bestMatch, @@ -495,7 +633,7 @@ class HangingProtocolService { * Check if the previous stage is available * @return {Boolean} True if previous stage is available or false otherwise */ - _isPreviousStageAvailable() { + _isPreviousStageAvailable(): boolean { return this.stage - 1 >= 0; } @@ -506,7 +644,7 @@ class HangingProtocolService { * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage * @return {Boolean} True if new stage has set or false, otherwise */ - _setCurrentProtocolStage(stageAction) { + _setCurrentProtocolStage(stageAction): boolean { //reseting the applied protocols this.hpAlreadyApplied = []; // Check if previous or next stage is available @@ -520,9 +658,7 @@ class HangingProtocolService { this.stage += stageAction; // Log the new stage - console.log( - `ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}` - ); + this.debug(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`); // Since stage has changed, we need to update the viewports // and redo matchings @@ -530,11 +666,22 @@ class HangingProtocolService { // Everything went well this._broadcastChange(this.EVENTS.STAGE_CHANGE, { - matchDetails: this.matchDetails, + viewportMatchDetails: this.viewportMatchDetails, hpAlreadyApplied: this.hpAlreadyApplied, + displaySetMatchDetails: this.displaySetMatchDetails, }); return true; } + + /** Set this.debugLogging to true to show debug level logging - needed + * to be able to figure out why hanging protocols are or are not applying. + */ + debug(...args): void { + if (this.debugLogging) { + console.log(...args); + } + } + /** * Broadcasts hanging protocols changes. * diff --git a/platform/core/src/services/HangingProtocolService/ProtocolEngine.js b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js index 260c7a248..2c8fddca9 100644 --- a/platform/core/src/services/HangingProtocolService/ProtocolEngine.js +++ b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js @@ -10,8 +10,15 @@ export default class ProtocolEngine { this.study = undefined; } - run(studyMetaData) { - this.study = studyMetaData; + /** Evaluate the hanging protocol matches on the given: + * @param props.studies is a list of studies to compare against (for priors evaluation) + * @param props.activeStudy is the current metadata for the study to display. + * @param props.displaySets are the list of display sets which can be modified. + */ + run({ studies, displaySets, activeStudy }) { + this.studies = studies; + this.study = activeStudy || studies[0]; + this.displaySets = displaySets; return this.getBestProtocolMatch(); } @@ -49,9 +56,11 @@ export default class ProtocolEngine { // Clear all data currently in matchedProtocols this._clearMatchedProtocols(); - // TODO: handle more than one study - const study = this.study; - const matched = this.findMatchByStudy(study); + // TODO: handle more than one study - this.studies has the list of studies + const matched = this.findMatchByStudy(this.study, { + studies: this.studies, + displaySets: this.displaySets, + }); // For each matched protocol, check if it is already in MatchedProtocols matched.forEach(matchedDetail => { @@ -72,11 +81,12 @@ export default class ProtocolEngine { }); } - findMatch(metaData, rules) { + findMatch(metaData, rules, options) { return HPMatcher.match( metaData, rules, - this.customAttributeRetrievalCallbacks + this.customAttributeRetrievalCallbacks, + options ); } @@ -84,11 +94,12 @@ export default class ProtocolEngine { * Finds the best protocols from Protocol Store, matching each protocol matching rules * with the given study. The best protocol are ordered by score and returned in an array * @param {Object} study StudyMetadata instance object + * @param {object} options containing additional matching data. * @return {Array} Array of match objects or an empty array if no match was found * Each match object has the score of the matching and the matched * protocol */ - findMatchByStudy(study) { + findMatchByStudy(study, options) { const matched = []; this.protocols.forEach(protocol => { @@ -98,13 +109,14 @@ export default class ProtocolEngine { let rules = protocol.protocolMatchingRules.slice(); if (!rules || !rules.length) { console.warn( - 'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules' + 'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules', + protocol.id ); return; } // Run the matcher and get matching details - const matchedDetails = this.findMatch(study, rules); + const matchedDetails = this.findMatch(study, rules, options); const score = matchedDetails.score; // The protocol matched some rule, add it to the matched list @@ -156,605 +168,3 @@ export default class ProtocolEngine { return this.matchedProtocols.get(highestScoringProtocolId); } } - -/** - * Resets the ProtocolEngine to the best match - */ - -/** - * Retrieves the current Stage from the current Protocol and stage index - * - * @returns {*} The Stage model for the currently displayed Stage - */ -// getCurrentStageModel() { -// return this.protocol.stages[this.stage]; -// } - -// /** -// * Get the number of prior studies supplied in the priorStudies map property. -// * -// * @param {String} studyObjectID The study object ID of the study whose priors are needed -// * @returns {number} The number of available prior studies with the same PatientID -// */ -// getNumberOfAvailablePriors(studyObjectID) { -// return this.getAvailableStudyPriors(studyObjectID).length; -// } - -// /** -// * Get the array of prior studies from a specific study. -// * -// * @param {String} studyObjectID The study object ID of the study whose priors are needed -// * @returns {Array} The array of available priors or an empty array -// */ -// getAvailableStudyPriors(studyObjectID) { -// const priors = this.priorStudies.get(studyObjectID); - -// return priors instanceof Array ? priors : []; -// } - -// // Match images given a list of Studies and a Viewport's image matching reqs -// matchImages(viewport, viewportIndex) { -// log.trace('ProtocolEngine::matchImages'); - -// const { -// studyMatchingRules, -// seriesMatchingRules, -// imageMatchingRules: instanceMatchingRules, -// } = viewport; - -// const matchingScores = []; -// const currentStudy = this.studies[0]; // @TODO: Should this be: this.studies[this.currentStudy] ??? -// const firstInstance = currentStudy.getFirstInstance(); - -// let highestStudyMatchingScore = 0; -// let highestSeriesMatchingScore = 0; - -// // Set custom attribute for study metadata and it's first instance -// currentStudy.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0); -// if (firstInstance instanceof InstanceMetadata) { -// firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0); -// } - -// // Only used if study matching rules has abstract prior values defined... -// let priorStudies; - -// studyMatchingRules.forEach(rule => { -// if (rule.attribute === ABSTRACT_PRIOR_VALUE) { -// const validatorType = Object.keys(rule.constraint)[0]; -// const validator = Object.keys(rule.constraint[validatorType])[0]; - -// let abstractPriorValue = rule.constraint[validatorType][validator]; -// abstractPriorValue = parseInt(abstractPriorValue, 10); -// // TODO: Restrict or clarify validators for abstractPriorValue? - -// // No need to call it more than once... -// if (!priorStudies) { -// priorStudies = this.getAvailableStudyPriors( -// currentStudy.getObjectID() -// ); -// } - -// // TODO: Revisit this later: What about two studies with the same -// // study date? - -// let priorStudy; -// if (abstractPriorValue === -1) { -// priorStudy = priorStudies[priorStudies.length - 1]; -// } else { -// const studyIndex = Math.max(abstractPriorValue - 1, 0); -// priorStudy = priorStudies[studyIndex]; -// } - -// // Invalid data -// if (!priorStudy instanceof StudyMetadata) { -// return; -// } - -// const priorStudyObjectID = priorStudy.getObjectID(); - -// // Check if study metadata is already in studies list -// if ( -// this.studies.find(study => study.getObjectID() === priorStudyObjectID) -// ) { -// return; -// } - -// // Get study metadata if necessary and load study in the viewer (each viewer should provide it's own load study method) -// this.studyMetadataSource.loadStudy(priorStudy).then( -// studyMetadata => { -// // Set the custom attribute abstractPriorValue for the study metadata -// studyMetadata.setCustomAttribute( -// ABSTRACT_PRIOR_VALUE, -// abstractPriorValue -// ); - -// // Also add custom attribute -// const firstInstance = studyMetadata.getFirstInstance(); -// if (firstInstance instanceof InstanceMetadata) { -// firstInstance.setCustomAttribute( -// ABSTRACT_PRIOR_VALUE, -// abstractPriorValue -// ); -// } - -// // Insert the new study metadata -// this.studies.push(studyMetadata); - -// // Update the viewport to refresh layout manager with new study -// this.updateViewports(viewportIndex); -// }, -// error => { -// log.warn(error); -// throw new OHIFError( -// `ProtocolEngine::matchImages could not get study metadata for the Study with the following ObjectID: ${priorStudyObjectID}` -// ); -// } -// ); -// } -// // TODO: Add relative Date / time -// }); - -// this.studies.forEach(study => { -// const studyMatchDetails = HPMatcher.match( -// study.getFirstInstance(), -// studyMatchingRules -// ); - -// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed -// if ( -// studyMatchDetails.requiredFailed === true || -// studyMatchDetails.score < highestStudyMatchingScore -// ) { -// return; -// } - -// highestStudyMatchingScore = studyMatchDetails.score; - -// study.forEachSeries(series => { -// const seriesMatchDetails = HPMatcher.match( -// series.getFirstInstance(), -// seriesMatchingRules -// ); - -// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed -// if ( -// seriesMatchDetails.requiredFailed === true || -// seriesMatchDetails.score < highestSeriesMatchingScore -// ) { -// return; -// } - -// highestSeriesMatchingScore = seriesMatchDetails.score; - -// series.forEachInstance((instance, index) => { -// // This tests to make sure there is actually image data in this instance -// // TODO: Change this when we add PDF and MPEG support -// // See https://ohiforg.atlassian.net/browse/LT-227 -// if ( -// !isImage(instance.getTagValue('SOPClassUID')) && -// !instance.getTagValue('Rows') -// ) { -// return; -// } - -// const instanceMatchDetails = HPMatcher.match( -// instance, -// instanceMatchingRules -// ); - -// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed -// if (instanceMatchDetails.requiredFailed === true) { -// return; -// } - -// const matchDetails = { -// passed: [], -// failed: [], -// }; - -// matchDetails.passed = matchDetails.passed.concat( -// instanceMatchDetails.details.passed -// ); -// matchDetails.passed = matchDetails.passed.concat( -// seriesMatchDetails.details.passed -// ); -// matchDetails.passed = matchDetails.passed.concat( -// studyMatchDetails.details.passed -// ); - -// matchDetails.failed = matchDetails.failed.concat( -// instanceMatchDetails.details.failed -// ); -// matchDetails.failed = matchDetails.failed.concat( -// seriesMatchDetails.details.failed -// ); -// matchDetails.failed = matchDetails.failed.concat( -// studyMatchDetails.details.failed -// ); - -// const totalMatchScore = -// instanceMatchDetails.score + -// seriesMatchDetails.score + -// studyMatchDetails.score; -// const currentSOPInstanceUID = instance.getSOPInstanceUID(); - -// const imageDetails = { -// StudyInstanceUID: study.getStudyInstanceUID(), -// SeriesInstanceUID: series.getSeriesInstanceUID(), -// SOPInstanceUID: currentSOPInstanceUID, -// currentImageIdIndex: index, -// matchingScore: totalMatchScore, -// matchDetails: matchDetails, -// sortingInfo: { -// score: totalMatchScore, -// study: -// instance.getTagValue('StudyDate') + -// instance.getTagValue('StudyTime'), -// series: parseInt(instance.getTagValue('SeriesNumber')), // TODO: change for seriesDateTime -// instance: parseInt(instance.getTagValue('InstanceNumber')), // TODO: change for acquisitionTime -// }, -// }; - -// // Find the displaySet -// const displaySet = study.findDisplaySet(displaySet => -// displaySet.images.find( -// image => image.getSOPInstanceUID() === currentSOPInstanceUID -// ) -// ); - -// // If the instance was found, set the displaySet ID -// if (displaySet) { -// imageDetails.displaySetInstanceUID = displaySet.getUID(); -// imageDetails.imageId = instance.getImageId(); -// } - -// matchingScores.push(imageDetails); -// }); -// }); -// }); - -// // Sort the matchingScores -// const sortingFunction = sortBy( -// { -// name: 'score', -// reverse: true, -// }, -// { -// name: 'study', -// reverse: true, -// }, -// { -// name: 'instance', -// }, -// { -// name: 'series', -// } -// ); -// matchingScores.sort((a, b) => -// sortingFunction(a.sortingInfo, b.sortingInfo) -// ); - -// const bestMatch = matchingScores[0]; - -// log.trace('ProtocolEngine::matchImages bestMatch', bestMatch); - -// return { -// bestMatch, -// matchingScores, -// }; -// } - -/** - * Sets the current layout - * - * @param {number} numRows - * @param {number} numColumns - */ -// setLayout(numRows, numColumns) { -// if (numRows < 1 && numColumns < 1) { -// log.error(`Invalid layout ${numRows} x ${numColumns}`); -// return; -// } - -// if (typeof this.options.setLayout !== 'function') { -// log.error('Hanging Protocol Engine setLayout callback is not defined'); -// return; -// } - -// let viewports = []; -// const numViewports = numRows * numColumns; - -// for (let i = 0; i < numViewports; i++) { -// viewports.push({}); -// } - -// this.options.setLayout({ numRows, numColumns, viewports }); -// } - -// /** -// * Rerenders viewports that are part of the current layout manager -// * using the matching rules internal to each viewport. -// * -// * If this function is provided the index of a viewport, only the specified viewport -// * is rerendered. -// * -// * @param viewportIndex -// */ -// updateViewports(viewportIndex) { -// log.trace( -// `ProtocolEngine::updateViewports viewportIndex: ${viewportIndex}` -// ); - -// // Make sure we have an active protocol with a non-empty array of display sets -// if (!this.getNumProtocolStages()) { -// return; -// } - -// // Retrieve the current stage -// const stageModel = this.getCurrentStageModel(); - -// // If the current stage does not fulfill the requirements to be displayed, -// // stop here. -// if ( -// !stageModel || -// !stageModel.viewportStructure || -// !stageModel.viewports || -// !stageModel.viewports.length -// ) { -// return; -// } - -// // Retrieve the layoutTemplate associated with the current display set's viewport structure -// // If no such template name exists, stop here. -// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName(); -// if (!layoutTemplateName) { -// return; -// } - -// // Retrieve the properties associated with the current display set's viewport structure template -// // If no such layout properties exist, stop here. -// const layoutProps = stageModel.viewportStructure.properties; -// if (!layoutProps) { -// return; -// } - -// // Create an empty array to store the output viewportData -// const viewportData = []; - -// // Empty the matchDetails associated with the ProtocolEngine. -// // This will be used to store the pass/fail details and score -// // for each of the viewport matching procedures -// this.matchDetails = []; - -// // Loop through each viewport -// stageModel.viewports.forEach((viewport, viewportIndex) => { -// const details = this.matchImages(viewport, viewportIndex); - -// this.matchDetails[viewportIndex] = details; - -// // Convert any YES/NO values into true/false for Cornerstone -// const cornerstoneViewportParams = {}; - -// // Cache viewportSettings keys -// const viewportSettingsKeys = Object.keys(viewport.viewportSettings); - -// viewportSettingsKeys.forEach(key => { -// let value = viewport.viewportSettings[key]; -// if (value === 'YES') { -// value = true; -// } else if (value === 'NO') { -// value = false; -// } - -// cornerstoneViewportParams[key] = value; -// }); - -// // imageViewerViewports occasionally needs relevant layout data in order to set -// // the element style of the viewport in question -// const currentViewportData = { -// viewportIndex, -// viewport: cornerstoneViewportParams, -// ...layoutProps, -// }; - -// const customSettings = []; -// viewportSettingsKeys.forEach(id => { -// const setting = CustomViewportSettings[id]; -// if (!setting) { -// return; -// } - -// customSettings.push({ -// id: id, -// value: viewport.viewportSettings[id], -// }); -// }); - -// currentViewportData.renderedCallback = element => { -// //console.log('renderedCallback for ' + element.id); -// customSettings.forEach(customSetting => { -// log.trace( -// `ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}` -// ); -// log.trace( -// `ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}` -// ); - -// const setting = CustomViewportSettings[customSetting.id]; -// setting.callback(element, customSetting.value); -// }); -// }; - -// let currentMatch = details.bestMatch; -// let currentPosition = 1; -// const scoresLength = details.matchingScores.length; -// while ( -// currentPosition < scoresLength && -// viewportData.find(a => a.imageId === currentMatch.imageId) -// ) { -// currentMatch = details.matchingScores[currentPosition]; -// currentPosition++; -// } - -// if (currentMatch && currentMatch.imageId) { -// currentViewportData.StudyInstanceUID = currentMatch.StudyInstanceUID; -// currentViewportData.SeriesInstanceUID = currentMatch.SeriesInstanceUID; -// currentViewportData.SOPInstanceUID = currentMatch.SOPInstanceUID; -// currentViewportData.currentImageIdIndex = -// currentMatch.currentImageIdIndex; -// currentViewportData.displaySetInstanceUID = -// currentMatch.displaySetInstanceUID; -// currentViewportData.imageId = currentMatch.imageId; -// } - -// // @TODO Why should we throw an exception when a best match is not found? This was aborting the whole process. -// // if (!currentViewportData.displaySetInstanceUID) { -// // throw new OHIFError('ProtocolEngine::updateViewports No matching display set found?'); -// // } - -// viewportData.push(currentViewportData); -// }); - -// this.setLayout(layoutProps.Rows, layoutProps.Columns); - -// if (typeof this.options.setViewportSpecificData !== 'function') { -// log.error( -// 'Hanging Protocol Engine setViewportSpecificData callback is not defined' -// ); -// return; -// } - -// // If viewportIndex is defined, then update only that viewport -// if (viewportIndex !== undefined && viewportData[viewportIndex]) { -// this.options.setViewportSpecificData( -// viewportIndex, -// viewportData[viewportIndex] -// ); -// return; -// } - -// // Update all viewports -// viewportData.forEach(viewportSpecificData => { -// this.options.setViewportSpecificData( -// viewportSpecificData.viewportIndex, -// viewportSpecificData -// ); -// }); -// } - -// /** -// * Sets the current Hanging Protocol to the specified Protocol -// * An optional argument can also be used to prevent the updating of the Viewports -// * -// * @param newProtocol -// * @param updateViewports -// */ -// setHangingProtocol(newProtocol, updateViewports = true) { -// log.trace('ProtocolEngine::setHangingProtocol newProtocol', newProtocol); -// log.trace( -// `ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}` -// ); - -// // Reset the array of newStageIds -// this.newStageIds = []; - -// if (Protocol.prototype.isPrototypeOf(newProtocol)) { -// this.protocol = newProtocol; -// } else { -// this.protocol = new Protocol(); -// this.protocol.fromObject(newProtocol); -// } - -// this.stage = 0; - -// // Update viewports by default -// if (updateViewports) { -// this.updateViewports(); -// } -// } - -// /** -// * Check if the next stage is available -// * @return {Boolean} True if next stage is available or false otherwise -// */ -// isNextStageAvailable() { -// const numberOfStages = this.getNumProtocolStages(); - -// return this.stage + 1 < numberOfStages; -// } - -// /** -// * Check if the previous stage is available -// * @return {Boolean} True if previous stage is available or false otherwise -// */ -// isPreviousStageAvailable() { -// return this.stage - 1 >= 0; -// } - -// /** -// * Changes the current stage to a new stage index in the display set sequence. -// * It checks if the next stage exists. -// * -// * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage -// * @return {Boolean} True if new stage has set or false, otherwise -// */ -// setCurrentProtocolStage(stageAction) { -// // Check if previous or next stage is available -// if (stageAction === -1 && !this.isPreviousStageAvailable()) { -// return false; -// } else if (stageAction === 1 && !this.isNextStageAvailable()) { -// return false; -// } - -// // Sets the new stage -// this.stage += stageAction; - -// // Log the new stage -// log.trace(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`); - -// // Since stage has changed, we need to update the viewports -// // and redo matchings -// this.updateViewports(); - -// // Everything went well -// return true; -// } - -// /** -// * Retrieves the number of Stages in the current Protocol or -// * undefined if no protocol or stages are set -// */ -// getNumProtocolStages() { -// if ( -// !this.protocol || -// !this.protocol.stages || -// !this.protocol.stages.length -// ) { -// return; -// } - -// return this.protocol.stages.length; -// } - -// /** -// * Switches to the next protocol stage in the display set sequence -// */ -// nextProtocolStage() { -// log.trace('ProtocolEngine::nextProtocolStage'); - -// if (!this.setCurrentProtocolStage(1)) { -// log.trace('ProtocolEngine::nextProtocolStage failed'); -// } -// } - -// /** -// * Switches to the previous protocol stage in the display set sequence -// */ -// previousProtocolStage() { -// log.trace('ProtocolEngine::previousProtocolStage'); - -// if (!this.setCurrentProtocolStage(-1)) { -// log.trace('ProtocolEngine::previousProtocolStage failed'); -// } -// } -// } diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.js b/platform/core/src/services/HangingProtocolService/lib/validator.js index 454143a03..8bdc06949 100644 --- a/platform/core/src/services/HangingProtocolService/lib/validator.js +++ b/platform/core/src/services/HangingProtocolService/lib/validator.js @@ -1,39 +1,88 @@ import validate from 'validate.js'; -validate.validators.equals = function (value, options, key, attributes) { - if (options && value !== options.value) { - return key + 'must equal ' + options.value; +validate.validators.equals = function(value, options, key, attributes) { + const testValue = options?.value ?? options; + if (value !== testValue) { + return key + 'must equal ' + testValue; } }; -validate.validators.doesNotEqual = function (value, options, key) { - if (options && value === options.value) { - return key + 'cannot equal ' + options.value; +validate.validators.doesNotEqual = function(value, options, key) { + const testValue = options?.value ?? options; + if (value === testValue) { + return key + 'cannot equal ' + testValue; } }; -validate.validators.contains = function (value, options, key) { - if (options && value.indexOf && value.indexOf(options.value) === -1) { - return key + 'must contain ' + options.value; +validate.validators.contains = function(value, options, key) { + const testValue = options?.value ?? options; + if (Array.isArray(value)) { + if (value.some(item => !validate.validators.contains(item, options, key))) { + return undefined; + } + return `No item of ${value.join(',')} contains ${JSON.stringify( + testValue + )}`; + } + if (Array.isArray(testValue)) { + if ( + testValue.some( + subTest => !validate.validators.contains(value, subTest, key) + ) + ) { + return; + } + return `${key} must contain at least one of ${testValue.join(',')}`; + } + if (testValue && value.indexOf && value.indexOf(testValue) === -1) { + return key + 'must contain ' + testValue; } }; -validate.validators.doesNotContain = function (value, options, key) { +validate.validators.doesNotContain = function(value, options, key) { if (options && value.indexOf && value.indexOf(options.value) !== -1) { return key + 'cannot contain ' + options.value; } }; -validate.validators.startsWith = function (value, options, key) { +validate.validators.startsWith = function(value, options, key) { if (options && value.startsWith && !value.startsWith(options.value)) { return key + 'must start with ' + options.value; } }; -validate.validators.endsWith = function (value, options, key) { +validate.validators.endsWith = function(value, options, key) { if (options && value.endsWith && !value.endsWith(options.value)) { return key + 'must end with ' + options.value; } }; +validate.validators.greaterThan = function(value, options, key) { + const testValue = options?.value ?? options; + if (testValue !== undefined && value <= testValue) { + return key + 'with value ' + value + ' must be greater than ' + testValue; + } +}; + +validate.validators.range = function(value, options, key) { + const testValue = options?.value ?? options; + if ( + (testValue !== undefined && value < testValue[0]) || + value > testValue[1] + ) { + return ( + key + + 'with value ' + + value + + ' must be between ' + + testValue[0] + + ' and ' + + testValue[1] + ); + } +}; + +validate.validators.notNull = value => + value === null || value === undefined ? 'Value is null' : undefined; + export default validate; diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.test.js b/platform/core/src/services/HangingProtocolService/lib/validator.test.js new file mode 100644 index 000000000..4b20f8029 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/validator.test.js @@ -0,0 +1,49 @@ +import validate from "./validator.js"; + +describe("validator", () => { + const attributeMap = { + str: "string", + num: 3, + nullValue: null, + list: ["abc", "def"], + } + + const options = { + format: 'grouped', + }; + + describe("contains", () => { + it("returns match any list contains", () => { + expect(validate(attributeMap, { list: { contains: 'a' } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { str: { contains: 'i' } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { str: { contains: ['i'] } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { list: { contains: ['a'] } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { list: { contains: ['z', 'd'] } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { list: { contains: ['z'] } }, [options])).not.toBeUndefined(); + }) + }) + + describe("equals", () => { + it("returned undefined on equals", () => { + expect(validate(attributeMap, { str: { equals: attributeMap.str } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { num: { equals: { value: attributeMap.num } } }, [options])).toBeUndefined(); + }) + + it("returns error on not equals", () => { + expect(validate(attributeMap, { str: { equals: "abc" } }, [options])).not.toBeUndefined(); + expect(validate(attributeMap, { num: { equals: { value: 1 + attributeMap.num } } }, [options])).not.toBeUndefined(); + }) + }) + + describe("greaterThan", () => { + it("returns undefined on greaterThan", () => { + expect(validate(attributeMap, { num: { greaterThan: { value: attributeMap.num - 1 } } }, [options])).toBeUndefined(); + expect(validate(attributeMap, { num: { greaterThan: attributeMap.num - 1 } }, [options])).toBeUndefined(); + }) + + it("returns error on not greater than", () => { + expect(validate(attributeMap, { num: { greaterThan: { value: attributeMap.num } } }, [options])).not.toBeUndefined(); + expect(validate(attributeMap, { num: { greaterThan: attributeMap.num } }, [options])).not.toBeUndefined(); + }) + }) +}); diff --git a/platform/docs/docs/platform/services/data/DisplaySetService.md b/platform/docs/docs/platform/services/data/DisplaySetService.md index f6c053cb6..6a42d6269 100644 --- a/platform/docs/docs/platform/services/data/DisplaySetService.md +++ b/platform/docs/docs/platform/services/data/DisplaySetService.md @@ -10,41 +10,7 @@ sidebar_label: DisplaySet Service > Based on the instanceMetadata's `SOPClassHandlerId`, the correct module from the registered extensions is found by `OHIF` and its `getDisplaySetsFromSeries` runs to create a DisplaySet for the Series. - -```js title="platform/core/src/services/DisplaySetService/DisplaySetService.js" -init(extensionManager, SOPClassHandlerIds) { - this.extensionManager = extensionManager; - this.SOPClassHandlerIds = SOPClassHandlerIds; - this.activeDisplaySets = []; -} -``` - -in `Mode.jsx` - -```js title="platform/viewer/src/routes/Mode/Mode.jsx" -export default function ModeRoute(/** ... **/) { - /** ... **/ - const { DisplaySetService } = servicesManager.services - const { sopClassHandlers } = mode - /** ... **/ - useEffect( - () => { - /** ... **/ - - // Add SOPClassHandlers to a new SOPClassManager. - DisplaySetService.init(extensionManager, sopClassHandlers) - - /** ... **/ - } - /** ... **/ - ) - /** ... **/ - return <> /** ... **/ -} -``` - - - +DisplaySets are created synchronously when the instances metadata is retrieved and added to the [DicomMetaDataStore](../data//DicomMetadataStore.md). ## Events There are three events that get broadcasted in `DisplaySetService`: diff --git a/platform/docs/versioned_docs/version-3.0/platform/services/data/DisplaySetService.md b/platform/docs/versioned_docs/version-3.0/platform/services/data/DisplaySetService.md index f6c053cb6..defc022c6 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/services/data/DisplaySetService.md +++ b/platform/docs/versioned_docs/version-3.0/platform/services/data/DisplaySetService.md @@ -59,7 +59,6 @@ There are three events that get broadcasted in `DisplaySetService`: - ## API Let's find out about the public API for `DisplaySetService`. @@ -83,3 +82,7 @@ Let's find out about the public API for `DisplaySetService`. - `getActiveDisplaySets`: Returns the active displaySets - `deleteDisplaySet`: Deletes the displaySets from the displaySets cache + +- `holdChangeEvents`: Prevents firing change events (currently only works on add event). + +- `fireHoldChangeEvents`: Causes the change event to be fired IF there were any changes. No longer holds events. diff --git a/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md b/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md index 575873953..641a6c3ee 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md +++ b/platform/docs/versioned_docs/version-3.0/platform/services/data/HangingProtocolService.md @@ -29,7 +29,7 @@ You can find the skeleton of the hanging protocols here: ```js const defaultProtocol = { - id: 'defaultProtocol', + id: 'test', locked: true, hasUpdatedPriorsInformation: false, name: 'Default', @@ -37,31 +37,91 @@ const defaultProtocol = { modifiedDate: '2021-02-23T19:22:08.894Z', availableTo: {}, editableBy: {}, - protocolMatchingRules: [], + toolGroupIds: [ + 'ctToolGroup', + 'ptToolGroup', + ], + imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter" + protocolMatchingRules: [ + { + id: 'wauZK2QNEfDPwcAQo', + weight: 1, + attribute: 'StudyDescription', + constraint: { + contains: { + value: 'PETCT', + }, + }, + required: false, + }, + ], stages: [ { - id: 'nwzau7jDkEkL8djfr', - name: 'oneByOne', + id: 'hYbmMy3b7pz7GLiaT', + name: 'default', viewportStructure: { - type: 'grid', + layoutType: 'grid', properties: { rows: 1, columns: 1, }, }, - viewports: [ + displaySets: [ { - viewportSettings: [], - imageMatchingRules: [], - seriesMatchingRules: [], + id: 'displaySet', + seriesMatchingRules: [ + { + id: 'GPEYqFLv2dwzCM322', + weight: 1, + attribute: 'Modality', + constraint: { + equals: 'CT', + }, + required: true, + }, + { + id: 'vSjk7NCYjtdS3XZAw', + weight: 1, + attribute: 'numImageFrames', + constraint: { + greaterThan: 10, + }, + }, + ], studyMatchingRules: [], }, ], - createdDate: '2021-02-23T19:22:08.894Z', + viewports: [ + { + viewportOptions: { + viewportId: 'ctAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'ctToolGroup', + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'displaySet', + }, + ], + }, + ], }, ], numberOfPriorsReferenced: -1, -}; +} ``` Let's discuss each property in depth. @@ -74,7 +134,7 @@ Let's discuss each property in depth. - `weight`: weight for the matching rule. Eventually, all the registered protocols get sorted based on the weights, and the winning protocol gets applied to the viewer. - - `attriubte`: tag that needs to be matched against. This can be either + - `attribute`: tag that needs to be matched against. This can be either Study-level metadata or a custom attribute. [Learn more about custom attribute matching](#custom-attribute) @@ -85,92 +145,65 @@ Let's discuss each property in depth. ```js { - id: 'wauZK2QNEfDPwcAQo', weight: 1, attribute: 'StudyInstanceUID', constraint: { - equals: { - value: '1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11', - }, + equals: '1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11', }, required: true, } ``` + - `stages`: Each protocol can define one or more stages. Each stage defines a certain layout and viewport rules. Therefore, the `stages` property is array of objects, each object being one stage. + - `displaySets`: Defines the matching rules for which display sets to use. - `viewportStructure`: Defines the layout of the viewer. You can define the - number of `rows` and `columns`. There should be `rows * columns` number of - viewport configuration in the `viewports` property. Note that order of - viewports are rows first then columns. + number of `rows` and `columns`. + - `viewports` defines the actual viewports to display. There should be `rows * columns` number of + these `viewports` property, ordered rows first, then columns. - - `viewportSettings`: custom settings to be applied to the viewport. This can - be a `voi` being applied to the viewer or a tool to get enabled. We will - discuss viewport-specific settings [below](#viewport-settings) - - - `imageMatchingRules (comming soon)`: setting the image slice for the - viewport. - - - `seriesMatchingRules`: the most important rule that matches series in the - viewport. For instance, the following stage configuration will create a - one-by-two layout and put the series whose description contains `t2` on the - left, and a series with description that contains `adc` on the right. (order - of viewports are rows, first then columns) ```js stages: [ { id: 'hYbmMy3b7pz7GLiaT', - name: 'oneByThree', + name: 'oneByTwo', viewportStructure: { type: 'grid', properties: { rows: 1, - columns: 2, + columns: 3, }, }, viewports: [ // viewport 1 - { - viewportSettings: [], - imageMatchingRules: [], - seriesMatchingRules: [ + { + viewportOptions: { + viewportId: 'ctAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'ctToolGroup', + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ { - id: 'vSjk7NCYjtdS3XZAw', - weight: 1, - attribute: 'SeriesDescription', - constraint: { - contains: { - value: 't2', - }, - }, - required: false, + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, }, ], - studyMatchingRules: [], }, - // viewport 2 - { - viewportSettings: [], - imageMatchingRules: [], - seriesMatchingRules: [ - { - id: 'vSjk7NCYjtdS3XZAw', - weight: 1, - attribute: 'SeriesDescription', - constraint: { - contains: { - value: 'ADC', - }, - }, - required: true, - }, - ], - studyMatchingRules: [], - }, - ], - }, + displaySets: [ + { + id: 'displaySet', + }, + ], + }, ]; ``` @@ -194,8 +227,9 @@ There are two events that get publish in `HangingProtocolService`: - `addProtocols`: adds provided protocols to the list of registered protocols for matching -- `run(studyMetaData, protocol)`: runs the HPService with the provided - studyMetaData and optional protocol. If protocol is not given, HP Matching +- `run({ studies, displaySets }, protocol)`: runs the HPService with the provided + list of studies, display sets and optional protocol. + If protocol is not given, HP Matching engine will search all the registered protocols for the best matching one based on the constraints. @@ -206,7 +240,11 @@ There are two events that get publish in `HangingProtocolService`: protocol definitions. `addCustomViewportSetting` is another way to set these settings which is exposed by API -- +- `hps.applyCustomViewportSettings(viewportOptions, viewport,...args)` will run + the callback registered with addCustomViewportSetting for all custom settings + whose name matches the id of the custom viewport, with the arguments (id, value, viewport, ...args) + + Default initialization of the modes handles running the `HangingProtocolService` @@ -278,7 +316,7 @@ function modeFactory() { metaData => getFirstMeasurementSeriesInstanceUID(metaData) ); - HangingProtocolService.run(studyMetadata); + HangingProtocolService.run(studyMetadata, DisplaySetService.getActiveDisplaySets()); }; DicomMetadataStore.subscribe( diff --git a/platform/docs/versioned_docs/version-3.0/platform/services/pubsub.md b/platform/docs/versioned_docs/version-3.0/platform/services/pubsub.md index 4fdc8748c..fae6978e6 100644 --- a/platform/docs/versioned_docs/version-3.0/platform/services/pubsub.md +++ b/platform/docs/versioned_docs/version-3.0/platform/services/pubsub.md @@ -59,7 +59,7 @@ async function defaultRouteInit({ DicomMetadataStore.EVENTS.SERIES_ADDED, ({ StudyInstanceUID }) => { const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); - HangingProtocolService.run(studyMetadata); + HangingProtocolService.run(studyMetadata, DisplaySetService.getActiveDisplaySets()); } ); unsubscriptions.push(seriesAddedUnsubscribe); diff --git a/platform/viewer/src/components/ViewportGrid.tsx b/platform/viewer/src/components/ViewportGrid.tsx index e57d633ce..8416daa6c 100644 --- a/platform/viewer/src/components/ViewportGrid.tsx +++ b/platform/viewer/src/components/ViewportGrid.tsx @@ -26,12 +26,12 @@ function ViewerViewportGrid(props) { return; } - const [ - matchDetails, + const { + viewportMatchDetails, hpAlreadyApplied, - ] = HangingProtocolService.getState(); + } = HangingProtocolService.getMatchDetails(); - if (!matchDetails.length) { + if (!viewportMatchDetails.length) { return; } @@ -43,24 +43,24 @@ function ViewerViewportGrid(props) { } // if current viewport doesn't have a match - if (matchDetails[i] === undefined) return; + if (viewportMatchDetails[i] === undefined) { + return; + } - const { displaySetsInfo, viewportOptions } = matchDetails[i]; + const { displaySetsInfo, viewportOptions } = viewportMatchDetails[i]; const displaySetUIDsToHang = []; const displaySetUIDsToHangOptions = []; - displaySetsInfo.forEach(({ SeriesInstanceUID, displaySetOptions }) => { - const matchingDisplaySet = availableDisplaySets.find(ds => { - return ds.SeriesInstanceUID === SeriesInstanceUID; - }); + displaySetsInfo.forEach( + ({ displaySetInstanceUID, displaySetOptions }) => { + if (!displaySetInstanceUID) { + return; + } - if (!matchingDisplaySet) { - return; + displaySetUIDsToHang.push(displaySetInstanceUID); + displaySetUIDsToHangOptions.push(displaySetOptions); } - - displaySetUIDsToHang.push(matchingDisplaySet.displaySetInstanceUID); - displaySetUIDsToHangOptions.push(displaySetOptions); - }); + ); if (!displaySetUIDsToHang.length) { continue; @@ -113,10 +113,11 @@ function ViewerViewportGrid(props) { // Using Hanging protocol engine to match the displaySets useEffect(() => { - const { unsubscribe } = DisplaySetService.subscribe( - DisplaySetService.EVENTS.DISPLAY_SETS_CHANGED, - activeDisplaySets => { - updateDisplaySetsForViewports(activeDisplaySets); + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.PROTOCOL_CHANGED, + () => { + const displaySets = DisplaySetService.getActiveDisplaySets(); + updateDisplaySetsForViewports(displaySets); } ); @@ -369,6 +370,11 @@ function _getViewportComponent(displaySets, viewportComponents) { const SOPClassHandlerId = displaySets[0].SOPClassHandlerId; for (let i = 0; i < viewportComponents.length; i++) { + if (!viewportComponents[i]) + throw new Error('viewport components not defined'); + if (!viewportComponents[i].displaySetsToDisplay) { + throw new Error('displaySetsToDisplay is null'); + } if ( viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId) ) { @@ -376,6 +382,7 @@ function _getViewportComponent(displaySets, viewportComponents) { return component; } } + throw new Error(`No display set handler for ${SOPClassHandlerId}`); } export default ViewerViewportGrid; diff --git a/platform/viewer/src/routes/Mode/Mode.tsx b/platform/viewer/src/routes/Mode/Mode.tsx index f2e164bbf..d49606849 100644 --- a/platform/viewer/src/routes/Mode/Mode.tsx +++ b/platform/viewer/src/routes/Mode/Mode.tsx @@ -9,24 +9,26 @@ import { useQuery } from '@hooks'; import ViewportGrid from '@components/ViewportGrid'; import Compose from './Compose'; -async function defaultRouteInit({ - servicesManager, - studyInstanceUIDs, - dataSource, -}) { +/** + * Initialize the route. + * + * @param props.servicesManager to read services from + * @param props.studyInstanceUIDs for a list of studies to read + * @param props.dataSource to read the data from + * @returns array of subscriptions to cancel + */ +function defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource }) { const { DisplaySetService, HangingProtocolService, } = servicesManager.services; const unsubscriptions = []; - // TODO: This should be baked into core, not manual? - // DisplaySetService would wire this up? const { unsubscribe: instanceAddedUnsubscribe, } = DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.INSTANCES_ADDED, - ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) => { + function({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) { const seriesMetadata = DicomMetadataStore.getSeries( StudyInstanceUID, SeriesInstanceUID @@ -38,19 +40,43 @@ async function defaultRouteInit({ unsubscriptions.push(instanceAddedUnsubscribe); - const { unsubscribe: seriesAddedUnsubscribe } = DicomMetadataStore.subscribe( - DicomMetadataStore.EVENTS.SERIES_ADDED, - ({ StudyInstanceUID, madeInClient }) => { - const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); - if (!madeInClient) { - HangingProtocolService.run(studyMetadata); - } - } + const allRetrieves = studyInstanceUIDs.map(StudyInstanceUID => + dataSource.retrieve.series.metadata({ StudyInstanceUID }) ); - unsubscriptions.push(seriesAddedUnsubscribe); - studyInstanceUIDs.forEach(StudyInstanceUID => { - dataSource.retrieve.series.metadata({ StudyInstanceUID }); + // The hanging protocol matching service is fairly expensive to run multiple + // times, and doesn't allow partial matches to be made (it will simply fail + // to display anything if a required match fails), so we wait here until all metadata + // is retrieved (which will synchronously trigger the display set creation) + // until we run the hanging protocol matching service. + + Promise.allSettled(allRetrieves).then(() => { + const displaySets = DisplaySetService.getActiveDisplaySets(); + + if (!displaySets || !displaySets.length) { + return; + } + + const studyMap = {}; + + // Prior studies don't quite work properly yet, but the studies list + // is at least being generated and passed in. + const studies = displaySets.reduce((prev, curr) => { + const { StudyInstanceUID } = curr; + if (!studyMap[StudyInstanceUID]) { + const study = DicomMetadataStore.getStudy(StudyInstanceUID); + studyMap[StudyInstanceUID] = study; + prev.push(study); + } + return prev; + }, []); + + // The assumption is that the display set at position 0 is the first + // study being displayed, and is thus the "active" study. + const activeStudy = studies[0]; + + // run the hanging protocol matching service on the displaySets + HangingProtocolService.run({ studies, activeStudy, displaySets }); }); return unsubscriptions; @@ -236,7 +262,7 @@ export default function ModeRoute({ }); } - return await defaultRouteInit({ + return defaultRouteInit({ servicesManager, studyInstanceUIDs, dataSource,