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
This commit is contained in:
parent
5c5a49486d
commit
2d75e01ea0
@ -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;
|
||||
};
|
||||
|
||||
|
||||
157
platform/core/src/utils/isDisplaySetReconstructable.js
Normal file
157
platform/core/src/utils/isDisplaySetReconstructable.js
Normal file
@ -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',
|
||||
};
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -6,10 +6,8 @@ import { connect } from 'react-redux';
|
||||
|
||||
const { setLayout } = OHIF.redux.actions;
|
||||
|
||||
const ConnectedPluginSwitch = (props) => {
|
||||
return (
|
||||
<PluginSwitch {...props} />
|
||||
)
|
||||
const ConnectedPluginSwitch = props => {
|
||||
return <PluginSwitch {...props} />;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="PluginSwitch">
|
||||
<ToolbarButton label={label} icon={icon} onClick={this.handleClick} />
|
||||
</div>
|
||||
<>
|
||||
{shouldRender && (
|
||||
<div className="PluginSwitch">
|
||||
<ToolbarButton
|
||||
label={label}
|
||||
icon={icon}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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 {
|
||||
</div>
|
||||
{buttonComponents}
|
||||
<ConnectedLayoutButton />
|
||||
<ConnectedPluginSwitch />
|
||||
<ConnectedPluginSwitch studies={this.props.studies} />
|
||||
<div
|
||||
className="pull-right m-t-1 rm-x-1"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
@ -229,7 +230,6 @@ function _getButtonComponents(toolbarButtons, activeButtons) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A handy way for us to handle different button types. IE. firing commands for
|
||||
* buttons, or initiation built in behavior.
|
||||
|
||||
@ -279,6 +279,7 @@ class Viewer extends Component {
|
||||
|
||||
this.setState(updatedState);
|
||||
}}
|
||||
studies={this.props.studies}
|
||||
/>
|
||||
|
||||
{/*<ConnectedStudyLoadingMonitor studies={this.props.studies} />*/}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user