diff --git a/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts b/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts index 0a2f888e4..f61136245 100644 --- a/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone-dicom-seg/src/getHangingProtocolModule.ts @@ -5,7 +5,6 @@ const segProtocol: Types.HangingProtocol.Protocol = { // Don't store this hanging protocol as it applies to the currently active // display set by default // cacheId: null, - hasUpdatedPriorsInformation: false, name: 'Segmentations', // Just apply this one when specifically listed protocolMatchingRules: [], diff --git a/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts b/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts index e9fcf31be..8d47aca0c 100644 --- a/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getHangingProtocolModule.ts @@ -5,7 +5,6 @@ const srProtocol: Types.HangingProtocol.Protocol = { // Don't store this hanging protocol as it applies to the currently active // display set by default // cacheId: null, - hasUpdatedPriorsInformation: false, name: 'SR Key Images', // Just apply this one when specifically listed protocolMatchingRules: [], diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 15ff51460..e4bf99628 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -4,9 +4,8 @@ const mpr: Types.HangingProtocol.Protocol = { id: 'mpr', name: 'Multi-Planar Reconstruction', locked: true, - hasUpdatedPriorsInformation: false, createdDate: '2021-02-23', - modifiedDate: '2023-04-03', + modifiedDate: '2023-08-15', availableTo: {}, editableBy: {}, // Unknown number of priors referenced - so just match any study @@ -164,7 +163,6 @@ const mpr: Types.HangingProtocol.Protocol = { const mprAnd3DVolumeViewport = { id: 'mprAnd3DVolumeViewport', locked: true, - hasUpdatedPriorsInformation: false, name: 'mpr', createdDate: '2023-03-15T10:29:44.894Z', modifiedDate: '2023-03-15T10:29:44.894Z', diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index 5875e8e76..2df134f48 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -1,4 +1,5 @@ import hpMNGrid from './hpMNGrid'; +import hpMNCompare from './hpCompare'; const defaultProtocol = { id: 'default', @@ -6,7 +7,6 @@ const defaultProtocol = { // Don't store this hanging protocol as it applies to the currently active // display set by default // cacheId: null, - hasUpdatedPriorsInformation: false, name: 'Default', createdDate: '2021-02-23T19:22:08.894Z', modifiedDate: '2023-04-01', @@ -108,6 +108,11 @@ function getHangingProtocolModule() { name: hpMNGrid.id, protocol: hpMNGrid, }, + // Create a MxN comparison hanging protocol available by default + { + name: hpMNCompare.id, + protocol: hpMNCompare, + }, ]; } diff --git a/extensions/default/src/hpCompare.ts b/extensions/default/src/hpCompare.ts new file mode 100644 index 000000000..dca48f22b --- /dev/null +++ b/extensions/default/src/hpCompare.ts @@ -0,0 +1,193 @@ +import { Types } from '@ohif/core'; + +const defaultDisplaySetSelector = { + studyMatchingRules: [ + { + // The priorInstance is a study counter that indicates what position this study is in + // and the value comes from the options parameter. + attribute: 'studyInstanceUIDsIndex', + from: 'options', + required: true, + constraint: { + equals: { value: 0 }, + }, + }, + ], + seriesMatchingRules: [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + // This display set will select the specified items by preference + // It has no affect if nothing is specified in the URL. + { + attribute: 'isDisplaySetFromUrl', + weight: 10, + constraint: { + equals: true, + }, + }, + ], +}; + +const priorDisplaySetSelector = { + studyMatchingRules: [ + { + // The priorInstance is a study counter that indicates what position this study is in + // and the value comes from the options parameter. + attribute: 'studyInstanceUIDsIndex', + from: 'options', + required: true, + constraint: { + equals: { value: 1 }, + }, + }, + ], + seriesMatchingRules: [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + // This display set will select the specified items by preference + // It has no affect if nothing is specified in the URL. + { + attribute: 'isDisplaySetFromUrl', + weight: 10, + constraint: { + equals: true, + }, + }, + ], +}; + +const currentDisplaySet = { + id: 'defaultDisplaySetId', +}; + +const priorDisplaySet = { + id: 'priorDisplaySetId', +}; + +const currentViewport0 = { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [currentDisplaySet], +}; + +const currentViewport1 = { + ...currentViewport0, + displaySets: [ + { + ...currentDisplaySet, + matchedDisplaySetsIndex: 1, + }, + ], +}; + +const priorViewport0 = { + ...currentViewport0, + displaySets: [priorDisplaySet], +}; + +const priorViewport1 = { + ...priorViewport0, + displaySets: [ + { + ...priorDisplaySet, + matchedDisplaySetsIndex: 1, + }, + ], +}; + +/** + * This hanging protocol can be activated on the primary mode by directly + * referencing it in a URL or by directly including it within a mode, e.g.: + * `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL + * It is not included in the viewer mode by default. + */ +const hpMNCompare: Types.HangingProtocol.Protocol = { + id: '@ohif/hpCompare', + description: 'Compare two studies in various layouts', + name: 'Compare Two Studies', + numberOfPriorsReferenced: 1, + protocolMatchingRules: [ + { + id: 'Two Studies', + weight: 1000, + attribute: 'StudyInstanceUID', + // The 'from' attribute says where to get the 'attribute' value from. In this case + // prior means the second study in the study list. + from: 'prior', + required: true, + constraint: { + notNull: true, + }, + }, + ], + toolGroupIds: ['default'], + displaySetSelectors: { + defaultDisplaySetId: defaultDisplaySetSelector, + priorDisplaySetId: priorDisplaySetSelector, + }, + defaultViewport: { + viewportOptions: { + viewportType: 'stack', + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: -1, + }, + ], + }, + stages: [ + { + name: '2x2', + stageActivation: { + enabled: { + minViewportsMatched: 4, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [ + currentViewport0, + priorViewport0, + currentViewport1, + priorViewport1, + ], + }, + + { + name: '2x1', + stageActivation: { + enabled: { + minViewportsMatched: 2, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 2, + }, + }, + viewports: [currentViewport0, priorViewport0], + }, + ], +}; + +export default hpMNCompare; diff --git a/extensions/default/src/hpMNGrid.ts b/extensions/default/src/hpMNGrid.ts index 2e3206615..505ff2c63 100644 --- a/extensions/default/src/hpMNGrid.ts +++ b/extensions/default/src/hpMNGrid.ts @@ -7,7 +7,6 @@ import { Types } from '@ohif/core'; * It is not included in the viewer mode by default. */ const hpMN: Types.HangingProtocol.Protocol = { - hasUpdatedPriorsInformation: false, id: '@ohif/mnGrid', description: 'Has various hanging protocol grid layouts', name: '2x2', diff --git a/extensions/tmtv/src/getHangingProtocolModule.js b/extensions/tmtv/src/getHangingProtocolModule.js index 45720c10d..e123a7917 100644 --- a/extensions/tmtv/src/getHangingProtocolModule.js +++ b/extensions/tmtv/src/getHangingProtocolModule.js @@ -217,7 +217,6 @@ const stage4 = { const ptCT = { id: '@ohif/extension-tmtv.hangingProtocolModule.ptCT', locked: true, - hasUpdatedPriorsInformation: false, name: 'Default', createdDate: '2021-02-23T19:22:08.894Z', modifiedDate: '2022-10-04T19:22:08.894Z', diff --git a/platform/app/cypress/integration/MultiStudy.spec.js b/platform/app/cypress/integration/MultiStudy.spec.js new file mode 100644 index 000000000..bfc268975 --- /dev/null +++ b/platform/app/cypress/integration/MultiStudy.spec.js @@ -0,0 +1,28 @@ +describe('OHIF Multi Study', () => { + const beforeSetup = () => { + cy.checkStudyRouteInViewer( + '1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1,1.2.840.113619.2.5.1762583153.215519.978957063.78', + '&hangingProtocolId=@ohif/hpCompare' + ); + cy.expectMinimumThumbnails(4); + cy.initCornerstoneToolsAliases(); + cy.initCommonElementsAliases(); + }; + + it('Should display 2 comparison up', () => { + beforeSetup(); + + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 4); + cy.get('[data-cy="studyDate"]').should(studyDate => { + expect(studyDate.length).to.be.eq(4); + expect(studyDate.text()) + .to.contain('2014') + .contain('2001'); + expect(studyDate.text().indexOf('2014')).to.be.lessThan( + studyDate.text().indexOf('2001') + ); + }); + }); +}); diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js index e85dd1365..6dff6a190 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js @@ -2,7 +2,6 @@ import HangingProtocolService from './HangingProtocolService'; const testProtocol = { id: 'test', - hasUpdatedPriorsInformation: false, name: 'Default', protocolMatchingRules: [ { diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index fe71fd150..d20153909 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -161,6 +161,9 @@ export default class HangingProtocolService extends PubSubService { this.studies = []; this.viewportMatchDetails = new Map(); this.displaySetMatchDetails = new Map(); + this.protocol = undefined; + this.stageIndex = undefined; + this.protocolEngine = undefined; } /** Leave the hanging protocol in the initialized state */ @@ -908,7 +911,7 @@ export default class HangingProtocolService extends PubSubService { const stage = stages[i]; if (stage.id === stageId && stage.status !== 'disabled') { return i; - } + } } return; } @@ -1221,7 +1224,7 @@ export default class HangingProtocolService extends PubSubService { for (const subMatch of match.matchingScores) { if (subMatch.displaySetInstanceUID === displaySetUID) { return; - } + } } throw new Error( `Reused viewport details ${id} with ds ${displaySetUID} not valid` @@ -1421,25 +1424,26 @@ export default class HangingProtocolService extends PubSubService { seriesMatchingRules ); const matchActiveOnly = this.protocol.numberOfPriorsReferenced === -1; - this.studies.forEach(study => { + this.studies.forEach((study, studyInstanceUIDsIndex) => { // Skip non-active if active only if (matchActiveOnly && this.activeStudy !== study) { return; } const studyDisplaySets = this.displaySets.filter( - it => it.StudyInstanceUID === study.StudyInstanceUID + it => it.StudyInstanceUID === study.StudyInstanceUID ); const studyMatchDetails = this.protocolEngine.findMatch( - study, - studyMatchingRules, - { - studies: this.studies, - displaySets: studyDisplaySets, - allDisplaySets: this.displaySets, - displaySetMatchDetails: this.displaySetMatchDetails, - } + study, + studyMatchingRules, + { + studies: this.studies, + displaySets: studyDisplaySets, + allDisplaySets: this.displaySets, + displaySetMatchDetails: this.displaySetMatchDetails, + studyInstanceUIDsIndex, + } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed @@ -1448,10 +1452,10 @@ export default class HangingProtocolService extends PubSubService { } this.debug( - 'study', - study.StudyInstanceUID, - 'display sets #', - studyDisplaySets.length + 'study', + study.StudyInstanceUID, + 'display sets #', + studyDisplaySets.length ); studyDisplaySets.forEach(displaySet => { const { @@ -1460,15 +1464,15 @@ export default class HangingProtocolService extends PubSubService { displaySetInstanceUID, } = displaySet; const seriesMatchDetails = this.protocolEngine.findMatch( - displaySet, - seriesMatchingRules, - // Todo: why we have images here since the matching type does not have it - { - studies: this.studies, - instance: displaySet.images?.[0], - displaySetMatchDetails: this.displaySetMatchDetails, - displaySets: studyDisplaySets, - } + displaySet, + seriesMatchingRules, + // Todo: why we have images here since the matching type does not have it + { + studies: this.studies, + instance: displaySet.images?.[0], + displaySetMatchDetails: this.displaySetMatchDetails, + displaySets: studyDisplaySets, + } ); // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index fec29f489..f8f82ef08 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -270,7 +270,6 @@ export type Protocol = { stages: ProtocolStage[]; // Optional locked?: boolean; - hasUpdatedPriorsInformation?: boolean; name?: string; createdDate?: string; modifiedDate?: string; @@ -284,7 +283,9 @@ export type Protocol = { /* The number of priors required for this hanging protocol. * -1 means that NO priors are referenced, and thus this HP matches * only the active study, whereas 0 means that an unknown number of - * priors is matched. + * priors is matched. Positive values mean at least that many priors are + * required. + * Replaces hasUpdatedPriors */ numberOfPriorsReferenced?: number; syncDataForViewports?: boolean; diff --git a/platform/docs/docs/platform/extensions/modules/hpModule.md b/platform/docs/docs/platform/extensions/modules/hpModule.md index acfb4638b..39d6408e5 100644 --- a/platform/docs/docs/platform/extensions/modules/hpModule.md +++ b/platform/docs/docs/platform/extensions/modules/hpModule.md @@ -36,7 +36,6 @@ Here is an example protocol which if used will hang a 1x3 layout with the first const oneByThreeProtocol = { id: 'oneByThreeProtocol', locked: true, - hasUpdatedPriorsInformation: false, name: 'Default', createdDate: '2021-02-23T19:22:08.894Z', modifiedDate: '2022-10-04T19:22:08.894Z', @@ -241,6 +240,9 @@ A list of criteria for the protocol along with the provided points for ranking. "StudyDescription", "ModalitiesInStudy", "NumberOfStudyRelatedSeries", "NumberOfSeriesRelatedInstances" In addition to these tags, you can also use a custom attribute that you have registered before. We will learn more about this later. + - `from`: Indicates the source of the attribute. This allows getting values + from other objects such as the `prior` instance object instead of from the + current one. @@ -279,6 +281,20 @@ A list of criteria for the protocol along with the provided points for ranking. }, ``` +### `from` attribute +The from attribute allows getting the attribute to test from some other object +such as the prior study, the list of studies overall or another module provided +value. Some of the possible attributes are: + +* `prior`: To get the value from the prior study. +* `activeStudy`: To match the active study +* `studies`: To match the list of studies to display +* `displaySets`: The display sets for the current study +* `allDisplaySets`: Alll available display sets +* `instance`: An instance from the current display set being tested +* `options`: Gets the options object itself, eg if you want a simple top level + value. + ### displaySetSelectors Defines the display sets that the protocol will use for arrangement. @@ -475,22 +491,70 @@ HangingProtocolService.addCustomAttribute( ``` - ## Matching on Prior Study with UID Often it is desired to match a new study to a prior study (e.g., follow up on a surgery). Since the hanging protocols run on displaySets we need to have a way to let OHIF knows that it needs to load the prior study as well. This can -be done by specifying both StudyInstanceUIDs in the URL. Below we are -running OHIF with two studies +be done by specifying both StudyInstanceUIDs in the URL. The additional studies +are then accessible to the hanging protocol. Below we are +running OHIF with two studies, and a comparison hanging protocol available by +default. ```bash -http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5&StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095722.1 +http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5&StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095722.1&hangingprotocolId=@ohif/hpCompare ``` -Now you have access to both studies and you can use matchingRules to match -displaySets. +The `&hangingProtocolId` option forces the specific hanging protocol to be +applied, but the mode can also add the hanging protocols to the default set, +and then the best matching hanging protocol will be applied by the run method. +To match any other studies, it is required to enable the prior matching rules +capability using: +```javascript + // Indicate number of priors used - 0 means any number, -1 means none. + numberOfPriorsReferenced: 1, +``` -Our roadmap includes enabling matching on prior studies without the UID (e.g., baseline, most recent and index). +The matching rule that allows the hanging protocol to be runnable is: + +```javascript + protocolMatchingRules: [ + { + id: 'Two Studies', + weight: 1000, + // This will generate 1.3.6.1.4.1.25403.345050719074.3824.20170125095722.1 + // since that is study instance UID in the prior from instance. + attribute: 'StudyInstanceUID', + // The 'from' attribute says where to get the 'attribute' value from. In this case + // prior means the second study in the study list. + from: 'prior', + required: true, + constraint: { + notNull: true, + }, + }, + ], +``` + +The display set selector selecting the specific study to display is included +in the studyMatchingRules. Note that this rule will cause ONLY the second study +to be matched, so it won't attempt to match anything in other studies. +Additional series level criteria, such as modality rules must be included at the +`seriesMatchingRules`. + +```javascript + studyMatchingRules: [ + { + // The priorInstance is a study counter that indicates what position this study is in + // and the value comes from the options parameter. + attribute: 'studyInstanceUIDsIndex', + from: 'options', + required: true, + constraint: { + equals: { value: 1 }, + }, + }, + ], +``` diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.tsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.tsx index c4124522c..7a20dddde 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.tsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.tsx @@ -133,7 +133,11 @@ const ViewportActionBar = ({ {label} )}
- + {studyDate} {showSeriesDesc && (