feat(hp priors): Study comparison hanging protocol (#3579)

This commit is contained in:
Bill Wallace 2023-08-16 10:15:12 -04:00 committed by GitHub
parent ecbe7c56e0
commit 53c16b928f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 337 additions and 45 deletions

View File

@ -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: [],

View File

@ -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: [],

View File

@ -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',

View File

@ -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,
},
];
}

View File

@ -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;

View File

@ -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',

View File

@ -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',

View File

@ -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')
);
});
});
});

View File

@ -2,7 +2,6 @@ import HangingProtocolService from './HangingProtocolService';
const testProtocol = {
id: 'test',
hasUpdatedPriorsInformation: false,
name: 'Default',
protocolMatchingRules: [
{

View File

@ -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

View File

@ -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;

View File

@ -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 },
},
},
],
```

View File

@ -133,7 +133,11 @@ const ViewportActionBar = ({
<span className="ml-1 text-aqua-pale text-large">{label}</span>
)}
<div className={separatorClasses}></div>
<span ref={studyDateElemRef} className={studyDateClasses()}>
<span
data-cy="studyDate"
ref={studyDateElemRef}
className={studyDateClasses()}
>
{studyDate}
</span>
{showSeriesDesc && (