feat(RT): add dicom RT support via volume viewports (#3310)
* feat: initial RT support * make the segmentation service work with representation data * feat: make segmentation service work with representations * fix rtss vis * fix: rt hydration * fix the rendering of rt names * fix imports * refactor: Modify status and click handling for hydration of RTStructures Modify status and click handling for hydration of RTStructures by renaming `onPillClick` to `onStatusClick` in `OHIFCornerstoneRTViewport.tsx` and `_getStatusComponent.tsx` files. Also, update initial segmentation configurations in `PanelSegmentation.tsx` and simplify configuration changes and values for segmentation service in `SegmentationService.ts`. Finally, remove console debug in `CornerstoneViewportService.ts`. * wip for highlighting contours * refactor rt displayset code * review code update * update cornerstone dependencies * refactor: Update license year, version number, and minor code cleanup This commit updates the license year in several files, updates the version number in package.json, and contains minor code cleanup in two files. * add bulkdataURI retrieve for RT * fix package version * apply review comments * apply review comments * apply review comments * feat(panels): refactor and streamline segmentation configuration and inputs Rewrote state hooks and streamlined the configuration input for `PanelSegmentation` to be more verbose and reusable. Included several new input types, including the `InputRange` component which now shows a fixed floating value based on the step provided. The `SegmentationConfig` component now works with dynamic values controlled by `initialConfig`. These changes should improve function usability and make the code more maintainable going forward. * fix various bugs * fix contour delete by upgrade cs3d version * feat(viewport, inputNumber, segmentationConfig, orthanc): Implement minimum and maximum values for input number components, and useBulkDataURI for Orthanc configuration. Compare measurement view planes with absolute viewport view planes in Cornerstone viewport. * update yarn lock
This commit is contained in:
1 parent
69d8e6a191
commit
66863281c4
60 files changed
+2488
-678
No files matched your search
@@ -0,0 +1,415 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import OHIF, { utils } from '@ohif/core';
|
||||
import {
|
||||
Notification,
|
||||
ViewportActionBar,
|
||||
useViewportGrid,
|
||||
useViewportDialog,
|
||||
LoadingIndicatorTotalPercent,
|
||||
} from '@ohif/ui';
|
||||
|
||||
import _hydrateRTdisplaySet from '../utils/_hydrateRT';
|
||||
import promptHydrateRT from '../utils/promptHydrateRT';
|
||||
import _getStatusComponent from './_getStatusComponent';
|
||||
import createRTToolGroupAndAddTools from '../utils/initRTToolGroup';
|
||||
import _hydrateRTDisplaySet from '../utils/_hydrateRT';
|
||||
|
||||
const { formatDate } = utils;
|
||||
const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup';
|
||||
|
||||
function OHIFCornerstoneRTViewport(props) {
|
||||
const {
|
||||
children,
|
||||
displaySets,
|
||||
viewportOptions,
|
||||
viewportIndex,
|
||||
viewportLabel,
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
toolGroupService,
|
||||
segmentationService,
|
||||
uiNotificationService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const toolGroupId = `${RT_TOOLGROUP_BASE_NAME}-${viewportIndex}`;
|
||||
|
||||
// RT viewport will always have a single display set
|
||||
if (displaySets.length > 1) {
|
||||
throw new Error('RT viewport should only have a single display set');
|
||||
}
|
||||
|
||||
const rtDisplaySet = displaySets[0];
|
||||
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
const [viewportDialogState, viewportDialogApi] = useViewportDialog();
|
||||
|
||||
// States
|
||||
const [isToolGroupCreated, setToolGroupCreated] = useState(false);
|
||||
const [selectedSegment, setSelectedSegment] = useState(1);
|
||||
|
||||
// Hydration means that the RT is opened and segments are loaded into the
|
||||
// segmentation panel, and RT is also rendered on any viewport that is in the
|
||||
// same frameOfReferenceUID as the referencedSeriesUID of the RT. However,
|
||||
// loading basically means RT loading over network and bit unpacking of the
|
||||
// RT data.
|
||||
const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated);
|
||||
const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded);
|
||||
const [element, setElement] = useState(null);
|
||||
const [processingProgress, setProcessingProgress] = useState({
|
||||
percentComplete: null,
|
||||
totalSegments: null,
|
||||
});
|
||||
|
||||
// refs
|
||||
const referencedDisplaySetRef = useRef(null);
|
||||
|
||||
const { viewports, activeViewportIndex } = viewportGrid;
|
||||
|
||||
const referencedDisplaySet = rtDisplaySet.getReferenceDisplaySet();
|
||||
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
|
||||
referencedDisplaySet
|
||||
);
|
||||
|
||||
referencedDisplaySetRef.current = {
|
||||
displaySet: referencedDisplaySet,
|
||||
metadata: referencedDisplaySetMetadata,
|
||||
};
|
||||
/**
|
||||
* OnElementEnabled callback which is called after the cornerstoneExtension
|
||||
* has enabled the element. Note: we delegate all the image rendering to
|
||||
* cornerstoneExtension, so we don't need to do anything here regarding
|
||||
* the image rendering, element enabling etc.
|
||||
*/
|
||||
const onElementEnabled = evt => {
|
||||
setElement(evt.detail.element);
|
||||
};
|
||||
|
||||
const onElementDisabled = () => {
|
||||
setElement(null);
|
||||
};
|
||||
|
||||
const getCornerstoneViewport = useCallback(() => {
|
||||
const { component: Component } = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.viewportModule.cornerstone'
|
||||
);
|
||||
|
||||
const {
|
||||
displaySet: referencedDisplaySet,
|
||||
} = referencedDisplaySetRef.current;
|
||||
|
||||
// Todo: jump to the center of the first segment
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
displaySets={[referencedDisplaySet, rtDisplaySet]}
|
||||
viewportOptions={{
|
||||
viewportType: 'volume',
|
||||
toolGroupId: toolGroupId,
|
||||
orientation: viewportOptions.orientation,
|
||||
viewportId: viewportOptions.viewportId,
|
||||
}}
|
||||
onElementEnabled={onElementEnabled}
|
||||
onElementDisabled={onElementDisabled}
|
||||
// initialImageIndex={initialImageIndex}
|
||||
></Component>
|
||||
);
|
||||
}, [viewportIndex, rtDisplaySet, toolGroupId]);
|
||||
|
||||
const onSegmentChange = useCallback(
|
||||
direction => {
|
||||
direction = direction === 'left' ? -1 : 1;
|
||||
const segmentationId = rtDisplaySet.displaySetInstanceUID;
|
||||
const segmentation = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
const { segments } = segmentation;
|
||||
|
||||
const numberOfSegments = Object.keys(segments).length;
|
||||
|
||||
let newSelectedSegmentIndex = selectedSegment + direction;
|
||||
|
||||
// Segment 0 is always background
|
||||
if (newSelectedSegmentIndex >= numberOfSegments - 1) {
|
||||
newSelectedSegmentIndex = 1;
|
||||
} else if (newSelectedSegmentIndex === 0) {
|
||||
newSelectedSegmentIndex = numberOfSegments - 1;
|
||||
}
|
||||
|
||||
segmentationService.jumpToSegmentCenter(
|
||||
segmentationId,
|
||||
newSelectedSegmentIndex,
|
||||
toolGroupId
|
||||
);
|
||||
setSelectedSegment(newSelectedSegmentIndex);
|
||||
},
|
||||
[selectedSegment]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rtIsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
promptHydrateRT({
|
||||
servicesManager,
|
||||
viewportIndex,
|
||||
rtDisplaySet,
|
||||
}).then(isHydrated => {
|
||||
if (isHydrated) {
|
||||
setIsHydrated(true);
|
||||
}
|
||||
});
|
||||
}, [servicesManager, viewportIndex, rtDisplaySet, rtIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
|
||||
evt => {
|
||||
if (
|
||||
evt.rtDisplaySet.displaySetInstanceUID ===
|
||||
rtDisplaySet.displaySetInstanceUID
|
||||
) {
|
||||
setRtIsLoading(false);
|
||||
}
|
||||
|
||||
if (evt.overlappingSegments) {
|
||||
uiNotificationService.show({
|
||||
title: 'Overlapping Segments',
|
||||
message:
|
||||
'Overlapping segments detected which is not currently supported',
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [rtDisplaySet]);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
|
||||
({ percentComplete, numSegments }) => {
|
||||
setProcessingProgress({
|
||||
percentComplete,
|
||||
totalSegments: numSegments,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [rtDisplaySet]);
|
||||
|
||||
/**
|
||||
Cleanup the SEG viewport when the viewport is destroyed
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
|
||||
({ displaySetInstanceUIDs }) => {
|
||||
const activeViewport = viewports[activeViewportIndex];
|
||||
if (
|
||||
displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)
|
||||
) {
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportIndex: activeViewportIndex,
|
||||
displaySetInstanceUIDs: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
onDisplaySetsRemovedSubscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
|
||||
|
||||
if (toolGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
toolGroup = createRTToolGroupAndAddTools(
|
||||
toolGroupService,
|
||||
customizationService,
|
||||
toolGroupId
|
||||
);
|
||||
|
||||
setToolGroupCreated(true);
|
||||
|
||||
return () => {
|
||||
// remove the segmentation representations if seg displayset changed
|
||||
segmentationService.removeSegmentationRepresentationFromToolGroup(
|
||||
toolGroupId
|
||||
);
|
||||
|
||||
toolGroupService.destroyToolGroup(toolGroupId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(rtDisplaySet.isHydrated);
|
||||
|
||||
return () => {
|
||||
// remove the segmentation representations if seg displayset changed
|
||||
segmentationService.removeSegmentationRepresentationFromToolGroup(
|
||||
toolGroupId
|
||||
);
|
||||
referencedDisplaySetRef.current = null;
|
||||
};
|
||||
}, [rtDisplaySet]);
|
||||
|
||||
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
let childrenWithProps = null;
|
||||
|
||||
if (
|
||||
!referencedDisplaySetRef.current ||
|
||||
referencedDisplaySet.displaySetInstanceUID !==
|
||||
referencedDisplaySetRef.current.displaySet.displaySetInstanceUID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children && children.length) {
|
||||
childrenWithProps = children.map((child, index) => {
|
||||
return (
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
viewportIndex,
|
||||
key: index,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
PatientID,
|
||||
PatientName,
|
||||
PatientSex,
|
||||
PatientAge,
|
||||
SliceThickness,
|
||||
ManufacturerModelName,
|
||||
StudyDate,
|
||||
SeriesDescription,
|
||||
SpacingBetweenSlices,
|
||||
SeriesNumber,
|
||||
} = referencedDisplaySetRef.current.metadata;
|
||||
|
||||
const onStatusClick = async () => {
|
||||
const isHydrated = await _hydrateRTDisplaySet({
|
||||
rtDisplaySet,
|
||||
viewportIndex,
|
||||
servicesManager,
|
||||
});
|
||||
|
||||
setIsHydrated(isHydrated);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewportActionBar
|
||||
onDoubleClick={evt => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}}
|
||||
onArrowsClick={onSegmentChange}
|
||||
getStatusComponent={() => {
|
||||
return _getStatusComponent({
|
||||
isHydrated,
|
||||
onStatusClick,
|
||||
});
|
||||
}}
|
||||
studyData={{
|
||||
label: viewportLabel,
|
||||
useAltStyling: true,
|
||||
studyDate: formatDate(StudyDate),
|
||||
currentSeries: SeriesNumber,
|
||||
seriesDescription: `RT Viewport ${SeriesDescription}`,
|
||||
patientInformation: {
|
||||
patientName: PatientName
|
||||
? OHIF.utils.formatPN(PatientName.Alphabetic)
|
||||
: '',
|
||||
patientSex: PatientSex || '',
|
||||
patientAge: PatientAge || '',
|
||||
MRN: PatientID || '',
|
||||
thickness: SliceThickness ? `${SliceThickness.toFixed(2)}mm` : '',
|
||||
spacing:
|
||||
SpacingBetweenSlices !== undefined
|
||||
? `${SpacingBetweenSlices.toFixed(2)}mm`
|
||||
: '',
|
||||
scanner: ManufacturerModelName || '',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-row w-full h-full overflow-hidden">
|
||||
{rtIsLoading && (
|
||||
<LoadingIndicatorTotalPercent
|
||||
className="w-full h-full"
|
||||
totalNumbers={processingProgress.totalSegments}
|
||||
percentComplete={processingProgress.percentComplete}
|
||||
loadingText="Loading RTSTRUCT..."
|
||||
/>
|
||||
)}
|
||||
{getCornerstoneViewport()}
|
||||
<div className="absolute w-full">
|
||||
{viewportDialogState.viewportIndex === viewportIndex && (
|
||||
<Notification
|
||||
id="viewport-notification"
|
||||
message={viewportDialogState.message}
|
||||
type={viewportDialogState.type}
|
||||
actions={viewportDialogState.actions}
|
||||
onSubmit={viewportDialogState.onSubmit}
|
||||
onOutsideClick={viewportDialogState.onOutsideClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{childrenWithProps}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OHIFCornerstoneRTViewport.propTypes = {
|
||||
displaySets: PropTypes.arrayOf(PropTypes.object),
|
||||
viewportIndex: PropTypes.number.isRequired,
|
||||
dataSource: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
customProps: PropTypes.object,
|
||||
};
|
||||
|
||||
OHIFCornerstoneRTViewport.defaultProps = {
|
||||
customProps: {},
|
||||
};
|
||||
|
||||
function _getReferencedDisplaySetMetadata(referencedDisplaySet) {
|
||||
const image0 = referencedDisplaySet.images[0];
|
||||
const referencedDisplaySetMetadata = {
|
||||
PatientID: image0.PatientID,
|
||||
PatientName: image0.PatientName,
|
||||
PatientSex: image0.PatientSex,
|
||||
PatientAge: image0.PatientAge,
|
||||
SliceThickness: image0.SliceThickness,
|
||||
StudyDate: image0.StudyDate,
|
||||
SeriesDescription: image0.SeriesDescription,
|
||||
SeriesInstanceUID: image0.SeriesInstanceUID,
|
||||
SeriesNumber: image0.SeriesNumber,
|
||||
ManufacturerModelName: image0.ManufacturerModelName,
|
||||
SpacingBetweenSlices: image0.SpacingBetweenSlices,
|
||||
};
|
||||
|
||||
return referencedDisplaySetMetadata;
|
||||
}
|
||||
|
||||
export default OHIFCornerstoneRTViewport;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Tooltip } from '@ohif/ui';
|
||||
|
||||
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
|
||||
let ToolTipMessage = null;
|
||||
let StatusIcon = null;
|
||||
|
||||
const { t } = useTranslation('Common');
|
||||
const loadStr = t('LOAD');
|
||||
|
||||
switch (isHydrated) {
|
||||
case true:
|
||||
StatusIcon = () => <Icon name="status-alert" />;
|
||||
|
||||
ToolTipMessage = () => (
|
||||
<div>This Segmentation is loaded in the segmentation panel</div>
|
||||
);
|
||||
break;
|
||||
case false:
|
||||
StatusIcon = () => <Icon name="status-untracked" />;
|
||||
|
||||
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
|
||||
}
|
||||
|
||||
const StatusArea = () => (
|
||||
<div className="flex h-6 leading-6 cursor-default text-sm text-white">
|
||||
<div className="min-w-[45px] flex items-center p-1 rounded-l-xl rounded-r bg-customgray-100">
|
||||
<StatusIcon />
|
||||
<span className="ml-1">RTSTRUCT</span>
|
||||
</div>
|
||||
{!isHydrated && (
|
||||
<div
|
||||
className="ml-1 px-1.5 rounded cursor-pointer hover:text-black bg-primary-main hover:bg-primary-light"
|
||||
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
|
||||
onMouseUp={onStatusClick}
|
||||
>
|
||||
{loadStr}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ToolTipMessage && (
|
||||
<Tooltip content={<ToolTipMessage />} position="bottom-left">
|
||||
<StatusArea />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!ToolTipMessage && <StatusArea />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user