diff --git a/platform/core/src/classes/metadata/StudyMetadata.js b/platform/core/src/classes/metadata/StudyMetadata.js index 256b1d0b8..7d86dc5b4 100644 --- a/platform/core/src/classes/metadata/StudyMetadata.js +++ b/platform/core/src/classes/metadata/StudyMetadata.js @@ -9,6 +9,7 @@ import { SeriesMetadata } from './SeriesMetadata'; import { api } from 'dicomweb-client'; // - createStacks import { isImage } from '../../utils/isImage'; +import isDisplaySetReconstructable from '../../utils/isDisplaySetReconstructable'; import isLowPriorityModality from '../../utils/isLowPriorityModality'; export class StudyMetadata extends Metadata { @@ -620,6 +621,16 @@ const makeDisplaySet = (series, instances) => { imageSet.getImage(0).getRawValue('x00200013') ); + const isReconstructable = isDisplaySetReconstructable(series, instances); + + imageSet.isReconstructable = isReconstructable.value; + + if (isReconstructable.missingFrames) { + // TODO -> This is currently unused, but may be used for reconstructing + // Volumes with gaps later on. + imageSet.missingFrames = isReconstructable.missingFrames; + } + return imageSet; }; diff --git a/platform/core/src/utils/isDisplaySetReconstructable.js b/platform/core/src/utils/isDisplaySetReconstructable.js new file mode 100644 index 000000000..950320540 --- /dev/null +++ b/platform/core/src/utils/isDisplaySetReconstructable.js @@ -0,0 +1,157 @@ +/** + * Checks if a series is reconstructable to a 3D volume. + * + * @param {Object} series The `OHIFSeriesMetadata` object. + * @param {Object[]} instances The `OHIFInstanceMetadata` object + */ +export default function isDisplaySetReconstructable(series, instances) { + // Can't reconstruct if we only have one image. + + const modality = series._data.modality; // TODO -> Is there a better way to get this? + const isMultiframe = instances[0].getRawValue('x00280008') > 1; + + if (!constructableModalities.includes(modality)) { + return { value: false }; + } + + if (!isMultiframe && instances.length === 1) { + return { values: false }; + } + + if (isMultiframe) { + return processMultiframe(instances[0]); + } else { + return processSingleframe(instances); + } +} + +function processMultiframe(instance) { + //TODO: deal with multriframe checks! return true for now. + return { value: true }; +} + +function processSingleframe(instances) { + const firstImage = instances[0]; + const firstImageRows = firstImage.getTagValue('x00280010'); + const firstImageColumns = firstImage.getTagValue('x00280011'); + const firstImageSamplesPerPixel = firstImage.getTagValue('x00280002'); + // Note: No need to unpack iop, can compare string form. + const firstImageOrientationPatient = firstImage.getTagValue('x00200037'); + + // Can't reconstruct if we: + // -- Have a different dimensions within a displaySet. + // -- Have a different number of components within a displaySet. + // -- Have different orientations within a displaySet. + for (let i = 1; i < instances.length; i++) { + const instance = instances[i]; + const rows = instance.getTagValue('x00280010'); + const columns = instance.getTagValue('x00280011'); + const samplesPerPixel = instance.getTagValue('x00280002'); + const imageOrientationPatient = instance.getTagValue('x00200037'); + + if ( + rows !== firstImageRows || + columns !== firstImageColumns || + samplesPerPixel !== firstImageSamplesPerPixel || + imageOrientationPatient !== firstImageOrientationPatient + ) { + return { value: false }; + } + } + + let missingFrames = 0; + + // Check if frame spacing is approximately equal within a tolerance. + // If spacing is on a uniform grid but we are missing frames, + // Allow reconstruction, but pass back the number of missing frames. + if (instances.length > 2) { + const firstIpp = _getImagePositionPatient(firstImage); + const lastIpp = _getImagePositionPatient(instances[instances.length - 1]); + const averageSpacingBetweenFrames = + _getPerpendicularDistance(firstIpp, lastIpp) / (instances.length - 1); + + let previousIpp = firstIpp; + + for (let i = 1; i < instances.length; i++) { + const instance = instances[i]; + const ipp = _getImagePositionPatient(instance); + + const spacingBetweenFrames = _getPerpendicularDistance(ipp, previousIpp); + const spacingIssue = _getSpacingIssue( + spacingBetweenFrames, + averageSpacingBetweenFrames + ); + + if (spacingIssue) { + const issue = spacingIssue.issue; + + if (issue === reconstructionIssues.MISSING_FRAMES) { + missingFrames += spacingIssue.missingFrames; + } else if (issue === reconstructionIssues.IRREGULAR_SPACING) { + return { value: false }; + } + } + + previousIpp = ipp; + } + } + + return { value: true, missingFrames }; +} + +// TODO: Is 10% a reasonable tolerance for spacing? +const tolerance = 0.1; + +/** + * Checks for spacing issues. + * + * @param {number} spacing The spacing between two frames. + * @param {number} averageSpacing The average spacing between all frames. + * + * @returns {Object} An object containing the issue and extra information if necessary. + */ +function _getSpacingIssue(spacing, averageSpacing) { + const equalWithinTolerance = + Math.abs(spacing - averageSpacing) < averageSpacing * tolerance; + + if (equalWithinTolerance) { + return; + } + + const multipleOfAverageSpacing = spacing / averageSpacing; + + const numberOfSpacings = Math.round(multipleOfAverageSpacing); + + const errorForEachSpacing = + Math.abs(spacing - numberOfSpacings * averageSpacing) / numberOfSpacings; + + if (errorForEachSpacing < tolerance * averageSpacing) { + return { + issue: reconstructionIssues.MISSING_FRAMES, + missingFrames: numberOfSpacings - 1, + }; + } + + return { issue: reconstructionIssues.IRREGULAR_SPACING }; +} + +function _getImagePositionPatient(instance) { + return instance + .getTagValue('x00200032') + .split('\\') + .map(element => Number(element)); +} + +function _getPerpendicularDistance(a, b) { + return Math.sqrt( + Math.pow(a[0] - b[0], 2) + + Math.pow(a[1] - b[1], 2) + + Math.pow(a[2] - b[2], 2) + ); +} + +const constructableModalities = ['MR', 'CT', 'PT', 'NM']; +const reconstructionIssues = { + MISSING_FRAMES: 'missingframes', + IRREGULAR_SPACING: 'irregularspacing', +}; diff --git a/platform/viewer/cypress/integration/common/OHIFCornerstoneToolbar.spec.js b/platform/viewer/cypress/integration/common/OHIFCornerstoneToolbar.spec.js index d4a15720c..0ce8c3560 100644 --- a/platform/viewer/cypress/integration/common/OHIFCornerstoneToolbar.spec.js +++ b/platform/viewer/cypress/integration/common/OHIFCornerstoneToolbar.spec.js @@ -42,9 +42,6 @@ describe('OHIF Cornerstone Toolbar', () => { cy.get('@moreBtn') .should('be.visible') .contains('More'); - cy.get('@twodmprBtn') - .should('be.visible') - .contains('2D MPR'); cy.get('@layoutBtn') .should('be.visible') .contains('Layout'); diff --git a/platform/viewer/cypress/support/aliases.js b/platform/viewer/cypress/support/aliases.js index b914c506c..37fa12ec2 100644 --- a/platform/viewer/cypress/support/aliases.js +++ b/platform/viewer/cypress/support/aliases.js @@ -10,7 +10,6 @@ export function initCornerstoneToolsAliases() { cy.get('.ToolbarRow > :nth-child(9)').as('resetBtn'); cy.get('.ToolbarRow > :nth-child(10)').as('cineBtn'); cy.get('.expandableToolMenu').as('moreBtn'); - cy.get('.PluginSwitch > .toolbar-button').as('twodmprBtn'); cy.get('.btn-group > .toolbar-button').as('layoutBtn'); } diff --git a/platform/viewer/src/connectedComponents/ConnectedPluginSwitch.js b/platform/viewer/src/connectedComponents/ConnectedPluginSwitch.js index 0e46e77f9..130bd0eba 100644 --- a/platform/viewer/src/connectedComponents/ConnectedPluginSwitch.js +++ b/platform/viewer/src/connectedComponents/ConnectedPluginSwitch.js @@ -6,10 +6,8 @@ import { connect } from 'react-redux'; const { setLayout } = OHIF.redux.actions; -const ConnectedPluginSwitch = (props) => { - return ( - - ) +const ConnectedPluginSwitch = props => { + return ; }; const mapStateToProps = state => { @@ -26,7 +24,7 @@ const mapDispatchToProps = dispatch => { return { setLayout: data => { dispatch(setLayout(data)); - } + }, }; }; @@ -40,13 +38,12 @@ const mapDispatchToProps = dispatch => { }*/ const mergeProps = (propsFromState, propsFromDispatch, ownProps) => { - //const { activeViewportIndex, layout } = propsFromState; + const { activeViewportIndex, viewportSpecificData } = propsFromState; + const { studies } = ownProps; const { setLayout } = propsFromDispatch; - // TODO: Do not display certain options if the current display set - // cannot be displayed using these view types const mpr = () => { - commandsManager.runCommand("mpr2d"); + commandsManager.runCommand('mpr2d'); }; const exitMpr = () => { @@ -61,7 +58,10 @@ const mergeProps = (propsFromState, propsFromDispatch, ownProps) => { return { mpr, - exitMpr + exitMpr, + activeViewportIndex, + viewportSpecificData, + studies, }; }; diff --git a/platform/viewer/src/connectedComponents/PluginSwitch.js b/platform/viewer/src/connectedComponents/PluginSwitch.js index 8c4666ea5..fd95e5627 100644 --- a/platform/viewer/src/connectedComponents/PluginSwitch.js +++ b/platform/viewer/src/connectedComponents/PluginSwitch.js @@ -6,7 +6,10 @@ import './PluginSwitch.css'; class PluginSwitch extends Component { static propTypes = { mpr: PropTypes.func, - exitMpr: PropTypes.func + activeViewportIndex: PropTypes.number, + viewportSpecificData: PropTypes.object, + studies: PropTypes.array, + exitMpr: PropTypes.func, }; static defaultProps = {}; @@ -14,8 +17,8 @@ class PluginSwitch extends Component { super(props); this.state = { isPlugSwitchOn: false, - label: "2D MPR", - icon: "cube" + label: '2D MPR', + icon: 'cube', }; } @@ -23,15 +26,15 @@ class PluginSwitch extends Component { if (this.state.isPlugSwitchOn) { this.setState({ isPlugSwitchOn: false, - label: "2D MPR", - icon: "cube" + label: '2D MPR', + icon: 'cube', }); this.props.exitMpr(); } else { this.setState({ isPlugSwitchOn: true, - label: "Exit 2D MPR", - icon: "times" + label: 'Exit 2D MPR', + icon: 'times', }); this.props.mpr(); } @@ -40,12 +43,67 @@ class PluginSwitch extends Component { render() { const { label, icon } = this.state; + // Render exit mpr if switched on, otherwise check if mpr button should be displayed. + + debugger; + + const shouldRender = + this.state.isPlugSwitchOn || _shouldRenderMpr2DButton.call(this); + return ( -
- -
+ <> + {shouldRender && ( +
+ +
+ )} + ); } } +function _shouldRenderMpr2DButton() { + const { viewportSpecificData, studies, activeViewportIndex } = this.props; + + if (!viewportSpecificData[activeViewportIndex]) { + return; + } + + const { displaySetInstanceUid, studyInstanceUid } = viewportSpecificData[ + activeViewportIndex + ]; + + const displaySet = _getDisplaySet( + studies, + studyInstanceUid, + displaySetInstanceUid + ); + + if (!displaySet) { + return; + } + + return displaySet.isReconstructable; +} + +function _getDisplaySet(studies, studyInstanceUid, displaySetInstanceUid) { + const study = studies.find( + study => study.studyInstanceUid === studyInstanceUid + ); + + if (!study) { + return; + } + + const displaySet = study.displaySets.find(set => { + return set.displaySetInstanceUid === displaySetInstanceUid; + }); + + return displaySet; +} + export default PluginSwitch; diff --git a/platform/viewer/src/connectedComponents/ToolbarRow.js b/platform/viewer/src/connectedComponents/ToolbarRow.js index ef5705d0c..c4fd2667c 100644 --- a/platform/viewer/src/connectedComponents/ToolbarRow.js +++ b/platform/viewer/src/connectedComponents/ToolbarRow.js @@ -25,6 +25,7 @@ class ToolbarRow extends Component { selectedRightSidePanel: PropTypes.string.isRequired, handleSidePanelChange: PropTypes.func, activeContexts: PropTypes.arrayOf(PropTypes.string).isRequired, + studies: PropTypes.array, }; constructor(props) { @@ -128,7 +129,7 @@ class ToolbarRow extends Component { {buttonComponents} - +
{/**/}