From 2d75e01ea07f4322ccfaccd108089299be43d0df Mon Sep 17 00:00:00 2001 From: James Petts Date: Tue, 15 Oct 2019 16:08:53 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Only=20allow=20reconstru?= =?UTF-8?q?ction=20of=20datasets=20that=20make=20sense=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../src/classes/metadata/StudyMetadata.js | 11 ++ .../src/utils/isDisplaySetReconstructable.js | 157 ++++++++++++++++++ .../common/OHIFCornerstoneToolbar.spec.js | 3 - platform/viewer/cypress/support/aliases.js | 1 - .../ConnectedPluginSwitch.js | 20 +-- .../src/connectedComponents/PluginSwitch.js | 78 +++++++-- .../src/connectedComponents/ToolbarRow.js | 4 +- .../viewer/src/connectedComponents/Viewer.js | 1 + 8 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 platform/core/src/utils/isDisplaySetReconstructable.js 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} - +
{/**/}