Skip to content

Commit 2d75e01

Browse files
authored
feat: 🎸 Only allow reconstruction of datasets that make sense (#1010)
* feat: 🎸 Only allow reconstruction of datasets that make sense Only allow reconstruction of datasets which are imaging data, that have frames in the same orientation, with the same size and make sense to be reconstructed in 3D. Closes: #561
1 parent 5c5a494 commit 2d75e01

8 files changed

Lines changed: 249 additions & 26 deletions

File tree

platform/core/src/classes/metadata/StudyMetadata.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { SeriesMetadata } from './SeriesMetadata';
99
import { api } from 'dicomweb-client';
1010
// - createStacks
1111
import { isImage } from '../../utils/isImage';
12+
import isDisplaySetReconstructable from '../../utils/isDisplaySetReconstructable';
1213
import isLowPriorityModality from '../../utils/isLowPriorityModality';
1314

1415
export class StudyMetadata extends Metadata {
@@ -620,6 +621,16 @@ const makeDisplaySet = (series, instances) => {
620621
imageSet.getImage(0).getRawValue('x00200013')
621622
);
622623

624+
const isReconstructable = isDisplaySetReconstructable(series, instances);
625+
626+
imageSet.isReconstructable = isReconstructable.value;
627+
628+
if (isReconstructable.missingFrames) {
629+
// TODO -> This is currently unused, but may be used for reconstructing
630+
// Volumes with gaps later on.
631+
imageSet.missingFrames = isReconstructable.missingFrames;
632+
}
633+
623634
return imageSet;
624635
};
625636

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Checks if a series is reconstructable to a 3D volume.
3+
*
4+
* @param {Object} series The `OHIFSeriesMetadata` object.
5+
* @param {Object[]} instances The `OHIFInstanceMetadata` object
6+
*/
7+
export default function isDisplaySetReconstructable(series, instances) {
8+
// Can't reconstruct if we only have one image.
9+
10+
const modality = series._data.modality; // TODO -> Is there a better way to get this?
11+
const isMultiframe = instances[0].getRawValue('x00280008') > 1;
12+
13+
if (!constructableModalities.includes(modality)) {
14+
return { value: false };
15+
}
16+
17+
if (!isMultiframe && instances.length === 1) {
18+
return { values: false };
19+
}
20+
21+
if (isMultiframe) {
22+
return processMultiframe(instances[0]);
23+
} else {
24+
return processSingleframe(instances);
25+
}
26+
}
27+
28+
function processMultiframe(instance) {
29+
//TODO: deal with multriframe checks! return true for now.
30+
return { value: true };
31+
}
32+
33+
function processSingleframe(instances) {
34+
const firstImage = instances[0];
35+
const firstImageRows = firstImage.getTagValue('x00280010');
36+
const firstImageColumns = firstImage.getTagValue('x00280011');
37+
const firstImageSamplesPerPixel = firstImage.getTagValue('x00280002');
38+
// Note: No need to unpack iop, can compare string form.
39+
const firstImageOrientationPatient = firstImage.getTagValue('x00200037');
40+
41+
// Can't reconstruct if we:
42+
// -- Have a different dimensions within a displaySet.
43+
// -- Have a different number of components within a displaySet.
44+
// -- Have different orientations within a displaySet.
45+
for (let i = 1; i < instances.length; i++) {
46+
const instance = instances[i];
47+
const rows = instance.getTagValue('x00280010');
48+
const columns = instance.getTagValue('x00280011');
49+
const samplesPerPixel = instance.getTagValue('x00280002');
50+
const imageOrientationPatient = instance.getTagValue('x00200037');
51+
52+
if (
53+
rows !== firstImageRows ||
54+
columns !== firstImageColumns ||
55+
samplesPerPixel !== firstImageSamplesPerPixel ||
56+
imageOrientationPatient !== firstImageOrientationPatient
57+
) {
58+
return { value: false };
59+
}
60+
}
61+
62+
let missingFrames = 0;
63+
64+
// Check if frame spacing is approximately equal within a tolerance.
65+
// If spacing is on a uniform grid but we are missing frames,
66+
// Allow reconstruction, but pass back the number of missing frames.
67+
if (instances.length > 2) {
68+
const firstIpp = _getImagePositionPatient(firstImage);
69+
const lastIpp = _getImagePositionPatient(instances[instances.length - 1]);
70+
const averageSpacingBetweenFrames =
71+
_getPerpendicularDistance(firstIpp, lastIpp) / (instances.length - 1);
72+
73+
let previousIpp = firstIpp;
74+
75+
for (let i = 1; i < instances.length; i++) {
76+
const instance = instances[i];
77+
const ipp = _getImagePositionPatient(instance);
78+
79+
const spacingBetweenFrames = _getPerpendicularDistance(ipp, previousIpp);
80+
const spacingIssue = _getSpacingIssue(
81+
spacingBetweenFrames,
82+
averageSpacingBetweenFrames
83+
);
84+
85+
if (spacingIssue) {
86+
const issue = spacingIssue.issue;
87+
88+
if (issue === reconstructionIssues.MISSING_FRAMES) {
89+
missingFrames += spacingIssue.missingFrames;
90+
} else if (issue === reconstructionIssues.IRREGULAR_SPACING) {
91+
return { value: false };
92+
}
93+
}
94+
95+
previousIpp = ipp;
96+
}
97+
}
98+
99+
return { value: true, missingFrames };
100+
}
101+
102+
// TODO: Is 10% a reasonable tolerance for spacing?
103+
const tolerance = 0.1;
104+
105+
/**
106+
* Checks for spacing issues.
107+
*
108+
* @param {number} spacing The spacing between two frames.
109+
* @param {number} averageSpacing The average spacing between all frames.
110+
*
111+
* @returns {Object} An object containing the issue and extra information if necessary.
112+
*/
113+
function _getSpacingIssue(spacing, averageSpacing) {
114+
const equalWithinTolerance =
115+
Math.abs(spacing - averageSpacing) < averageSpacing * tolerance;
116+
117+
if (equalWithinTolerance) {
118+
return;
119+
}
120+
121+
const multipleOfAverageSpacing = spacing / averageSpacing;
122+
123+
const numberOfSpacings = Math.round(multipleOfAverageSpacing);
124+
125+
const errorForEachSpacing =
126+
Math.abs(spacing - numberOfSpacings * averageSpacing) / numberOfSpacings;
127+
128+
if (errorForEachSpacing < tolerance * averageSpacing) {
129+
return {
130+
issue: reconstructionIssues.MISSING_FRAMES,
131+
missingFrames: numberOfSpacings - 1,
132+
};
133+
}
134+
135+
return { issue: reconstructionIssues.IRREGULAR_SPACING };
136+
}
137+
138+
function _getImagePositionPatient(instance) {
139+
return instance
140+
.getTagValue('x00200032')
141+
.split('\\')
142+
.map(element => Number(element));
143+
}
144+
145+
function _getPerpendicularDistance(a, b) {
146+
return Math.sqrt(
147+
Math.pow(a[0] - b[0], 2) +
148+
Math.pow(a[1] - b[1], 2) +
149+
Math.pow(a[2] - b[2], 2)
150+
);
151+
}
152+
153+
const constructableModalities = ['MR', 'CT', 'PT', 'NM'];
154+
const reconstructionIssues = {
155+
MISSING_FRAMES: 'missingframes',
156+
IRREGULAR_SPACING: 'irregularspacing',
157+
};

platform/viewer/cypress/integration/common/OHIFCornerstoneToolbar.spec.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@ describe('OHIF Cornerstone Toolbar', () => {
4242
cy.get('@moreBtn')
4343
.should('be.visible')
4444
.contains('More');
45-
cy.get('@twodmprBtn')
46-
.should('be.visible')
47-
.contains('2D MPR');
4845
cy.get('@layoutBtn')
4946
.should('be.visible')
5047
.contains('Layout');

platform/viewer/cypress/support/aliases.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ export function initCornerstoneToolsAliases() {
1010
cy.get('.ToolbarRow > :nth-child(9)').as('resetBtn');
1111
cy.get('.ToolbarRow > :nth-child(10)').as('cineBtn');
1212
cy.get('.expandableToolMenu').as('moreBtn');
13-
cy.get('.PluginSwitch > .toolbar-button').as('twodmprBtn');
1413
cy.get('.btn-group > .toolbar-button').as('layoutBtn');
1514
}
1615

platform/viewer/src/connectedComponents/ConnectedPluginSwitch.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ import { connect } from 'react-redux';
66

77
const { setLayout } = OHIF.redux.actions;
88

9-
const ConnectedPluginSwitch = (props) => {
10-
return (
11-
<PluginSwitch {...props} />
12-
)
9+
const ConnectedPluginSwitch = props => {
10+
return <PluginSwitch {...props} />;
1311
};
1412

1513
const mapStateToProps = state => {
@@ -26,7 +24,7 @@ const mapDispatchToProps = dispatch => {
2624
return {
2725
setLayout: data => {
2826
dispatch(setLayout(data));
29-
}
27+
},
3028
};
3129
};
3230

@@ -40,13 +38,12 @@ const mapDispatchToProps = dispatch => {
4038
}*/
4139

4240
const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
43-
//const { activeViewportIndex, layout } = propsFromState;
41+
const { activeViewportIndex, viewportSpecificData } = propsFromState;
42+
const { studies } = ownProps;
4443
const { setLayout } = propsFromDispatch;
4544

46-
// TODO: Do not display certain options if the current display set
47-
// cannot be displayed using these view types
4845
const mpr = () => {
49-
commandsManager.runCommand("mpr2d");
46+
commandsManager.runCommand('mpr2d');
5047
};
5148

5249
const exitMpr = () => {
@@ -61,7 +58,10 @@ const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
6158

6259
return {
6360
mpr,
64-
exitMpr
61+
exitMpr,
62+
activeViewportIndex,
63+
viewportSpecificData,
64+
studies,
6565
};
6666
};
6767

platform/viewer/src/connectedComponents/PluginSwitch.js

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,35 @@ import './PluginSwitch.css';
66
class PluginSwitch extends Component {
77
static propTypes = {
88
mpr: PropTypes.func,
9-
exitMpr: PropTypes.func
9+
activeViewportIndex: PropTypes.number,
10+
viewportSpecificData: PropTypes.object,
11+
studies: PropTypes.array,
12+
exitMpr: PropTypes.func,
1013
};
1114

1215
static defaultProps = {};
1316
constructor(props) {
1417
super(props);
1518
this.state = {
1619
isPlugSwitchOn: false,
17-
label: "2D MPR",
18-
icon: "cube"
20+
label: '2D MPR',
21+
icon: 'cube',
1922
};
2023
}
2124

2225
handleClick = () => {
2326
if (this.state.isPlugSwitchOn) {
2427
this.setState({
2528
isPlugSwitchOn: false,
26-
label: "2D MPR",
27-
icon: "cube"
29+
label: '2D MPR',
30+
icon: 'cube',
2831
});
2932
this.props.exitMpr();
3033
} else {
3134
this.setState({
3235
isPlugSwitchOn: true,
33-
label: "Exit 2D MPR",
34-
icon: "times"
36+
label: 'Exit 2D MPR',
37+
icon: 'times',
3538
});
3639
this.props.mpr();
3740
}
@@ -40,12 +43,67 @@ class PluginSwitch extends Component {
4043
render() {
4144
const { label, icon } = this.state;
4245

46+
// Render exit mpr if switched on, otherwise check if mpr button should be displayed.
47+
48+
debugger;
49+
50+
const shouldRender =
51+
this.state.isPlugSwitchOn || _shouldRenderMpr2DButton.call(this);
52+
4353
return (
44-
<div className="PluginSwitch">
45-
<ToolbarButton label={label} icon={icon} onClick={this.handleClick} />
46-
</div>
54+
<>
55+
{shouldRender && (
56+
<div className="PluginSwitch">
57+
<ToolbarButton
58+
label={label}
59+
icon={icon}
60+
onClick={this.handleClick}
61+
/>
62+
</div>
63+
)}
64+
</>
4765
);
4866
}
4967
}
5068

69+
function _shouldRenderMpr2DButton() {
70+
const { viewportSpecificData, studies, activeViewportIndex } = this.props;
71+
72+
if (!viewportSpecificData[activeViewportIndex]) {
73+
return;
74+
}
75+
76+
const { displaySetInstanceUid, studyInstanceUid } = viewportSpecificData[
77+
activeViewportIndex
78+
];
79+
80+
const displaySet = _getDisplaySet(
81+
studies,
82+
studyInstanceUid,
83+
displaySetInstanceUid
84+
);
85+
86+
if (!displaySet) {
87+
return;
88+
}
89+
90+
return displaySet.isReconstructable;
91+
}
92+
93+
function _getDisplaySet(studies, studyInstanceUid, displaySetInstanceUid) {
94+
const study = studies.find(
95+
study => study.studyInstanceUid === studyInstanceUid
96+
);
97+
98+
if (!study) {
99+
return;
100+
}
101+
102+
const displaySet = study.displaySets.find(set => {
103+
return set.displaySetInstanceUid === displaySetInstanceUid;
104+
});
105+
106+
return displaySet;
107+
}
108+
51109
export default PluginSwitch;

platform/viewer/src/connectedComponents/ToolbarRow.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class ToolbarRow extends Component {
2525
selectedRightSidePanel: PropTypes.string.isRequired,
2626
handleSidePanelChange: PropTypes.func,
2727
activeContexts: PropTypes.arrayOf(PropTypes.string).isRequired,
28+
studies: PropTypes.array,
2829
};
2930

3031
constructor(props) {
@@ -128,7 +129,7 @@ class ToolbarRow extends Component {
128129
</div>
129130
{buttonComponents}
130131
<ConnectedLayoutButton />
131-
<ConnectedPluginSwitch />
132+
<ConnectedPluginSwitch studies={this.props.studies} />
132133
<div
133134
className="pull-right m-t-1 rm-x-1"
134135
style={{ marginLeft: 'auto' }}
@@ -229,7 +230,6 @@ function _getButtonComponents(toolbarButtons, activeButtons) {
229230
});
230231
}
231232

232-
233233
/**
234234
* A handy way for us to handle different button types. IE. firing commands for
235235
* buttons, or initiation built in behavior.

platform/viewer/src/connectedComponents/Viewer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ class Viewer extends Component {
279279

280280
this.setState(updatedState);
281281
}}
282+
studies={this.props.studies}
282283
/>
283284

284285
{/*<ConnectedStudyLoadingMonitor studies={this.props.studies} />*/}

0 commit comments

Comments
 (0)