This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
.viewport-wrapper {
width: 100%;
height: 100%; /* MUST have `height` to prevent resize infinite loop */
position: relative;
}
.cornerstone-viewport-element {
width: 100%;
height: 100%;
position: relative;
background-color: black;
/* Prevent the blue outline in Chrome when a viewport is selected */
outline: 0 !important;
/* Prevents the entire page from getting larger
when the magnify tool is near the sides/corners of the page */
overflow: hidden;
}

View File

@@ -0,0 +1,664 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import * as cs3DTools from '@cornerstonejs/tools';
import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core';
import { MeasurementService } from '@ohif/core';
import { AllInOneMenu, Notification, useViewportDialog } from '@ohif/ui';
import type { Types as csTypes } from '@cornerstonejs/core';
import { setEnabledElement } from '../state';
import './OHIFCornerstoneViewport.css';
import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
import CinePlayer from '../components/CinePlayer';
import type { Types } from '@ohif/core';
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu';
import { useAppConfig } from '@state';
import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu';
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
const STACK = 'stack';
/**
* Caches the jump to measurement operation, so that if display set is shown,
* it can jump to the measurement.
*/
let cacheJumpToMeasurementEvent;
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
// Then we don't need to worry about the re-renders if the props change.
const OHIFCornerstoneViewport = React.memo(
(
props: withAppTypes<{
viewportId: string;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportGrid.GridViewportOptions;
initialImageIndex: number;
}>
) => {
const {
displaySets,
dataSource,
viewportOptions,
displaySetOptions,
servicesManager,
commandsManager,
onElementEnabled,
// eslint-disable-next-line react/prop-types
onElementDisabled,
isJumpToMeasurementDisabled = false,
// Note: you SHOULD NOT use the initialImageIdOrIndex for manipulation
// of the imageData in the OHIFCornerstoneViewport. This prop is used
// to set the initial state of the viewport's first image to render
// eslint-disable-next-line react/prop-types
initialImageIndex,
// if the viewport is part of a hanging protocol layout
// we should not really rely on the old synchronizers and
// you see below we only rehydrate the synchronizers if the viewport
// is not part of the hanging protocol layout. HPs should
// define their own synchronizers. Since the synchronizers are
// viewportId dependent and
// eslint-disable-next-line react/prop-types
isHangingProtocolLayout,
} = props;
const viewportId = viewportOptions.viewportId;
if (!viewportId) {
throw new Error('Viewport ID is required');
}
// Make sure displaySetOptions has one object per displaySet
while (displaySetOptions.length < displaySets.length) {
displaySetOptions.push({});
}
// Since we only have support for dynamic data in volume viewports, we should
// handle this case here and set the viewportType to volume if any of the
// displaySets are dynamic volumes
viewportOptions.viewportType = displaySets.some(
ds => ds.isDynamicVolume && ds.isReconstructable
)
? 'volume'
: viewportOptions.viewportType;
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [enabledVPElement, setEnabledVPElement] = useState(null);
const elementRef = useRef() as React.MutableRefObject<HTMLDivElement>;
const [appConfig] = useAppConfig();
const {
displaySetService,
toolbarService,
toolGroupService,
syncGroupService,
cornerstoneViewportService,
segmentationService,
cornerstoneCacheService,
viewportActionCornersService,
} = servicesManager.services;
const [viewportDialogState] = useViewportDialog();
// useCallback for scroll bar height calculation
const setImageScrollBarHeight = useCallback(() => {
const scrollbarHeight = `${elementRef.current.clientHeight - 40}px`;
setScrollbarHeight(scrollbarHeight);
}, [elementRef]);
// useCallback for onResize
const onResize = useCallback(() => {
if (elementRef.current) {
cornerstoneViewportService.resize();
setImageScrollBarHeight();
}
}, [elementRef]);
const cleanUpServices = useCallback(
viewportInfo => {
const renderingEngineId = viewportInfo.getRenderingEngineId();
const syncGroups = viewportInfo.getSyncGroups();
toolGroupService.removeViewportFromToolGroup(viewportId, renderingEngineId);
syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroups);
segmentationService.clearSegmentationRepresentations(viewportId);
viewportActionCornersService.clear(viewportId);
},
[
viewportId,
segmentationService,
syncGroupService,
toolGroupService,
viewportActionCornersService,
]
);
const elementEnabledHandler = useCallback(
evt => {
// check this is this element reference and return early if doesn't match
if (evt.detail.element !== elementRef.current) {
return;
}
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
setEnabledElement(viewportId, element);
setEnabledVPElement(element);
const renderingEngineId = viewportInfo.getRenderingEngineId();
const toolGroupId = viewportInfo.getToolGroupId();
const syncGroups = viewportInfo.getSyncGroups();
toolGroupService.addViewportToToolGroup(viewportId, renderingEngineId, toolGroupId);
syncGroupService.addViewportToSyncGroup(viewportId, renderingEngineId, syncGroups);
// we don't need reactivity here so just use state
const { synchronizersStore } = useSynchronizersStore.getState();
if (synchronizersStore?.[viewportId]?.length && !isHangingProtocolLayout) {
// If the viewport used to have a synchronizer, re apply it again
_rehydrateSynchronizers(viewportId, syncGroupService);
}
if (onElementEnabled && typeof onElementEnabled === 'function') {
onElementEnabled(evt);
}
},
[viewportId, onElementEnabled, toolGroupService]
);
// disable the element upon unmounting
useEffect(() => {
cornerstoneViewportService.enableViewport(viewportId, elementRef.current);
eventTarget.addEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
setImageScrollBarHeight();
return () => {
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) {
return;
}
cornerstoneViewportService.storePresentation({ viewportId });
// This should be done after the store presentation since synchronizers
// will get cleaned up and they need the viewportInfo to be present
cleanUpServices(viewportInfo);
if (onElementDisabled && typeof onElementDisabled === 'function') {
onElementDisabled(viewportInfo);
}
cornerstoneViewportService.disableElement(viewportId);
eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
};
}, []);
// subscribe to displaySet metadata invalidation (updates)
// Currently, if the metadata changes we need to re-render the display set
// for it to take effect in the viewport. As we deal with scaling in the loading,
// we need to remove the old volume from the cache, and let the
// viewport to re-add it which will use the new metadata. Otherwise, the
// viewport will use the cached volume and the new metadata will not be used.
// Note: this approach does not actually end of sending network requests
// and it uses the network cache
useEffect(() => {
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
async ({
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
invalidateData,
}: Types.DisplaySetSeriesMetadataInvalidatedEvent) => {
if (!invalidateData) {
return;
}
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) {
const viewportData = viewportInfo.getViewportData();
const newViewportData = await cornerstoneCacheService.invalidateViewportData(
viewportData,
invalidatedDisplaySetInstanceUID,
dataSource,
displaySetService
);
const keepCamera = true;
cornerstoneViewportService.updateViewport(viewportId, newViewportData, keepCamera);
}
}
);
return () => {
unsubscribe();
};
}, [viewportId]);
useEffect(() => {
// handle the default viewportType to be stack
if (!viewportOptions.viewportType) {
viewportOptions.viewportType = STACK;
}
const loadViewportData = async () => {
const viewportData = await cornerstoneCacheService.createViewportData(
displaySets,
viewportOptions,
dataSource,
initialImageIndex
);
const presentations = getViewportPresentations(viewportId, viewportOptions);
let measurement;
if (cacheJumpToMeasurementEvent?.viewportId === viewportId) {
measurement = cacheJumpToMeasurementEvent.measurement;
// Delete the position presentation so that viewport navigates direct
presentations.positionPresentation = null;
cacheJumpToMeasurementEvent = null;
}
// Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component
// Used for segmentation hydration right now, since the logic to decide whether
// a viewport needs to render a segmentation lives inside the CornerstoneViewportService
// so we need to re-render (force update via change of the needsRerendering) so that React
// does the diffing and decides we should render this again (although the id and element has not changed)
// so that the CornerstoneViewportService can decide whether to render the segmentation or not. Not that we reached here we can turn it off.
if (viewportOptions.needsRerendering) {
viewportOptions.needsRerendering = false;
}
cornerstoneViewportService.setViewportData(
viewportId,
viewportData,
viewportOptions,
displaySetOptions,
presentations
);
if (measurement) {
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
}
};
loadViewportData();
}, [viewportOptions, displaySets, dataSource]);
/**
* There are two scenarios for jump to click
* 1. Current viewports contain the displaySet that the annotation was drawn on
* 2. Current viewports don't contain the displaySet that the annotation was drawn on
* and we need to change the viewports displaySet for jumping.
* Since measurement_jump happens via events and listeners, the former case is handled
* by the measurement_jump direct callback, but the latter case is handled first by
* the viewportGrid to set the correct displaySet on the viewport, AND THEN we check
* the cache for jumping to see if there is any jump queued, then we jump to the correct slice.
*/
useEffect(() => {
if (isJumpToMeasurementDisabled) {
return;
}
const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents(
elementRef,
viewportId,
servicesManager
);
_checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager);
return () => {
unsubscribeFromJumpToMeasurementEvents();
};
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
// Set up the window level action menu in the viewport action corners.
useEffect(() => {
// Doing an === check here because the default config value when not set is true
if (appConfig.addWindowLevelActionMenu === false) {
return;
}
const location = viewportActionCornersService.LOCATIONS.topRight;
// TODO: In the future we should consider using the customization service
// to determine if and in which corner various action components should go.
viewportActionCornersService.addComponent({
viewportId,
id: 'windowLevelActionMenu',
component: getWindowLevelActionMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
verticalDirection: AllInOneMenu.VerticalDirection.TopToBottom,
horizontalDirection: AllInOneMenu.HorizontalDirection.RightToLeft,
}),
location,
});
viewportActionCornersService.addComponent({
viewportId,
id: 'segmentation',
component: getViewportDataOverlaySettingsMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
}),
location,
});
}, [
displaySets,
viewportId,
viewportActionCornersService,
servicesManager,
commandsManager,
appConfig,
]);
const { ref: resizeRef } = useResizeDetector({
onResize,
});
return (
<React.Fragment>
<div className="viewport-wrapper">
<div
className="cornerstone-viewport-element"
style={{ height: '100%', width: '100%' }}
onContextMenu={e => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
ref={el => {
resizeRef.current = el;
elementRef.current = el;
}}
></div>
<CornerstoneOverlays
viewportId={viewportId}
toolBarService={toolbarService}
element={elementRef.current}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
<CinePlayer
enabledVPElement={enabledVPElement}
viewportId={viewportId}
servicesManager={servicesManager}
/>
<ActiveViewportBehavior
viewportId={viewportId}
servicesManager={servicesManager}
/>
</div>
{/* top offset of 24px to account for ViewportActionCorners. */}
<div className="absolute top-[24px] w-full">
{viewportDialogState.viewportId === viewportId && (
<Notification
id="viewport-notification"
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
onKeyPress={viewportDialogState.onKeyPress}
/>
)}
</div>
{/* The OHIFViewportActionCorners follows the viewport in the DOM so that it is naturally at a higher z-index.*/}
<OHIFViewportActionCorners viewportId={viewportId} />
</React.Fragment>
);
},
areEqual
);
function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) {
const { measurementService, cornerstoneViewportService } = servicesManager.services;
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
props => {
cacheJumpToMeasurementEvent = props;
const { viewportId: jumpId, measurement, isConsumed } = props;
if (!measurement || isConsumed) {
return;
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
// Decide on which viewport should handle this
cacheJumpToMeasurementEvent.cornerstoneViewport =
cornerstoneViewportService.getViewportIdToJump(jumpId, {
displaySetInstanceUID: measurement.displaySetInstanceUID,
...measurement.metadata,
referencedImageId:
measurement.referencedImageId || measurement.metadata?.referencedImageId,
});
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) {
return;
}
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
);
return unsubscribe;
}
// Check if there is a queued jumpToMeasurement event
function _checkForCachedJumpToMeasurementEvents(
elementRef,
viewportId,
displaySets,
servicesManager
) {
if (!cacheJumpToMeasurementEvent) {
return;
}
if (cacheJumpToMeasurementEvent.isConsumed) {
cacheJumpToMeasurementEvent = null;
return;
}
const displaysUIDs = displaySets.map(displaySet => displaySet.displaySetInstanceUID);
if (!displaysUIDs?.length) {
return;
}
// Jump to measurement if the measurement exists
const { measurement } = cacheJumpToMeasurementEvent;
if (measurement && elementRef) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
}
}
function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) {
const { viewportGridService } = servicesManager.services;
const targetElement = targetElementRef.current;
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly
// setCornerstoneMeasurementActive(measurement);
viewportGridService.setActiveViewportId(viewportId);
const enabledElement = getEnabledElement(targetElement);
if (enabledElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
const { metadata } = measurement;
if (!viewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true })) {
console.log("Reference isn't viewable, postponing until updated");
return;
}
viewport.setViewReference(metadata);
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
// Jump to measurement consumed, remove.
cacheJumpToMeasurementEvent?.consume?.();
cacheJumpToMeasurementEvent = null;
}
}
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
const { synchronizersStore } = useSynchronizersStore.getState();
const synchronizers = synchronizersStore[viewportId];
if (!synchronizers) {
return;
}
synchronizers.forEach(synchronizerObj => {
if (!synchronizerObj.id) {
return;
}
const { id, sourceViewports, targetViewports } = synchronizerObj;
const synchronizer = syncGroupService.getSynchronizer(id);
if (!synchronizer) {
return;
}
const sourceViewportInfo = sourceViewports.find(
sourceViewport => sourceViewport.viewportId === viewportId
);
const targetViewportInfo = targetViewports.find(
targetViewport => targetViewport.viewportId === viewportId
);
const isSourceViewportInSynchronizer = synchronizer
.getSourceViewports()
.find(sourceViewport => sourceViewport.viewportId === viewportId);
const isTargetViewportInSynchronizer = synchronizer
.getTargetViewports()
.find(targetViewport => targetViewport.viewportId === viewportId);
// if the viewport was previously a source viewport, add it again
if (sourceViewportInfo && !isSourceViewportInSynchronizer) {
synchronizer.addSource({
viewportId: sourceViewportInfo.viewportId,
renderingEngineId: sourceViewportInfo.renderingEngineId,
});
}
// if the viewport was previously a target viewport, add it again
if (targetViewportInfo && !isTargetViewportInSynchronizer) {
synchronizer.addTarget({
viewportId: targetViewportInfo.viewportId,
renderingEngineId: targetViewportInfo.renderingEngineId,
});
}
});
}
// Component displayName
OHIFCornerstoneViewport.displayName = 'OHIFCornerstoneViewport';
function areEqual(prevProps, nextProps) {
if (nextProps.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: needsRerendering');
return false;
}
if (prevProps.displaySets.length !== nextProps.displaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length change');
return false;
}
if (prevProps.viewportOptions.orientation !== nextProps.viewportOptions.orientation) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: orientation change');
return false;
}
if (prevProps.viewportOptions.toolGroupId !== nextProps.viewportOptions.toolGroupId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: toolGroupId change');
return false;
}
if (
nextProps.viewportOptions.viewportType &&
prevProps.viewportOptions.viewportType !== nextProps.viewportOptions.viewportType
) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportType change');
return false;
}
if (nextProps.viewportOptions.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportOptions.needsRerendering');
return false;
}
const prevDisplaySets = prevProps.displaySets;
const nextDisplaySets = nextProps.displaySets;
if (prevDisplaySets.length !== nextDisplaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length mismatch');
return false;
}
for (let i = 0; i < prevDisplaySets.length; i++) {
const prevDisplaySet = prevDisplaySets[i];
const foundDisplaySet = nextDisplaySets.find(
nextDisplaySet =>
nextDisplaySet.displaySetInstanceUID === prevDisplaySet.displaySetInstanceUID
);
if (!foundDisplaySet) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySet not found');
return false;
}
// check they contain the same image
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: images length mismatch');
return false;
}
// check if their imageIds are the same
if (foundDisplaySet.images?.length) {
for (let j = 0; j < foundDisplaySet.images.length; j++) {
if (foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: imageId mismatch');
return false;
}
}
}
}
return true;
}
// Helper function to check if display sets have changed
function haveDisplaySetsChanged(prevDisplaySets, currentDisplaySets) {
if (prevDisplaySets.length !== currentDisplaySets.length) {
return true;
}
return currentDisplaySets.some((currentDS, index) => {
const prevDS = prevDisplaySets[index];
return currentDS.displaySetInstanceUID !== prevDS.displaySetInstanceUID;
});
}
export default OHIFCornerstoneViewport;

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import ViewportImageScrollbar from './ViewportImageScrollbar';
import CustomizableViewportOverlay from './CustomizableViewportOverlay';
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
function CornerstoneOverlays(props: withAppTypes) {
const { viewportId, element, scrollbarHeight, servicesManager } = props;
const { cornerstoneViewportService } = servicesManager.services;
const [imageSliceData, setImageSliceData] = useState({
imageIndex: 0,
numberOfSlices: 0,
});
const [viewportData, setViewportData] = useState(null);
useEffect(() => {
const { unsubscribe } = cornerstoneViewportService.subscribe(
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
props => {
if (props.viewportId !== viewportId) {
return;
}
setViewportData(props.viewportData);
}
);
return () => {
unsubscribe();
};
}, [viewportId]);
if (!element) {
return null;
}
if (viewportData) {
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (viewportInfo?.viewportOptions?.customViewportProps?.hideOverlays) {
return null;
}
}
return (
<div className="noselect">
<ViewportImageScrollbar
viewportId={viewportId}
viewportData={viewportData}
element={element}
imageSliceData={imageSliceData}
setImageSliceData={setImageSliceData}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
<CustomizableViewportOverlay
imageSliceData={imageSliceData}
viewportData={viewportData}
viewportId={viewportId}
servicesManager={servicesManager}
element={element}
/>
<ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers
imageSliceData={imageSliceData}
element={element}
viewportData={viewportData}
servicesManager={servicesManager}
viewportId={viewportId}
/>
</div>
);
}
export default CornerstoneOverlays;

View File

@@ -0,0 +1,25 @@
/*
custom overlay panels: top-left, top-right, bottom-left and bottom-right
If any text to be displayed on the overlay is too long to hold on a single
line, it will be truncated with ellipsis in the end.
*/
.viewport-overlay {
max-width: 40%;
}
.viewport-overlay span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewport-overlay.left-viewport {
text-align: left;
}
.viewport-overlay.right-viewport-scrollbar {
text-align: right;
}
.viewport-overlay.right-viewport-scrollbar .flex.flex-row {
justify-content: flex-end;
}

View File

@@ -0,0 +1,534 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import type { ImageSliceData } from '@cornerstonejs/core/types';
import { ViewportOverlay } from '@ohif/ui';
import type { InstanceMetadata } from '@ohif/core/src/types';
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import './CustomizableViewportOverlay.css';
const EPSILON = 1e-4;
type ViewportData = StackViewportData | VolumeViewportData;
interface OverlayItemProps {
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
servicesManager: AppTypes.ServicesManager;
viewportId: string;
instance: InstanceMetadata;
customization: any;
formatters: {
formatPN: (val) => string;
formatDate: (val) => string;
formatTime: (val) => string;
formatNumberPrecision: (val, number) => string;
};
// calculated values
voi: {
windowWidth: number;
windowCenter: number;
};
instanceNumber?: number;
scale?: number;
}
const OverlayItemComponents = {
'ohif.overlayItem': OverlayItem,
'ohif.overlayItem.windowLevel': VOIOverlayItem,
'ohif.overlayItem.zoomLevel': ZoomOverlayItem,
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
};
const studyDateItem = {
id: 'StudyDate',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Study date',
condition: ({ referenceInstance }) => referenceInstance?.StudyDate,
contentF: ({ referenceInstance, formatters: { formatDate } }) =>
formatDate(referenceInstance.StudyDate),
};
const seriesDescriptionItem = {
id: 'SeriesDescription',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Series description',
condition: ({ referenceInstance }) => {
return referenceInstance && referenceInstance.SeriesDescription;
},
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
};
const topLeftItems = {
id: 'cornerstoneOverlayTopLeft',
items: [studyDateItem, seriesDescriptionItem],
};
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
const bottomLeftItems = {
id: 'cornerstoneOverlayBottomLeft',
items: [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
{
id: 'ZoomLevel',
customizationType: 'ohif.overlayItem.zoomLevel',
condition: props => {
const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
return activeToolName === 'Zoom';
},
},
],
};
const bottomRightItems = {
id: 'cornerstoneOverlayBottomRight',
items: [
{
id: 'InstanceNumber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
],
};
/**
* The @ohif/cornerstoneOverlay is a default value for a customization
* for the cornerstone overlays. The intent is to allow it to be extended
* without needing to re-write the individual overlays by using the append
* mechanism. Individual attributes can be modified individually without
* affecting the other items by using the append as well, with position
* based replacement.
* This is used as a default in the getCustomizationModule so that it
* is available early for additional customization extensions.
*/
const CornerstoneOverlay = {
id: '@ohif/cornerstoneOverlay',
topLeftItems,
topRightItems,
bottomLeftItems,
bottomRightItems,
};
/**
* Customizable Viewport Overlay
*/
function CustomizableViewportOverlay({
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
}: {
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
viewportId: string;
servicesManager: AppTypes.ServicesManager;
}) {
const { cornerstoneViewportService, customizationService, toolGroupService, displaySetService } =
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const { imageIndex } = imageSliceData;
// The new customization is 'cornerstoneOverlay', with an append or replace
// on the individual items rather than defining individual items.
const cornerstoneOverlay = customizationService.getCustomization('@ohif/cornerstoneOverlay');
// Historical usage defined the overlays as separate items due to lack of
// append functionality. This code enables the historical usage, but
// the recommended functionality is to append to the default values in
// cornerstoneOverlay rather than defining individual items.
const topLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayTopLeft') ||
cornerstoneOverlay?.topLeftItems;
const topRightCustomization =
customizationService.getCustomization('cornerstoneOverlayTopRight') ||
cornerstoneOverlay?.topRightItems;
const bottomLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomLeft') ||
cornerstoneOverlay?.bottomLeftItems;
const bottomRightCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomRight') ||
cornerstoneOverlay?.bottomRightItems;
const instanceNumber = useMemo(
() =>
viewportData
? getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService)
: null,
[viewportData, viewportId, imageIndex, cornerstoneViewportService]
);
const displaySetProps = useMemo(() => {
const displaySets = getDisplaySets(viewportData, displaySetService);
if (!displaySets) {
return null;
}
const [displaySet] = displaySets;
const { instances, instance: referenceInstance } = displaySet;
return {
displaySets,
displaySet,
instance: instances?.[imageIndex],
instances,
referenceInstance,
};
}, [viewportData, viewportId, instanceNumber, cornerstoneViewportService]);
/**
* Updating the VOI when the viewport changes its voi
*/
useEffect(() => {
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
if (!range) {
return;
}
const { lower, upper } = range;
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
setVOI({ windowCenter, windowWidth });
};
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
return () => {
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
};
}, [viewportId, viewportData, voi, element]);
/**
* Updating the scale when the viewport changes its zoom
*/
useEffect(() => {
const updateScale = eventDetail => {
const { previousCamera, camera } = eventDetail.detail;
if (
previousCamera.parallelScale !== camera.parallelScale ||
previousCamera.scale !== camera.scale
) {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
const scale = viewport.getZoom();
setScale(scale);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
};
}, [viewportId, viewportData, cornerstoneViewportService, element]);
const _renderOverlayItem = useCallback(
(item, props) => {
const overlayItemProps = {
...props,
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
customization: item,
formatters: {
formatPN,
formatDate: formatDICOMDate,
formatTime: formatDICOMTime,
formatNumberPrecision,
},
};
if (!item) {
return null;
}
const { customizationType } = item;
const OverlayItemComponent = OverlayItemComponents[customizationType];
if (OverlayItemComponent) {
return <OverlayItemComponent {...overlayItemProps} />;
} else {
const renderItem = customizationService.transform(item);
if (typeof renderItem.contentF === 'function') {
return renderItem.contentF(overlayItemProps);
}
}
},
[
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
customizationService,
displaySetProps,
voi,
scale,
instanceNumber,
]
);
const getContent = useCallback(
(customization, keyPrefix) => {
if (!customization?.items) {
return null;
}
const { items } = customization;
const props = {
...displaySetProps,
formatters: { formatDate: formatDICOMDate },
voi,
scale,
instanceNumber,
viewportId,
toolGroupService,
};
return (
<>
{items.map((item, index) => (
<div key={`${keyPrefix}_${index}`}>
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
null}
</div>
))}
</>
);
},
[_renderOverlayItem]
);
return (
<ViewportOverlay
topLeft={getContent(topLeftCustomization, 'topLeftOverlayItem')}
topRight={getContent(topRightCustomization, 'topRightOverlayItem')}
bottomLeft={getContent(bottomLeftCustomization, 'bottomLeftOverlayItem')}
bottomRight={getContent(bottomRightCustomization, 'bottomRightOverlayItem')}
/>
);
}
/**
* Gets an array of display sets for the given viewport, based on the viewport data.
* Returns null if none found.
*/
function getDisplaySets(viewportData, displaySetService) {
if (!viewportData?.data?.length) {
return null;
}
const displaySets = viewportData.data
.map(datum => displaySetService.getDisplaySetByUID(datum.displaySetInstanceUID))
.filter(it => !!it);
if (!displaySets.length) {
return null;
}
return displaySets;
}
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
let instanceNumber;
switch (viewportData.viewportType) {
case Enums.ViewportType.STACK:
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
break;
case Enums.ViewportType.ORTHOGRAPHIC:
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
viewportId,
cornerstoneViewportService,
imageIndex
);
break;
}
return instanceNumber ?? null;
};
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.data[0].imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {
return;
}
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { instanceNumber } = generalImageModule;
const stackSize = imageIds.length;
if (stackSize <= 1) {
return;
}
return parseInt(instanceNumber);
}
// Since volume viewports can be in any view direction, they can render
// a reconstructed image which don't have imageIds; therefore, no instance and instanceNumber
// Here we check if viewport is in the acquisition direction and if so, we get the instanceNumber
function _getInstanceNumberFromVolume(
viewportData,
viewportId,
cornerstoneViewportService,
imageIndex
) {
const volumes = viewportData.data;
if (!volumes) {
return;
}
// Todo: support fusion of acquisition plane which has instanceNumber
const { volume } = volumes[0];
if (!volume) {
return;
}
const { direction, imageIds } = volume;
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!cornerstoneViewport) {
return;
}
const camera = cornerstoneViewport.getCamera();
const { viewPlaneNormal } = camera;
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
const scanAxisNormal = direction.slice(6, 9);
// check if viewPlaneNormal is parallel to scanAxisNormal
const cross = vec3.cross(vec3.create(), viewPlaneNormal, scanAxisNormal);
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}
function OverlayItem(props) {
const { instance, customization = {} } = props;
const { color, attribute, title, label, background } = customization;
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
if (value === undefined || value === null) {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color, background }}
title={title}
>
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
<span className="ml-1 mr-2 shrink-0">{value}</span>
</div>
);
}
/**
* Window Level / Center Overlay item
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color: customization?.color }}
>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 shrink-0">{windowWidth.toFixed(0)}</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 shrink-0">{windowCenter.toFixed(0)}</span>
</div>
);
}
/**
* Zoom Level Overlay item
*/
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">Zoom:</span>
<span>{scale.toFixed(2)}x</span>
</div>
);
}
/**
* Instance Number Overlay Item
*/
function InstanceNumberOverlayItem({
instanceNumber,
imageSliceData,
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span>
{instanceNumber !== undefined && instanceNumber !== null ? (
<>
<span className="mr-1 shrink-0">I:</span>
<span>{`${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`}</span>
</>
) : (
`${imageIndex + 1}/${numberOfSlices}`
)}
</span>
</div>
);
}
CustomizableViewportOverlay.propTypes = {
viewportData: PropTypes.object,
imageIndex: PropTypes.number,
viewportId: PropTypes.string,
};
export default CustomizableViewportOverlay;
export { CustomizableViewportOverlay, CornerstoneOverlay };

View File

@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { ImageScrollbar } from '@ohif/ui';
function CornerstoneImageScrollbar({
viewportData,
viewportId,
element,
imageSliceData,
setImageSliceData,
scrollbarHeight,
servicesManager,
}: withAppTypes<{
element: HTMLElement;
}>) {
const { cineService, cornerstoneViewportService } = servicesManager.services;
const onImageScrollbarChange = (imageIndex, viewportId) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { isCineEnabled } = cineService.getState();
if (isCineEnabled) {
// on image scrollbar change, stop the CINE if it is playing
cineService.stopClip(element, { viewportId });
cineService.setCine({ id: viewportId, isPlaying: false });
}
csUtils.jumpToSlice(viewport.element, {
imageIndex,
debounceLoading: true,
});
};
useEffect(() => {
if (!viewportData) {
return;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
return;
}
const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = viewport.getNumberOfSlices();
setImageSliceData({
imageIndex: imageIndex,
numberOfSlices,
});
}, [viewportId, viewportData]);
useEffect(() => {
if (!viewportData) {
return;
}
const { viewportType } = viewportData;
const eventId =
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) ||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
Enums.Events.IMAGE_RENDERED;
const updateIndex = event => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
return;
}
const { imageIndex, newImageIdIndex = imageIndex } = event.detail;
const numberOfSlices = viewport.getNumberOfSlices();
// find the index of imageId in the imageIds
setImageSliceData({
imageIndex: newImageIdIndex,
numberOfSlices,
});
};
element.addEventListener(eventId, updateIndex);
return () => {
element.removeEventListener(eventId, updateIndex);
};
}, [viewportData, element]);
return (
<ImageScrollbar
onChange={evt => onImageScrollbarChange(evt, viewportId)}
max={imageSliceData.numberOfSlices ? imageSliceData.numberOfSlices - 1 : 0}
height={scrollbarHeight}
value={imageSliceData.imageIndex || 0}
/>
);
}
CornerstoneImageScrollbar.propTypes = {
viewportData: PropTypes.object,
viewportId: PropTypes.string.isRequired,
element: PropTypes.instanceOf(Element),
scrollbarHeight: PropTypes.string,
imageSliceData: PropTypes.object.isRequired,
setImageSliceData: PropTypes.func.isRequired,
servicesManager: PropTypes.object.isRequired,
};
export default CornerstoneImageScrollbar;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { Enums } from '@cornerstonejs/core';
function ViewportImageSliceLoadingIndicator({ viewportData, element }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const loadIndicatorRef = useRef(null);
const imageIdToBeLoaded = useRef(null);
const setLoadingState = evt => {
clearTimeout(loadIndicatorRef.current);
loadIndicatorRef.current = setTimeout(() => {
setLoading(true);
}, 50);
};
const setFinishLoadingState = evt => {
clearTimeout(loadIndicatorRef.current);
setLoading(false);
};
const setErrorState = evt => {
clearTimeout(loadIndicatorRef.current);
if (imageIdToBeLoaded.current === evt.detail.imageId) {
setError(evt.detail.error);
imageIdToBeLoaded.current = null;
}
};
useEffect(() => {
element.addEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
element.addEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
element.addEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
return () => {
element.removeEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
element.removeEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
element.removeEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
};
}, [element, viewportData]);
if (error) {
return (
<>
<div className="absolute top-0 left-0 h-full w-full bg-black opacity-50">
<div className="transparent flex h-full w-full items-center justify-center">
<p className="text-primary-light text-xl font-light">
<h4>Error Loading Image</h4>
<p>An error has occurred.</p>
<p>{error}</p>
</p>
</div>
</div>
</>
);
}
if (loading) {
return (
// IMPORTANT: we need to use the pointer-events-none class to prevent the loading indicator from
// interacting with the mouse, since scrolling should propagate to the viewport underneath
<div className="pointer-events-none absolute top-0 left-0 h-full w-full bg-black opacity-50">
<div className="transparent flex h-full w-full items-center justify-center">
<p className="text-primary-light text-xl font-light">Loading...</p>
</div>
</div>
);
}
return null;
}
ViewportImageSliceLoadingIndicator.propTypes = {
error: PropTypes.object,
element: PropTypes.object,
};
export default ViewportImageSliceLoadingIndicator;

View File

@@ -0,0 +1,38 @@
.ViewportOrientationMarkers {
--marker-width: 100px;
--marker-height: 100px;
--scrollbar-width: 20px;
pointer-events: none;
font-size: 15px;
line-height: 18px;
}
.ViewportOrientationMarkers .orientation-marker {
position: absolute;
}
.ViewportOrientationMarkers .top-mid {
top: 0.6rem;
left: 50%;
}
.ViewportOrientationMarkers .left-mid {
top: 47%;
left: 5px;
}
.ViewportOrientationMarkers .right-mid {
top: 47%;
left: calc(100% - var(--marker-width) - var(--scrollbar-width));
}
.ViewportOrientationMarkers .bottom-mid {
top: calc(100% - var(--marker-height) - 0.6rem);
left: 47%;
}
.ViewportOrientationMarkers .right-mid .orientation-marker-value {
display: flex;
justify-content: flex-end;
min-width: var(--marker-width);
}
.ViewportOrientationMarkers .bottom-mid .orientation-marker-value {
display: flex;
justify-content: flex-start;
min-height: var(--marker-height);
flex-direction: column-reverse;
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useState, useMemo } from 'react';
import classNames from 'classnames';
import { metaData, Enums, Types, getEnabledElement } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css';
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
function ViewportOrientationMarkers({
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
orientationMarkers = ['top', 'left'],
}: withAppTypes) {
// Rotation is in degrees
const [rotation, setRotation] = useState(0);
const [flipHorizontal, setFlipHorizontal] = useState(false);
const [flipVertical, setFlipVertical] = useState(false);
const { cornerstoneViewportService } = servicesManager.services;
useEffect(() => {
const cameraModifiedListener = (evt: Types.EventTypes.CameraModifiedEvent) => {
const { previousCamera, camera } = evt.detail;
const { rotation } = camera;
if (rotation !== undefined) {
setRotation(rotation);
}
if (
camera.flipHorizontal !== undefined &&
previousCamera.flipHorizontal !== camera.flipHorizontal
) {
setFlipHorizontal(camera.flipHorizontal);
}
if (
camera.flipVertical !== undefined &&
previousCamera.flipVertical !== camera.flipVertical
) {
setFlipVertical(camera.flipVertical);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
};
}, []);
const markers = useMemo(() => {
if (!viewportData) {
return '';
}
let rowCosines, columnCosines, isDefaultValueSetForRowCosine, isDefaultValueSetForColumnCosine;
if (viewportData.viewportType === 'stack') {
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData.data[0].imageIds?.[imageIndex];
// Workaround for below TODO stub
if (!imageId) {
return false;
}
({
rowCosines,
columnCosines,
isDefaultValueSetForColumnCosine,
isDefaultValueSetForColumnCosine,
} = metaData.get('imagePlaneModule', imageId) || {});
} else {
if (!element || !getEnabledElement(element)) {
return '';
}
const { viewport } = getEnabledElement(element);
const { viewUp, viewPlaneNormal } = viewport.getCamera();
const viewRight = vec3.create();
vec3.cross(viewRight, viewUp, viewPlaneNormal);
columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]];
rowCosines = viewRight;
}
if (
!rowCosines ||
!columnCosines ||
rotation === undefined ||
isDefaultValueSetForRowCosine ||
isDefaultValueSetForColumnCosine
) {
return '';
}
const markers = _getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
);
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
if (!ohifViewport) {
console.log('ViewportOrientationMarkers::No viewport');
return null;
}
return orientationMarkers.map((m, index) => (
<div
className={classNames(
'overlay-text',
`${m}-mid orientation-marker`,
'text-aqua-pale',
'text-[13px]',
'leading-5'
)}
key={`${m}-mid orientation-marker`}
>
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
}, [
viewportData,
imageSliceData,
rotation,
flipVertical,
flipHorizontal,
orientationMarkers,
element,
]);
return <div className="ViewportOrientationMarkers select-none">{markers}</div>;
}
/**
*
* Computes the orientation labels on a Cornerstone-enabled Viewport element
* when the viewport settings change (e.g. when a horizontal flip or a rotation occurs)
*
* @param {*} rowCosines
* @param {*} columnCosines
* @param {*} rotation in degrees
* @returns
*/
function _getOrientationMarkers(rowCosines, columnCosines, rotation, flipVertical, flipHorizontal) {
const rowString = getOrientationStringLPS(rowCosines);
const columnString = getOrientationStringLPS(columnCosines);
const oppositeRowString = invertOrientationStringLPS(rowString);
const oppositeColumnString = invertOrientationStringLPS(columnString);
const markers = {
top: oppositeColumnString,
left: oppositeRowString,
right: rowString,
bottom: columnString,
};
// If any vertical or horizontal flips are applied, change the orientation strings ahead of
// the rotation applications
if (flipVertical) {
markers.top = invertOrientationStringLPS(markers.top);
markers.bottom = invertOrientationStringLPS(markers.bottom);
}
if (flipHorizontal) {
markers.left = invertOrientationStringLPS(markers.left);
markers.right = invertOrientationStringLPS(markers.right);
}
// Swap the labels accordingly if the viewport has been rotated
// This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees)
if (rotation === 90 || rotation === -270) {
return {
top: markers.left,
left: invertOrientationStringLPS(markers.top),
right: invertOrientationStringLPS(markers.bottom),
bottom: markers.right, // left
};
} else if (rotation === -90 || rotation === 270) {
return {
top: invertOrientationStringLPS(markers.left),
left: markers.top,
bottom: markers.left,
right: markers.bottom,
};
} else if (rotation === 180 || rotation === -180) {
return {
top: invertOrientationStringLPS(markers.top),
left: invertOrientationStringLPS(markers.left),
bottom: invertOrientationStringLPS(markers.bottom),
right: invertOrientationStringLPS(markers.right),
};
}
return markers;
}
export default ViewportOrientationMarkers;

View File

@@ -0,0 +1,98 @@
import moment from 'moment';
import { metaData } from '@cornerstonejs/core';
/**
* Checks if value is valid.
*
* @param {number} value
* @returns {boolean} is valid.
*/
export function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Formats number precision.
*
* @param {number} number
* @param {number} precision
* @returns {number} formatted number.
*/
export function formatNumberPrecision(number, precision = 0) {
if (number !== null) {
return parseFloat(number).toFixed(precision);
}
}
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
*/
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
* @returns {string} formatted name.
*/
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return '';
}
if (typeof name === 'object') {
name = name.Alphabetic;
if (!name) {
return '';
}
}
const cleaned = name
.split('^')
.filter(s => !!s)
.join(', ')
.trim();
return cleaned === ',' || cleaned === '' ? '' : cleaned;
}
/**
* Gets compression type
*
* @param {number} imageId
* @returns {string} compression type.
*/
export function getCompression(imageId) {
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { lossyImageCompression, lossyImageCompressionRatio, lossyImageCompressionMethod } =
generalImageModule;
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = formatNumberPrecision(lossyImageCompressionRatio, 2);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import React, { ReactElement } from 'react';
import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui';
import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel';
const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElement => {
const [viewportGrid] = useViewportGrid();
const { activeViewportId } = viewportGrid;
return (
<>
{activeViewportId && (
<ViewportWindowLevel
servicesManager={servicesManager}
viewportId={activeViewportId}
/>
)}
</>
);
};
ActiveViewportWindowLevel.propTypes = {
servicesManager: PropTypes.object.isRequired,
};
export default ActiveViewportWindowLevel;

View File

@@ -0,0 +1 @@
export { default } from './ActiveViewportWindowLevel';

View File

@@ -0,0 +1,248 @@
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { CinePlayer, useCine } from '@ohif/ui';
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
import { useAppConfig } from '@state';
function WrappedCinePlayer({
enabledVPElement,
viewportId,
servicesManager,
}: withAppTypes<{
enabledVPElement: HTMLElement;
viewportId: string;
}>) {
const { customizationService, displaySetService, viewportGridService } = servicesManager.services;
const [{ isCineEnabled, cines }, cineService] = useCine();
const [newStackFrameRate, setNewStackFrameRate] = useState(24);
const [dynamicInfo, setDynamicInfo] = useState(null);
const [appConfig] = useAppConfig();
const isMountedRef = useRef(null);
const cineHandler = () => {
if (!cines?.[viewportId] || !enabledVPElement) {
return;
}
const { isPlaying = false, frameRate = 24 } = cines[viewportId];
const validFrameRate = Math.max(frameRate, 1);
return isPlaying
? cineService.playClip(enabledVPElement, { framesPerSecond: validFrameRate, viewportId })
: cineService.stopClip(enabledVPElement);
};
const newDisplaySetHandler = useCallback(() => {
if (!enabledVPElement || !isCineEnabled) {
return;
}
const { viewports } = viewportGridService.getState();
const { displaySetInstanceUIDs } = viewports.get(viewportId);
let frameRate = 24;
let isPlaying = cines[viewportId]?.isPlaying || false;
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet.FrameRate) {
// displaySet.FrameRate corresponds to DICOM tag (0018,1063) which is defined as the the frame time in milliseconds
// So a bit of math to get the actual frame rate.
frameRate = Math.round(1000 / displaySet.FrameRate);
isPlaying ||= !!appConfig.autoPlayCine;
}
// check if the displaySet is dynamic and set the dynamic info
if (displaySet.isDynamicVolume) {
const { dynamicVolumeInfo } = displaySet;
const numTimePoints = dynamicVolumeInfo.timePoints.length;
const label = dynamicVolumeInfo.splittingTag;
const timePointIndex = dynamicVolumeInfo.timePointIndex || 0;
setDynamicInfo({
volumeId: displaySet.displaySetInstanceUID,
timePointIndex,
numTimePoints,
label,
});
} else {
setDynamicInfo(null);
}
});
if (isPlaying) {
cineService.setIsCineEnabled(isPlaying);
}
cineService.setCine({ id: viewportId, isPlaying, frameRate });
setNewStackFrameRate(frameRate);
}, [displaySetService, viewportId, viewportGridService, cines, isCineEnabled, enabledVPElement]);
useEffect(() => {
isMountedRef.current = true;
newDisplaySetHandler();
return () => {
isMountedRef.current = false;
};
}, [isCineEnabled, newDisplaySetHandler]);
useEffect(() => {
if (!isCineEnabled) {
return;
}
cineHandler();
}, [isCineEnabled, cineHandler, enabledVPElement]);
/**
* Use effect for handling new display set
*/
useEffect(() => {
if (!enabledVPElement) {
return;
}
enabledVPElement.addEventListener(Enums.Events.VIEWPORT_NEW_IMAGE_SET, newDisplaySetHandler);
// this doesn't makes sense that we are listening to this event on viewport element
enabledVPElement.addEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
newDisplaySetHandler
);
return () => {
cineService.setCine({ id: viewportId, isPlaying: false });
enabledVPElement.removeEventListener(
Enums.Events.VIEWPORT_NEW_IMAGE_SET,
newDisplaySetHandler
);
enabledVPElement.removeEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
newDisplaySetHandler
);
};
}, [enabledVPElement, newDisplaySetHandler, viewportId]);
useEffect(() => {
if (!cines || !cines[viewportId] || !enabledVPElement || !isMountedRef.current) {
return;
}
cineHandler();
return () => {
cineService.stopClip(enabledVPElement, { viewportId });
};
}, [cines, viewportId, cineService, enabledVPElement, cineHandler]);
if (!isCineEnabled) {
return null;
}
const cine = cines[viewportId];
const isPlaying = cine?.isPlaying || false;
return (
<RenderCinePlayer
viewportId={viewportId}
cineService={cineService}
newStackFrameRate={newStackFrameRate}
isPlaying={isPlaying}
dynamicInfo={dynamicInfo}
customizationService={customizationService}
/>
);
}
function RenderCinePlayer({
viewportId,
cineService,
newStackFrameRate,
isPlaying,
dynamicInfo: dynamicInfoProp,
customizationService,
}) {
const { component: CinePlayerComponent = CinePlayer } =
customizationService.get('cinePlayer') ?? {};
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);
useEffect(() => {
setDynamicInfo(dynamicInfoProp);
}, [dynamicInfoProp]);
/**
* Use effect for handling 4D time index changed
*/
useEffect(() => {
if (!dynamicInfo) {
return;
}
const handleTimePointIndexChange = evt => {
const { volumeId, timePointIndex, numTimePoints, splittingTag } = evt.detail;
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
};
eventTarget.addEventListener(
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
return () => {
eventTarget.removeEventListener(
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
};
}, [dynamicInfo]);
useEffect(() => {
if (!dynamicInfo) {
return;
}
const { volumeId, timePointIndex, numTimePoints, splittingTag } = dynamicInfo || {};
const volume = cache.getVolume(volumeId, true);
volume.timePointIndex = timePointIndex;
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
}, []);
const updateDynamicInfo = useCallback(props => {
const { volumeId, timePointIndex } = props;
const volume = cache.getVolume(volumeId, true);
volume.timePointIndex = timePointIndex;
}, []);
return (
<CinePlayerComponent
className="absolute left-1/2 bottom-3 -translate-x-1/2"
frameRate={newStackFrameRate}
isPlaying={isPlaying}
onClose={() => {
// also stop the clip
cineService.setCine({
id: viewportId,
isPlaying: false,
});
cineService.setIsCineEnabled(false);
cineService.setViewportCineClosed(viewportId);
}}
onPlayPauseChange={isPlaying => {
cineService.setCine({
id: viewportId,
isPlaying,
});
}}
onFrameRateChange={frameRate =>
cineService.setCine({
id: viewportId,
frameRate,
})
}
dynamicInfo={dynamicInfo}
updateDynamicInfo={updateDynamicInfo}
/>
);
}
export default WrappedCinePlayer;

View File

@@ -0,0 +1,3 @@
import CinePlayer from './CinePlayer';
export default CinePlayer;

View File

@@ -0,0 +1,23 @@
.dicom-upload-drop-area-border-dash {
background-image: repeating-linear-gradient(
to right,
#7bb2ce 0%,
#7bb2ce 50%,
transparent 50%,
transparent 100%
),
repeating-linear-gradient(to right, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%),
repeating-linear-gradient(to bottom, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%),
repeating-linear-gradient(to bottom, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%);
background-position:
left top,
left bottom,
left top,
right top;
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
background-size:
20px 3px,
20px 3px,
3px 20px,
3px 20px;
}

View File

@@ -0,0 +1,108 @@
import React, { useCallback, useState } from 'react';
import { ReactElement } from 'react';
import Dropzone from 'react-dropzone';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import DicomFileUploader from '../../utils/DicomFileUploader';
import DicomUploadProgress from './DicomUploadProgress';
import { Button, ButtonEnums } from '@ohif/ui';
import './DicomUpload.css';
type DicomUploadProps = {
dataSource;
onComplete: () => void;
onStarted: () => void;
};
function DicomUpload({ dataSource, onComplete, onStarted }: DicomUploadProps): ReactElement {
const baseClassNames = 'min-h-[480px] flex flex-col bg-black select-none';
const [dicomFileUploaderArr, setDicomFileUploaderArr] = useState([]);
const onDrop = useCallback(async acceptedFiles => {
onStarted();
setDicomFileUploaderArr(acceptedFiles.map(file => new DicomFileUploader(file, dataSource)));
}, []);
const getDropZoneComponent = (): ReactElement => {
return (
<Dropzone
onDrop={acceptedFiles => {
onDrop(acceptedFiles);
}}
noClick
>
{({ getRootProps }) => (
<div
{...getRootProps()}
className="dicom-upload-drop-area-border-dash m-5 flex h-full flex-col items-center justify-center"
>
<div className="flex gap-3">
<Dropzone
onDrop={onDrop}
noDrag
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
disabled={false}
onClick={() => {}}
>
{'Add files'}
<input {...getInputProps()} />
</Button>
</div>
)}
</Dropzone>
<Dropzone
onDrop={onDrop}
noDrag
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<Button
type={ButtonEnums.type.secondary}
disabled={false}
onClick={() => {}}
>
{'Add folder'}
<input
{...getInputProps()}
webkitdirectory="true"
mozdirectory="true"
/>
</Button>
</div>
)}
</Dropzone>
</div>
<div className="pt-5">or drag images or folders here</div>
<div className="text-aqua-pale pt-3 text-lg">(DICOM files supported)</div>
</div>
)}
</Dropzone>
);
};
return (
<>
{dicomFileUploaderArr.length ? (
<div className={classNames('h-[calc(100vh-300px)]', baseClassNames)}>
<DicomUploadProgress
dicomFileUploaderArr={Array.from(dicomFileUploaderArr)}
onComplete={onComplete}
/>
</div>
) : (
<div className={classNames('h-[480px]', baseClassNames)}>{getDropZoneComponent()}</div>
)}
</>
);
}
DicomUpload.propTypes = {
dataSource: PropTypes.object.isRequired,
onComplete: PropTypes.func.isRequired,
onStarted: PropTypes.func.isRequired,
};
export default DicomUpload;

View File

@@ -0,0 +1,388 @@
import React, { useCallback, useEffect, useRef, useState, ReactElement } from 'react';
import PropTypes from 'prop-types';
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
import DicomFileUploader, {
EVENTS,
UploadStatus,
DicomFileUploaderProgressEvent,
UploadRejection,
} from '../../utils/DicomFileUploader';
import DicomUploadProgressItem from './DicomUploadProgressItem';
import classNames from 'classnames';
type DicomUploadProgressProps = {
dicomFileUploaderArr: DicomFileUploader[];
onComplete: () => void;
};
const ONE_SECOND = 1000;
const ONE_MINUTE = ONE_SECOND * 60;
const ONE_HOUR = ONE_MINUTE * 60;
// The base/initial interval time length used to calculate the
// rate of the upload and in turn estimate the
// the amount of time remaining for the upload. This is the length
// of the very first interval to get a reasonable estimate on screen in
// a reasonable amount of time. The length of each interval after the first
// is based on the upload rate calculated. Faster rates use this base interval
// length. Slower rates below UPLOAD_RATE_THRESHOLD get longer interval times
// to obtain more accurate upload rates.
const BASE_INTERVAL_TIME = 15000;
// The upload rate threshold to determine the length of the interval to
// calculate the upload rate.
const UPLOAD_RATE_THRESHOLD = 75;
const NO_WRAP_ELLIPSIS_CLASS_NAMES = 'text-ellipsis whitespace-nowrap overflow-hidden';
function DicomUploadProgress({
dicomFileUploaderArr,
onComplete,
}: DicomUploadProgressProps): ReactElement {
const [totalUploadSize] = useState(
dicomFileUploaderArr.reduce((acc, fileUploader) => acc + fileUploader.getFileSize(), 0)
);
const currentUploadSizeRef = useRef<number>(0);
const uploadRateRef = useRef(0);
const [timeRemaining, setTimeRemaining] = useState<number>(null);
const [percentComplete, setPercentComplete] = useState(0);
const [numFilesCompleted, setNumFilesCompleted] = useState(0);
const [numFails, setNumFails] = useState(0);
const [showFailedOnly, setShowFailedOnly] = useState(false);
const progressBarContainerRef = useRef<HTMLElement>();
/**
* The effect for measuring and setting the current upload rate. This is
* done by measuring the amount of data uploaded in a set interval time.
*/
useEffect(() => {
let timeoutId: NodeJS.Timeout;
// The amount of data already uploaded at the start of the interval.
let intervalStartUploadSize = 0;
// The starting time of the interval.
let intervalStartTime = Date.now();
const setUploadRateRef = () => {
const uploadSizeFromStartOfInterval = currentUploadSizeRef.current - intervalStartUploadSize;
const now = Date.now();
const timeSinceStartOfInterval = now - intervalStartTime;
// Calculate and set the upload rate (ref)
uploadRateRef.current = uploadSizeFromStartOfInterval / timeSinceStartOfInterval;
// Reset the interval starting values.
intervalStartUploadSize = currentUploadSizeRef.current;
intervalStartTime = now;
// Only start a new interval if there is more to upload.
if (totalUploadSize - currentUploadSizeRef.current > 0) {
if (uploadRateRef.current >= UPLOAD_RATE_THRESHOLD) {
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
} else {
// The current upload rate is relatively slow, so use a larger
// time interval to get a better upload rate estimate.
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME * 2);
}
}
};
// The very first interval is just the base time interval length.
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
return () => {
clearTimeout(timeoutId);
};
}, []);
/**
* The effect for: updating the overall percentage complete; setting the
* estimated time remaining; updating the number of files uploaded; and
* detecting if any error has occurred.
*/
useEffect(() => {
let currentTimeRemaining = null;
// For each uploader, listen for the progress percentage complete and
// add promise catch/finally callbacks to detect errors and count number
// of uploads complete.
const subscriptions = dicomFileUploaderArr.map(fileUploader => {
let currentFileUploadSize = 0;
const updateProgress = (percentComplete: number) => {
const previousFileUploadSize = currentFileUploadSize;
currentFileUploadSize = Math.round((percentComplete / 100) * fileUploader.getFileSize());
currentUploadSizeRef.current = Math.min(
totalUploadSize,
currentUploadSizeRef.current - previousFileUploadSize + currentFileUploadSize
);
setPercentComplete((currentUploadSizeRef.current / totalUploadSize) * 100);
if (uploadRateRef.current !== 0) {
const uploadSizeRemaining = totalUploadSize - currentUploadSizeRef.current;
const timeRemaining = Math.round(uploadSizeRemaining / uploadRateRef.current);
if (currentTimeRemaining === null) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
return;
}
// Do not show an increase in the time remaining by two seconds or minutes
// so as to prevent jumping the time remaining up and down constantly
// due to rounding, inaccuracies in the estimate and slight variations
// in upload rates over time.
if (timeRemaining < ONE_MINUTE) {
const currentSecondsRemaining = Math.ceil(currentTimeRemaining / ONE_SECOND);
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
const delta = secondsRemaining - currentSecondsRemaining;
if (delta < 0 || delta > 2) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
return;
}
if (timeRemaining < ONE_HOUR) {
const currentMinutesRemaining = Math.ceil(currentTimeRemaining / ONE_MINUTE);
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
const delta = minutesRemaining - currentMinutesRemaining;
if (delta < 0 || delta > 2) {
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
return;
}
// Hours remaining...
currentTimeRemaining = timeRemaining;
setTimeRemaining(currentTimeRemaining);
}
};
const progressCallback = (progressEvent: DicomFileUploaderProgressEvent) => {
updateProgress(progressEvent.percentComplete);
};
// Use the uploader promise to flag any error and count the number of
// uploads completed.
fileUploader
.load()
.catch((rejection: UploadRejection) => {
if (rejection.status === UploadStatus.Failed) {
setNumFails(numFails => numFails + 1);
}
})
.finally(() => {
// If any error occurred, the percent complete progress stops firing
// but this call to updateProgress nicely puts all finished uploads at 100%.
updateProgress(100);
setNumFilesCompleted(numCompleted => numCompleted + 1);
});
return fileUploader.subscribe(EVENTS.PROGRESS, progressCallback);
});
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
};
}, []);
const cancelAllUploads = useCallback(async () => {
for (const dicomFileUploader of dicomFileUploaderArr) {
// Important: we need a non-blocking way to cancel every upload,
// otherwise the UI will freeze and the user will not be able
// to interact with the app and progress will not be updated.
const promise = new Promise<void>((resolve, reject) => {
setTimeout(() => {
dicomFileUploader.cancel();
resolve();
}, 0);
});
}
}, []);
const getFormattedTimeRemaining = useCallback((): string => {
if (timeRemaining == null) {
return '';
}
if (timeRemaining < ONE_MINUTE) {
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
return `${secondsRemaining} ${secondsRemaining === 1 ? 'second' : 'seconds'}`;
}
if (timeRemaining < ONE_HOUR) {
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
return `${minutesRemaining} ${minutesRemaining === 1 ? 'minute' : 'minutes'}`;
}
const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR);
return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`;
}, [timeRemaining]);
const getPercentCompleteRounded = useCallback(
() => Math.min(100, Math.round(percentComplete)),
[percentComplete]
);
/**
* Determines if the progress bar should show the infinite animation or not.
* Show the infinite animation for progress less than 1% AND if less than
* one pixel of the progress bar would be displayed.
*/
const showInfiniteProgressBar = useCallback((): boolean => {
return (
getPercentCompleteRounded() < 1 &&
(progressBarContainerRef?.current?.offsetWidth ?? 0) * (percentComplete / 100) < 1
);
}, [getPercentCompleteRounded, percentComplete]);
/**
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
* of the style is width such that the 'n of m' is always a fixed width and thus
* as each file completes uploading the text on screen does not constantly shift
* left and right.
*/
const getNofMFilesStyle = useCallback(() => {
// the number of digits accounts for the digits being on each side of the ' of '
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
// the number of digits + 2 spaces and 2 characters for ' of '
const numChars = numDigits + 4;
return { width: `${numChars}ch` };
}, []);
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
return (
<div className="bg-primary-dark flex h-14 items-center px-1 pb-4 text-lg">
{numFilesCompleted === dicomFileUploaderArr.length ? (
<>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${dicomFileUploaderArr.length} ${
dicomFileUploaderArr.length > 1 ? 'files' : 'file'
} completed.`}</span>
<Button
disabled={false}
className="ml-auto"
onClick={onComplete}
>
{'Close'}
</Button>
</>
) : (
<>
<span
style={getNofMFilesStyle()}
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
>
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}&nbsp;
</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{' files completed.'}&nbsp;</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
{timeRemaining ? `Less than ${getFormattedTimeRemaining()} remaining. ` : ''}
</span>
<span
className={classNames(
NO_WRAP_ELLIPSIS_CLASS_NAMES,
'text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto cursor-pointer'
)}
onClick={cancelAllUploads}
>
Cancel All Uploads
</span>
</>
)}
</div>
);
};
const getShowFailedOnlyIconComponent = (): ReactElement => {
return (
<div className="ml-auto flex w-6 justify-center">
{numFails > 0 && (
<div onClick={() => setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)}>
<Icon
className="cursor-pointer"
name="icon-status-alert"
></Icon>
</div>
)}
</div>
);
};
const getPercentCompleteComponent = (): ReactElement => {
return (
<div className="ohif-scrollbar border-secondary-light overflow-y-scroll border-b px-2">
<div className="min-h-14 flex w-full items-center p-2.5">
{numFilesCompleted === dicomFileUploaderArr.length ? (
<>
<div className="text-primary-light text-xl">
{numFails > 0
? `Completed with ${numFails} ${numFails > 1 ? 'errors' : 'error'}!`
: 'Completed!'}
</div>
{getShowFailedOnlyIconComponent()}
</>
) : (
<>
<div
ref={progressBarContainerRef}
className="flex-grow"
>
<ProgressLoadingBar
progress={showInfiniteProgressBar() ? undefined : Math.min(100, percentComplete)}
></ProgressLoadingBar>
</div>
<div className="ml-1 flex w-24 items-center">
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
{getShowFailedOnlyIconComponent()}
</div>
</>
)}
</div>
</div>
);
};
return (
<div className="flex grow flex-col">
{getNumCompletedAndTimeRemainingComponent()}
<div className="flex grow flex-col overflow-hidden bg-black text-lg">
{getPercentCompleteComponent()}
<div className="ohif-scrollbar h-1 grow overflow-y-scroll px-2">
{dicomFileUploaderArr
.filter(
dicomFileUploader =>
!showFailedOnly || dicomFileUploader.getStatus() === UploadStatus.Failed
)
.map(dicomFileUploader => (
<DicomUploadProgressItem
key={dicomFileUploader.getFileId()}
dicomFileUploader={dicomFileUploader}
/>
))}
</div>
</div>
</div>
);
}
DicomUploadProgress.propTypes = {
dicomFileUploaderArr: PropTypes.arrayOf(PropTypes.instanceOf(DicomFileUploader)).isRequired,
onComplete: PropTypes.func.isRequired,
};
export default DicomUploadProgress;

View File

@@ -0,0 +1,109 @@
import React, { ReactElement, memo, useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import DicomFileUploader, {
DicomFileUploaderProgressEvent,
EVENTS,
UploadRejection,
UploadStatus,
} from '../../utils/DicomFileUploader';
import { Icon } from '@ohif/ui';
type DicomUploadProgressItemProps = {
dicomFileUploader: DicomFileUploader;
};
// eslint-disable-next-line react/display-name
const DicomUploadProgressItem = memo(
({ dicomFileUploader }: DicomUploadProgressItemProps): ReactElement => {
const [percentComplete, setPercentComplete] = useState(dicomFileUploader.getPercentComplete());
const [failedReason, setFailedReason] = useState('');
const [status, setStatus] = useState(dicomFileUploader.getStatus());
const isComplete = useCallback(() => {
return (
status === UploadStatus.Failed ||
status === UploadStatus.Cancelled ||
status === UploadStatus.Success
);
}, [status]);
useEffect(() => {
const progressSubscription = dicomFileUploader.subscribe(
EVENTS.PROGRESS,
(dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => {
setPercentComplete(dicomFileUploaderProgressEvent.percentComplete);
}
);
dicomFileUploader
.load()
.catch((reason: UploadRejection) => {
setStatus(reason.status);
setFailedReason(reason.message ?? '');
})
.finally(() => setStatus(dicomFileUploader.getStatus()));
return () => progressSubscription.unsubscribe();
}, []);
const cancelUpload = useCallback(() => {
dicomFileUploader.cancel();
}, []);
const getStatusIcon = (): ReactElement => {
switch (dicomFileUploader.getStatus()) {
case UploadStatus.Success:
return (
<Icon
name="status-tracked"
className="text-primary-light"
></Icon>
);
case UploadStatus.InProgress:
return <Icon name="icon-transferring"></Icon>;
case UploadStatus.Failed:
return <Icon name="icon-alert-small"></Icon>;
case UploadStatus.Cancelled:
return <Icon name="icon-alert-outline"></Icon>;
default:
return <></>;
}
};
return (
<div className="min-h-14 border-secondary-light flex w-full items-center overflow-hidden border-b p-2.5 text-lg">
<div className="self-top flex w-0 shrink grow flex-col gap-1">
<div className="flex gap-4">
<div className="flex w-6 shrink-0 items-center justify-center">{getStatusIcon()}</div>
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{dicomFileUploader.getFileName()}
</div>
</div>
{failedReason && <div className="pl-10">{failedReason}</div>}
</div>
<div className="flex w-24 items-center">
{!isComplete() && (
<>
{dicomFileUploader.getStatus() === UploadStatus.InProgress && (
<div className="w-10 text-right">{percentComplete}%</div>
)}
<div className="ml-auto flex cursor-pointer">
<Icon
className="text-primary-active self-center"
name="close"
onClick={cancelUpload}
/>
</div>
</>
)}
</div>
</div>
);
}
);
DicomUploadProgressItem.propTypes = {
dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired,
};
export default DicomUploadProgressItem;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useViewportActionCornersContext } from '../contextProviders/ViewportActionCornersProvider';
import { ViewportActionCorners } from '@ohif/ui';
export type OHIFViewportActionCornersProps = {
viewportId: string;
};
function OHIFViewportActionCorners({ viewportId }: OHIFViewportActionCornersProps) {
const [viewportActionCornersState] = useViewportActionCornersContext();
if (!viewportActionCornersState[viewportId]) {
return null;
}
return (
<ViewportActionCorners
cornerComponents={viewportActionCornersState[viewportId]}
></ViewportActionCorners>
);
}
export default OHIFViewportActionCorners;

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { Button, Icons, Separator } from '@ohif/ui-next';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
function ViewportSegmentationMenu({
viewportId,
servicesManager,
}: withAppTypes<{ viewportId: string }>) {
const { segmentationService } = servicesManager.services;
const [activeSegmentations, setActiveSegmentations] = useState([]);
const [availableSegmentations, setAvailableSegmentations] = useState([]);
useEffect(() => {
const updateSegmentations = () => {
const active = segmentationService.getSegmentationRepresentations(viewportId);
setActiveSegmentations(active);
const all = segmentationService.getSegmentations();
const available = all.filter(
seg => !active.some(activeSeg => activeSeg.segmentationId === seg.segmentationId)
);
setAvailableSegmentations(available);
};
updateSegmentations();
const subscriptions = [
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
segmentationService.EVENTS.SEGMENTATION_REMOVED,
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
].map(event => segmentationService.subscribe(event, updateSegmentations));
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
};
}, [segmentationService, viewportId]);
const toggleSegmentationRepresentationVisibility = (
segmentationId,
type = SegmentationRepresentations.Labelmap
) => {
segmentationService.toggleSegmentationRepresentationVisibility(viewportId, {
segmentationId,
type,
});
};
const addSegmentationToViewport = segmentationId => {
segmentationService.addSegmentationRepresentation(viewportId, { segmentationId });
};
const removeSegmentationFromViewport = segmentationId => {
segmentationService.removeSegmentationRepresentations(viewportId, {
segmentationId,
});
};
return (
<div className="bg-muted flex h-full w-[262px] flex-col rounded p-3">
<span className="text-muted-foreground mb-2 text-xs font-semibold">Current Viewport</span>
<ul className="space-y-1">
{activeSegmentations.map(segmentation => (
<li
key={segmentation.id}
className="flex items-center text-sm"
>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground mr-2"
onClick={() => removeSegmentationFromViewport(segmentation.segmentationId)}
>
<Icons.Minus className="h-6 w-6" />
</Button>
<span className="text-foreground flex-grow">{segmentation.label}</span>
{segmentation.visible ? (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
onClick={() =>
toggleSegmentationRepresentationVisibility(
segmentation.segmentationId,
segmentation.type
)
}
>
<Icons.Hide className="h-6 w-6" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
onClick={() =>
toggleSegmentationRepresentationVisibility(
segmentation.segmentationId,
segmentation.type
)
}
>
<Icons.Show className="h-6 w-6" />
</Button>
)}
</li>
))}
</ul>
{availableSegmentations.length > 0 && (
<>
<Separator className="bg-input mb-3" />
<span className="text-muted-foreground mb-2 text-xs font-semibold">Available</span>
<ul className="space-y-1">
{availableSegmentations.map(({segmentationId, label}) => (
<li
key={segmentationId}
className="flex items-center text-sm"
>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground mr-2"
onClick={() => addSegmentationToViewport(segmentationId)}
>
<Icons.Plus className="h-6 w-6" />
</Button>
<span className="text-foreground/60">{label}</span>
</li>
))}
</ul>
</>
)}
</div>
);
}
export default ViewportSegmentationMenu;

View File

@@ -0,0 +1,83 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { Button, Icons, Popover, PopoverContent, PopoverTrigger } from '@ohif/ui-next';
import ViewportSegmentationMenu from './ViewportSegmentationMenu';
import classNames from 'classnames';
import { useSegmentations } from '../../hooks/useSegmentations';
export function ViewportSegmentationMenuWrapper({
viewportId,
displaySets,
servicesManager,
commandsManager,
location,
}: withAppTypes<{
viewportId: string;
element: HTMLElement;
}>): ReactNode {
const { viewportActionCornersService, viewportGridService } = servicesManager.services;
const segmentations = useSegmentations({ servicesManager });
const activeViewportId = viewportGridService.getActiveViewportId();
const isActiveViewport = viewportId === activeViewportId;
const { align, side } = getAlignAndSide(viewportActionCornersService, location);
if (!segmentations?.length) {
return null;
}
return (
<Popover>
<PopoverTrigger
asChild
className="flex items-center justify-center"
>
<Button
variant="ghost"
size="icon"
>
<Icons.ViewportViews
className={classNames(
'text-highlight',
isActiveViewport ? 'visible' : 'invisible group-hover/pane:visible'
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className="border-none bg-transparent p-0 shadow-none"
side={side}
align={align}
alignOffset={-15}
sideOffset={5}
>
<ViewportSegmentationMenu
className="w-full"
viewportId={viewportId}
displaySets={displaySets}
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
</PopoverContent>
</Popover>
);
}
const getAlignAndSide = (viewportActionCornersService, location) => {
const ViewportActionCornersLocations = viewportActionCornersService.LOCATIONS;
switch (location) {
case ViewportActionCornersLocations.topLeft:
return { align: 'start', side: 'bottom' };
case ViewportActionCornersLocations.topRight:
return { align: 'end', side: 'bottom' };
case ViewportActionCornersLocations.bottomLeft:
return { align: 'start', side: 'top' };
case ViewportActionCornersLocations.bottomRight:
return { align: 'end', side: 'top' };
default:
console.debug('Unknown location, defaulting to bottom-start');
return { align: 'start', side: 'bottom' };
}
};

View File

@@ -0,0 +1,11 @@
import React, { ReactNode } from 'react';
import { ViewportSegmentationMenuWrapper } from './ViewportSegmentationMenuWrapper';
export function getViewportDataOverlaySettingsMenu(
props: withAppTypes<{
viewportId: string;
element: HTMLElement;
}>
): ReactNode {
return <ViewportSegmentationMenuWrapper {...props} />;
}

View File

@@ -0,0 +1,235 @@
import React, { useEffect, useCallback, useState, ReactElement, useMemo } from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash.debounce';
import { PanelSection, WindowLevel } from '@ohif/ui';
import { Enums, eventTarget } from '@cornerstonejs/core';
import { useActiveViewportDisplaySets } from '@ohif/core';
import {
getNodeOpacity,
isPetVolumeWithDefaultOpacity,
isVolumeWithConstantOpacity,
getWindowLevelsData,
} from './utils';
const { Events } = Enums;
const ViewportWindowLevel = ({
servicesManager,
viewportId,
}: withAppTypes<{
viewportId: string;
}>): ReactElement => {
const { cornerstoneViewportService } = servicesManager.services;
const [windowLevels, setWindowLevels] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const displaySets = useActiveViewportDisplaySets({ servicesManager });
const getViewportsWithVolumeIds = useCallback(
(volumeIds: string[]) => {
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const viewports = renderingEngine.getVolumeViewports();
return viewports.filter(vp => {
const viewportVolumeIds = vp.getActors().map(actor => actor.referencedId);
return (
volumeIds.length === viewportVolumeIds.length &&
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
);
});
},
[cornerstoneViewportService]
);
const getVolumeOpacity = useCallback((viewport, volumeId) => {
const volumeActor = viewport.getActors().find(actor => actor.referencedId === volumeId)?.actor;
if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) {
return getNodeOpacity(volumeActor, 1);
} else if (isVolumeWithConstantOpacity(volumeActor)) {
return getNodeOpacity(volumeActor, 0);
}
return undefined;
}, []);
const updateViewportHistograms = useCallback(() => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
getWindowLevelsData(viewport, viewportInfo, getVolumeOpacity).then(data => {
setWindowLevels(data);
});
}, [viewportId, cornerstoneViewportService, getVolumeOpacity]);
const handleCornerstoneVOIModified = useCallback(
e => {
const { detail } = e;
const { volumeId, range } = detail;
const oldWindowLevel = windowLevels.find(wl => wl.volumeId === volumeId);
if (!oldWindowLevel) {
return;
}
const oldVOI = oldWindowLevel.voi;
const windowWidth = range.upper - range.lower;
const windowCenter = range.lower + windowWidth / 2;
if (windowWidth === oldVOI.windowWidth && windowCenter === oldVOI.windowCenter) {
return;
}
const newWindowLevel = {
...oldWindowLevel,
voi: {
windowWidth,
windowCenter,
},
};
setWindowLevels(
windowLevels.map(windowLevel =>
windowLevel === oldWindowLevel ? newWindowLevel : windowLevel
)
);
},
[windowLevels]
);
const debouncedHandleCornerstoneVOIModified = useMemo(
() => debounce(handleCornerstoneVOIModified, 100),
[handleCornerstoneVOIModified]
);
const handleVOIChange = useCallback(
(volumeId, voi) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const newRange = {
lower: voi.windowCenter - voi.windowWidth / 2,
upper: voi.windowCenter + voi.windowWidth / 2,
};
viewport.setProperties({ voiRange: newRange }, volumeId);
viewport.render();
},
[cornerstoneViewportService, viewportId]
);
const handleOpacityChange = useCallback(
(viewportId, _volumeIndex, volumeId, opacity) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
const viewportVolumeIds = viewport.getActors().map(actor => actor.referencedId);
const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
viewports.forEach(vp => {
vp.setProperties({ colormap: { opacity } }, volumeId);
vp.render();
});
},
[getViewportsWithVolumeIds, cornerstoneViewportService]
);
// New function to handle image volume loading completion
const handleImageVolumeLoadingCompleted = useCallback(() => {
setIsLoading(false);
updateViewportHistograms();
}, [updateViewportHistograms]);
// Listen to cornerstone events and set up interval for histogram updates
useEffect(() => {
document.addEventListener(Events.VOI_MODIFIED, debouncedHandleCornerstoneVOIModified, true);
eventTarget.addEventListener(
Events.IMAGE_VOLUME_LOADING_COMPLETED,
handleImageVolumeLoadingCompleted
);
const intervalId = setInterval(() => {
if (isLoading) {
updateViewportHistograms();
}
}, 1000);
return () => {
document.removeEventListener(
Events.VOI_MODIFIED,
debouncedHandleCornerstoneVOIModified,
true
);
eventTarget.removeEventListener(
Events.IMAGE_VOLUME_LOADING_COMPLETED,
handleImageVolumeLoadingCompleted
);
clearInterval(intervalId);
};
}, [
updateViewportHistograms,
debouncedHandleCornerstoneVOIModified,
handleImageVolumeLoadingCompleted,
isLoading,
]);
// Create a memoized version of displaySet IDs for comparison
const displaySetIds = useMemo(() => {
return displaySets?.map(ds => ds.displaySetInstanceUID).sort() || [];
}, [displaySets]);
useEffect(() => {
const { unsubscribe } = cornerstoneViewportService.subscribe(
cornerstoneViewportService.EVENTS.VIEWPORT_VOLUMES_CHANGED,
({ viewportInfo }) => {
if (viewportInfo.viewportId === viewportId) {
updateViewportHistograms();
}
}
);
// Only update if displaySets actually changed and are loaded
if (displaySetIds.length && !isLoading) {
updateViewportHistograms();
}
return () => {
unsubscribe();
};
}, [viewportId, cornerstoneViewportService, updateViewportHistograms, displaySetIds, isLoading]);
return (
<PanelSection title="Window Level">
{windowLevels.map((windowLevel, i) => {
if (!windowLevel.histogram) {
return null;
}
return (
<WindowLevel
key={windowLevel.volumeId}
title={`${windowLevel.modality}`}
histogram={windowLevel.histogram}
voi={windowLevel.voi}
step={windowLevel.step}
showOpacitySlider={windowLevel.showOpacitySlider}
colormap={windowLevel.colormap}
onVOIChange={voi => handleVOIChange(windowLevel.volumeId, voi)}
opacity={windowLevel.opacity}
onOpacityChange={opacity =>
handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity)
}
/>
);
})}
</PanelSection>
);
};
ViewportWindowLevel.propTypes = {
servicesManager: PropTypes.object.isRequired,
viewportId: PropTypes.string.isRequired,
};
export default ViewportWindowLevel;

View File

@@ -0,0 +1,72 @@
import { getWebWorkerManager } from '@cornerstonejs/core';
const workerManager = getWebWorkerManager();
const WorkerOptions = {
maxWorkerInstances: 1,
autoTerminateOnIdle: {
enabled: true,
idleTimeThreshold: 1000,
},
};
// Register the task
const workerFn = () => {
return new Worker(new URL('./histogramWorker.js', import.meta.url), {
name: 'histogram-worker', // name used by the browser to name the worker
});
};
const getViewportVolumeHistogram = async (viewport, volume, options?) => {
workerManager.registerWorker('histogram-worker', workerFn, WorkerOptions);
const volumeImageData = viewport.getImageData(volume.volumeId);
if (!volumeImageData) {
return undefined;
}
let scalarData = volume.scalarData;
if (volume.numTimePoints > 1) {
const targetTimePoint = volume.numTimePoints - 1; // or any other time point you need
scalarData = volume.voxelManager.getTimePointScalarData(targetTimePoint);
} else {
scalarData = volume.voxelManager.getCompleteScalarDataArray();
}
if (!scalarData?.length) {
return undefined;
}
const { dimensions, origin, direction, spacing } = volume;
const range = await workerManager.executeTask('histogram-worker', 'getRange', {
dimensions,
origin,
direction,
spacing,
scalarData,
});
const { minimum: min, maximum: max } = range;
if (min === Infinity || max === -Infinity) {
return undefined;
}
const calcHistOptions = {
numBins: 256,
min: Math.max(min, options?.min ?? min),
max: Math.min(max, options?.max ?? max),
};
const histogram = await workerManager.executeTask('histogram-worker', 'calcHistogram', {
data: scalarData,
options: calcHistOptions,
});
return histogram;
};
export { getViewportVolumeHistogram };

View File

@@ -0,0 +1,97 @@
import { expose } from 'comlink';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
/**
* This object simulates a heavy task by implementing a sleep function and a recursive Fibonacci function.
* It's used for testing or demonstrating purposes where a heavy or time-consuming task is needed.
*/
const obj = {
getRange: ({ dimensions, origin, direction, spacing, scalarData }) => {
const imageData = vtkImageData.newInstance();
imageData.setDimensions(dimensions);
imageData.setOrigin(origin);
imageData.setDirection(direction);
imageData.setSpacing(spacing);
const scalarArray = vtkDataArray.newInstance({
name: 'Pixels',
numberOfComponents: 1,
values: scalarData,
});
imageData.getPointData().setScalars(scalarArray);
imageData.modified();
const range = imageData.computeHistogram(imageData.getBounds());
return range;
},
calcHistogram: ({ data, options }) => {
if (options === undefined) {
options = {};
}
const histogram = {
numBins: options.numBins || 256,
range: { min: 0, max: 0 },
bins: new Int32Array(1),
maxBin: 0,
maxBinValue: 0,
};
let minToUse = options.min;
let maxToUse = options.max;
if (minToUse === undefined || maxToUse === undefined) {
let min = Infinity;
let max = -Infinity;
let index = data.length;
while (index--) {
const value = data[index];
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
}
minToUse = min;
maxToUse = max;
}
histogram.range = { min: minToUse, max: maxToUse };
const bins = new Int32Array(histogram.numBins);
const binScale = histogram.numBins / (maxToUse - minToUse);
for (let index = 0; index < data.length; index++) {
const value = data[index];
if (value < minToUse) {
continue;
}
if (value > maxToUse) {
continue;
}
const bin = Math.floor((value - minToUse) * binScale);
bins[bin] += 1;
}
histogram.bins = bins;
histogram.maxBin = 0;
histogram.maxBinValue = 0;
for (let bin = 0; bin < histogram.numBins; bin++) {
if (histogram.bins[bin] > histogram.maxBinValue) {
histogram.maxBin = bin;
histogram.maxBinValue = histogram.bins[bin];
}
}
return histogram;
},
};
expose(obj);

View File

@@ -0,0 +1 @@
export { default } from './ViewportWindowLevel';

View File

@@ -0,0 +1,153 @@
import { cache as cs3DCache, Types } from '@cornerstonejs/core';
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
import { utilities as csUtils } from '@cornerstonejs/core';
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
/**
* Gets node opacity from volume actor
*/
export const getNodeOpacity = (volumeActor, nodeIndex) => {
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
const nodeValue = [];
volumeOpacity.getNodeValue(nodeIndex, nodeValue);
return nodeValue[1];
};
/**
* Checks if the opacity applied to the PET volume follows a specific pattern
*/
export const isPetVolumeWithDefaultOpacity = (volumeId: string, volumeActor) => {
const volume = cs3DCache.getVolume(volumeId);
if (!volume || volume.metadata.Modality !== 'PT') {
return false;
}
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
if (volumeOpacity.getSize() < 2) {
return false;
}
const node1Value = [];
const node2Value = [];
volumeOpacity.getNodeValue(0, node1Value);
volumeOpacity.getNodeValue(1, node2Value);
if (node1Value[0] !== 0 || node1Value[1] !== 0 || node2Value[0] !== 0.1) {
return false;
}
const expectedOpacity = node2Value[1];
const opacitySize = volumeOpacity.getSize();
const currentNodeValue = [];
for (let i = 2; i < opacitySize; i++) {
volumeOpacity.getNodeValue(i, currentNodeValue);
if (currentNodeValue[1] !== expectedOpacity) {
return false;
}
}
return true;
};
/**
* Checks if volume has constant opacity
*/
export const isVolumeWithConstantOpacity = volumeActor => {
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
const opacitySize = volumeOpacity.getSize();
const firstNodeValue = [];
volumeOpacity.getNodeValue(0, firstNodeValue);
const firstNodeOpacity = firstNodeValue[1];
for (let i = 0; i < opacitySize; i++) {
const currentNodeValue = [];
volumeOpacity.getNodeValue(0, currentNodeValue);
if (currentNodeValue[1] !== firstNodeOpacity) {
return false;
}
}
return true;
};
/**
* Gets window levels data for a viewport
*/
export const getWindowLevelsData = async (
viewport: Types.IStackViewport | Types.IVolumeViewport,
viewportInfo: any,
getVolumeOpacity: (viewport: any, volumeId: string) => number | undefined
) => {
if (!viewport) {
return [];
}
const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
const viewportProperties = viewport.getProperties();
const { voiRange } = viewportProperties;
const viewportVoi = voiRange
? {
windowWidth: voiRange.upper - voiRange.lower,
windowCenter: voiRange.lower + (voiRange.upper - voiRange.lower) / 2,
}
: undefined;
const windowLevels = await Promise.all(
volumeIds.map(async (volumeId, volumeIndex) => {
const volume = cs3DCache.getVolume(volumeId);
const opacity = getVolumeOpacity(viewport, volumeId);
const { metadata, scaling } = volume;
const modality = metadata.Modality;
const options = {
min: modality === 'PT' ? 0.1 : -999,
max: modality === 'PT' ? 5 : 10000,
};
const histogram = await getViewportVolumeHistogram(viewport, volume, options);
if (!histogram || histogram.range.min === histogram.range.max) {
return null;
}
if (!viewportInfo.displaySetOptions || !viewportInfo.displaySetOptions[volumeIndex]) {
return null;
}
const { voi: displaySetVOI, colormap: displaySetColormap } =
viewportInfo.displaySetOptions[volumeIndex];
let colormap;
if (displaySetColormap) {
colormap =
csUtils.colormap.getColormap(displaySetColormap.name) ??
vtkColorMaps.getPresetByName(displaySetColormap.name);
}
const voi = !volumeIndex ? (viewportVoi ?? displaySetVOI) : displaySetVOI;
return {
viewportId: viewportInfo.viewportId,
modality,
volumeId,
volumeIndex,
voi,
histogram,
colormap,
step: scaling?.PT ? 0.05 : 1,
opacity,
showOpacitySlider: volumeIndex === 1 && opacity !== undefined,
};
})
);
return windowLevels.filter(Boolean);
};

View File

@@ -0,0 +1,115 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { SwitchButton } from '@ohif/ui';
import { StackViewport, VolumeViewport } from '@cornerstonejs/core';
import { ColorbarProps } from '../../types/Colorbar';
import { utilities } from '@cornerstonejs/core';
export function setViewportColorbar(
viewportId,
displaySets,
commandsManager,
servicesManager: AppTypes.ServicesManager,
colorbarOptions
) {
const { cornerstoneViewportService } = servicesManager.services;
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
const backgroundColor = viewportInfo.getViewportOptions().background;
const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false;
if (isLight) {
colorbarOptions.ticks = {
position: 'left',
style: {
font: '12px Arial',
color: '#000000',
maxNumTicks: 8,
tickSize: 5,
tickWidth: 1,
labelMargin: 3,
},
};
}
const displaySetInstanceUIDs = [];
if (viewport instanceof StackViewport) {
displaySetInstanceUIDs.push(viewportId);
}
if (viewport instanceof VolumeViewport) {
displaySets.forEach(ds => {
displaySetInstanceUIDs.push(ds.displaySetInstanceUID);
});
}
commandsManager.run({
commandName: 'toggleViewportColorbar',
commandOptions: {
viewportId,
options: colorbarOptions,
displaySetInstanceUIDs,
},
context: 'CORNERSTONE',
});
}
export function Colorbar({
viewportId,
displaySets,
commandsManager,
servicesManager,
colorbarProperties,
}: withAppTypes<ColorbarProps>): ReactElement {
const { colorbarService } = servicesManager.services;
const {
width: colorbarWidth,
colorbarTickPosition,
colorbarContainerPosition,
colormaps,
colorbarInitialColormap,
} = colorbarProperties;
const [showColorbar, setShowColorbar] = useState(colorbarService.hasColorbar(viewportId));
const onSetColorbar = useCallback(() => {
setViewportColorbar(viewportId, displaySets, commandsManager, servicesManager, {
viewportId,
colormaps,
ticks: {
position: colorbarTickPosition,
},
width: colorbarWidth,
position: colorbarContainerPosition,
activeColormapName: colorbarInitialColormap,
});
}, [commandsManager]);
useEffect(() => {
const updateColorbarState = () => {
setShowColorbar(colorbarService.hasColorbar(viewportId));
};
const { unsubscribe } = colorbarService.subscribe(
colorbarService.EVENTS.STATE_CHANGED,
updateColorbarState
);
return () => {
unsubscribe();
};
}, [viewportId]);
return (
<div className="all-in-one-menu-item flex w-full justify-center">
<div className="mr-2 w-[28px]"></div>
<SwitchButton
label="Display Color bar"
checked={showColorbar}
onChange={() => {
onSetColorbar();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React, { ReactElement, useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { AllInOneMenu, ButtonGroup, SwitchButton } from '@ohif/ui';
import { StackViewport, Types } from '@cornerstonejs/core';
import { ColormapProps } from '../../types/Colormap';
export function Colormap({
colormaps,
viewportId,
displaySets,
commandsManager,
servicesManager,
}: ColormapProps): ReactElement {
const { cornerstoneViewportService } = servicesManager.services;
const [activeDisplaySet, setActiveDisplaySet] = useState(displaySets[0]);
const [showPreview, setShowPreview] = useState(false);
const [prePreviewColormap, setPrePreviewColormap] = useState(null);
const showPreviewRef = useRef(showPreview);
showPreviewRef.current = showPreview;
const prePreviewColormapRef = useRef(prePreviewColormap);
prePreviewColormapRef.current = prePreviewColormap;
const activeDisplaySetRef = useRef(activeDisplaySet);
activeDisplaySetRef.current = activeDisplaySet;
const onSetColorLUT = useCallback(
props => {
// TODO: Better way to check if it's a fusion
const oneOpacityColormaps = ['Grayscale', 'X Ray'];
const opacity =
displaySets.length > 1 && !oneOpacityColormaps.includes(props.colormap.name) ? 0.5 : 1;
commandsManager.run({
commandName: 'setViewportColormap',
commandOptions: {
...props,
opacity,
immediate: true,
},
context: 'CORNERSTONE',
});
},
[commandsManager]
);
const getViewportColormap = (viewportId, displaySet) => {
const { displaySetInstanceUID } = displaySet;
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport instanceof StackViewport) {
const { colormap } = viewport.getProperties();
if (!colormap) {
return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0];
}
return colormap;
}
const actorEntries = viewport.getActors();
const actorEntry = actorEntries?.find(entry =>
entry.referencedId.includes(displaySetInstanceUID)
);
const { colormap } = (viewport as Types.IVolumeViewport).getProperties(actorEntry.referencedId);
if (!colormap) {
return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0];
}
return colormap;
};
const buttons = useMemo(() => {
return displaySets.map((displaySet, index) => ({
children: displaySet.Modality,
key: index,
style: {
minWidth: `calc(100% / ${displaySets.length})`,
fontSize: '0.8rem',
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
}));
}, [displaySets]);
useEffect(() => {
setActiveDisplaySet(displaySets[displaySets.length - 1]);
}, [displaySets]);
return (
<>
{buttons.length > 1 && (
<div className="all-in-one-menu-item flex w-full justify-center">
<ButtonGroup
onActiveIndexChange={index => {
setActiveDisplaySet(displaySets[index]);
setPrePreviewColormap(null);
}}
activeIndex={
displaySets.findIndex(
ds => ds.displaySetInstanceUID === activeDisplaySetRef.current.displaySetInstanceUID
) || 1
}
className="w-[70%] text-[10px]"
>
{buttons.map(({ children, key, style }) => (
<div
key={key}
style={style}
>
{children}
</div>
))}
</ButtonGroup>
</div>
)}
<div className="all-in-one-menu-item flex w-full justify-center">
<SwitchButton
label="Preview in viewport"
checked={showPreview}
onChange={checked => {
setShowPreview(checked);
}}
/>
</div>
<AllInOneMenu.DividerItem />
<AllInOneMenu.ItemPanel>
{colormaps.map((colormap, index) => (
<AllInOneMenu.Item
key={index}
label={colormap.description}
onClick={() => {
onSetColorLUT({
viewportId,
colormap,
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
});
setPrePreviewColormap(null);
}}
onMouseEnter={() => {
if (showPreviewRef.current) {
setPrePreviewColormap(getViewportColormap(viewportId, activeDisplaySetRef.current));
onSetColorLUT({
viewportId,
colormap,
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
});
}
}}
onMouseLeave={() => {
if (showPreviewRef.current && prePreviewColormapRef.current) {
onSetColorLUT({
viewportId,
colormap: prePreviewColormapRef.current,
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
});
}
}}
></AllInOneMenu.Item>
))}
</AllInOneMenu.ItemPanel>
</>
);
}

View File

@@ -0,0 +1,130 @@
import React, { ReactElement, useState, useEffect, useCallback } from 'react';
import { VolumeLightingProps } from '../../types/ViewportPresets';
export function VolumeLighting({
servicesManager,
commandsManager,
viewportId,
}: VolumeLightingProps): ReactElement {
const { cornerstoneViewportService } = servicesManager.services;
const [ambient, setAmbient] = useState(null);
const [diffuse, setDiffuse] = useState(null);
const [specular, setSpecular] = useState(null);
const onAmbientChange = useCallback(() => {
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { ambient } });
}, [ambient, commandsManager, viewportId]);
const onDiffuseChange = useCallback(() => {
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { diffuse } });
}, [diffuse, commandsManager, viewportId]);
const onSpecularChange = useCallback(() => {
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { specular } });
}, [specular, commandsManager, viewportId]);
const calculateBackground = value => {
const percentage = ((value - 0) / (1 - 0)) * 100;
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
};
useEffect(() => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { actor } = viewport.getActors()[0];
const ambient = actor.getProperty().getAmbient();
const diffuse = actor.getProperty().getDiffuse();
const specular = actor.getProperty().getSpecular();
setAmbient(ambient);
setDiffuse(diffuse);
setSpecular(specular);
}, [viewportId, cornerstoneViewportService]);
return (
<>
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
<label
className="block text-white"
htmlFor="ambient"
>
Ambient
</label>
{ambient !== null && (
<input
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
value={ambient}
onChange={e => {
setAmbient(e.target.value);
onAmbientChange();
}}
id="ambient"
max={1}
min={0}
type="range"
step={0.1}
style={{
background: calculateBackground(ambient),
'--thumb-inner-color': '#5acce6',
'--thumb-outer-color': '#090c29',
}}
/>
)}
</div>
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
<label
className="block text-white"
htmlFor="diffuse"
>
Diffuse
</label>
{diffuse !== null && (
<input
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
value={diffuse}
onChange={e => {
setDiffuse(e.target.value);
onDiffuseChange();
}}
id="diffuse"
max={1}
min={0}
type="range"
step={0.1}
style={{
background: calculateBackground(diffuse),
'--thumb-inner-color': '#5acce6',
'--thumb-outer-color': '#090c29',
}}
/>
)}
</div>
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
<label
className="block text-white"
htmlFor="specular"
>
Specular
</label>
{specular !== null && (
<input
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
value={specular}
onChange={e => {
setSpecular(e.target.value);
onSpecularChange();
}}
id="specular"
max={1}
min={0}
type="range"
step={0.1}
style={{
background: calculateBackground(specular),
'--thumb-inner-color': '#5acce6',
'--thumb-outer-color': '#090c29',
}}
/>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
import React, { ReactElement } from 'react';
import { AllInOneMenu } from '@ohif/ui';
import { VolumeRenderingOptionsProps } from '../../types/ViewportPresets';
import { VolumeRenderingQuality } from './VolumeRenderingQuality';
import { VolumeShift } from './VolumeShift';
import { VolumeLighting } from './VolumeLighting';
import { VolumeShade } from './VolumeShade';
export function VolumeRenderingOptions({
viewportId,
commandsManager,
volumeRenderingQualityRange,
servicesManager,
}: VolumeRenderingOptionsProps): ReactElement {
return (
<AllInOneMenu.ItemPanel>
<VolumeRenderingQuality
viewportId={viewportId}
commandsManager={commandsManager}
servicesManager={servicesManager}
volumeRenderingQualityRange={volumeRenderingQualityRange}
/>
<VolumeShift
viewportId={viewportId}
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
<div className="all-in-one-menu-item mt-2 flex !h-[20px] w-full justify-start">
<div className="text-aqua-pale text-[13px]">LIGHTING</div>
</div>
<div className="bg-primary-dark mt-1 mb-1 h-[2px] w-full"></div>
<div className="all-in-one-menu-item flex w-full justify-center">
<VolumeShade
commandsManager={commandsManager}
servicesManager={servicesManager}
viewportId={viewportId}
/>
</div>
<VolumeLighting
viewportId={viewportId}
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
</AllInOneMenu.ItemPanel>
);
}

View File

@@ -0,0 +1,38 @@
import { AllInOneMenu, Icon } from '@ohif/ui';
import React, { ReactElement } from 'react';
import { VolumeRenderingPresetsProps } from '../../types/ViewportPresets';
import { VolumeRenderingPresetsContent } from './VolumeRenderingPresetsContent';
export function VolumeRenderingPresets({
viewportId,
servicesManager,
commandsManager,
volumeRenderingPresets,
}: VolumeRenderingPresetsProps): ReactElement {
const { uiModalService } = servicesManager.services;
const onClickPresets = () => {
uiModalService.show({
content: VolumeRenderingPresetsContent,
title: 'Rendering Presets',
movable: true,
contentProps: {
onClose: uiModalService.hide,
presets: volumeRenderingPresets,
viewportId,
commandsManager,
},
containerDimensions: 'h-[543px] w-[460px]',
contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]',
});
};
return (
<AllInOneMenu.Item
label="Rendering Presets"
icon={<Icon name="VolumeRendering" />}
rightIcon={<Icon name="action-new-dialog" />}
onClick={onClickPresets}
/>
);
}

View File

@@ -0,0 +1,95 @@
import { Icon } from '@ohif/ui';
import { ButtonEnums } from '@ohif/ui';
import React, { ReactElement, useState, useCallback } from 'react';
import { Button, InputFilterText } from '@ohif/ui';
import { ViewportPreset, VolumeRenderingPresetsContentProps } from '../../types/ViewportPresets';
export function VolumeRenderingPresetsContent({
presets,
viewportId,
commandsManager,
onClose,
}: VolumeRenderingPresetsContentProps): ReactElement {
const [filteredPresets, setFilteredPresets] = useState(presets);
const [searchValue, setSearchValue] = useState('');
const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
const handleSearchChange = useCallback(
(value: string) => {
setSearchValue(value);
const filtered = value
? presets.filter(preset => preset.name.toLowerCase().includes(value.toLowerCase()))
: presets;
setFilteredPresets(filtered);
},
[presets]
);
const handleApply = useCallback(
props => {
commandsManager.runCommand('setViewportPreset', {
...props,
});
},
[commandsManager]
);
const formatLabel = (label: string, maxChars: number) => {
return label.length > maxChars ? `${label.slice(0, maxChars)}...` : label;
};
return (
<div className="flex min-h-full w-full flex-col justify-between">
<div className="border-secondary-light h-[433px] w-full overflow-hidden rounded border bg-black px-2.5">
<div className="flex h-[46px] w-full items-center justify-start">
<div className="h-[26px] w-[200px]">
<InputFilterText
value={searchValue}
onDebounceChange={handleSearchChange}
placeholder={'Search all'}
/>
</div>
</div>
<div className="ohif-scrollbar overflow h-[385px] w-full overflow-y-auto">
<div className="grid grid-cols-4 gap-3 pt-2 pr-3">
{filteredPresets.map((preset, index) => (
<div
key={index}
className="flex cursor-pointer flex-col items-start"
onClick={() => {
setSelectedPreset(preset);
handleApply({ preset: preset.name, viewportId });
}}
>
<Icon
name={preset.name}
className={
selectedPreset?.name === preset.name
? 'border-primary-light h-[75px] w-[95px] max-w-none rounded border-2'
: 'hover:border-primary-light h-[75px] w-[95px] max-w-none rounded border-2 border-black'
}
/>
<label className="text-aqua-pale mt-2 text-left text-xs">
{formatLabel(preset.name, 11)}
</label>
</div>
))}
</div>
</div>
</div>
<footer className="flex h-[60px] w-full items-center justify-end">
<div className="flex">
<Button
name="Cancel"
size={ButtonEnums.size.medium}
type={ButtonEnums.type.secondary}
onClick={onClose}
>
{' '}
Cancel{' '}
</Button>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import React, { ReactElement, useCallback, useState, useEffect } from 'react';
import { VolumeRenderingQualityProps } from '../../types/ViewportPresets';
export function VolumeRenderingQuality({
volumeRenderingQualityRange,
commandsManager,
servicesManager,
viewportId,
}: VolumeRenderingQualityProps): ReactElement {
const { cornerstoneViewportService } = servicesManager.services;
const { min, max, step } = volumeRenderingQualityRange;
const [quality, setQuality] = useState(null);
const onChange = useCallback(
(value: number) => {
commandsManager.runCommand('setVolumeRenderingQulaity', {
viewportId,
volumeQuality: value,
});
setQuality(value);
},
[commandsManager, viewportId]
);
const calculateBackground = value => {
const percentage = ((value - 0) / (1 - 0)) * 100;
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
};
useEffect(() => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { actor } = viewport.getActors()[0];
const mapper = actor.getMapper();
const image = mapper.getInputData();
const spacing = image.getSpacing();
const sampleDistance = mapper.getSampleDistance();
const averageSpacing = spacing.reduce((a, b) => a + b) / 3.0;
if (sampleDistance === averageSpacing) {
setQuality(1);
} else {
setQuality(Math.sqrt(averageSpacing / (sampleDistance * 0.5)));
}
}, [cornerstoneViewportService, viewportId]);
return (
<>
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
<label
className="block text-white"
htmlFor="volume"
>
Quality
</label>
{quality !== null && (
<input
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
value={quality}
id="volume"
max={max}
min={min}
type="range"
step={step}
onChange={e => onChange(parseInt(e.target.value, 10))}
style={{
background: calculateBackground((quality - min) / (max - min)),
'--thumb-inner-color': '#5acce6',
'--thumb-outer-color': '#090c29',
}}
/>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,39 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { SwitchButton } from '@ohif/ui';
import { VolumeShadeProps } from '../../types/ViewportPresets';
export function VolumeShade({
commandsManager,
viewportId,
servicesManager,
}: VolumeShadeProps): ReactElement {
const { cornerstoneViewportService } = servicesManager.services;
const [shade, setShade] = useState(true);
const [key, setKey] = useState(0);
const onShadeChange = useCallback(
(checked: boolean) => {
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { shade: checked } });
},
[commandsManager, viewportId]
);
useEffect(() => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { actor } = viewport.getActors()[0];
const shade = actor.getProperty().getShade();
setShade(shade);
setKey(key + 1);
}, [viewportId, cornerstoneViewportService]);
return (
<SwitchButton
key={key}
label="Shade"
checked={shade}
onChange={() => {
setShade(!shade);
onShadeChange(!shade);
}}
/>
);
}

View File

@@ -0,0 +1,93 @@
import React, { ReactElement, useCallback, useEffect, useState, useRef } from 'react';
import { VolumeShiftProps } from '../../types/ViewportPresets';
export function VolumeShift({
viewportId,
commandsManager,
servicesManager,
}: VolumeShiftProps): ReactElement {
const { cornerstoneViewportService } = servicesManager.services;
const [minShift, setMinShift] = useState<number | null>(null);
const [maxShift, setMaxShift] = useState<number | null>(null);
const [shift, setShift] = useState<number | null>(
cornerstoneViewportService.getCornerstoneViewport(viewportId)?.shiftedBy || 0
);
const [step, setStep] = useState<number | null>(null);
const [isBlocking, setIsBlocking] = useState(false);
const prevShiftRef = useRef<number>(shift);
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { actor } = viewport.getActors()[0];
const ofun = actor.getProperty().getScalarOpacity(0);
useEffect(() => {
if (isBlocking) {
return;
}
const range = ofun.getRange();
const transferFunctionWidth = range[1] - range[0];
const minShift = -transferFunctionWidth;
const maxShift = transferFunctionWidth;
setMinShift(minShift);
setMaxShift(maxShift);
setStep(Math.pow(10, Math.floor(Math.log10(transferFunctionWidth / 500))));
}, [cornerstoneViewportService, viewportId, actor, ofun, isBlocking]);
const onChangeRange = useCallback(
newShift => {
const shiftDifference = newShift - prevShiftRef.current;
prevShiftRef.current = newShift;
viewport.shiftedBy = newShift;
commandsManager.runCommand('shiftVolumeOpacityPoints', {
viewportId,
shift: shiftDifference,
});
},
[commandsManager, viewportId, viewport]
);
const calculateBackground = value => {
const percentage = ((value - 0) / (1 - 0)) * 100;
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
};
return (
<>
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
<label
className="block text-white"
htmlFor="shift"
>
Shift
</label>
{step !== null && (
<input
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
value={shift}
onChange={e => {
const shiftValue = parseInt(e.target.value, 10);
setShift(shiftValue);
onChangeRange(shiftValue);
}}
id="shift"
onMouseDown={() => setIsBlocking(true)}
onMouseUp={() => setIsBlocking(false)}
max={maxShift}
min={minShift}
type="range"
step={step}
style={{
background: calculateBackground((shift - minShift) / (maxShift - minShift)),
'--thumb-inner-color': '#5acce6',
'--thumb-outer-color': '#090c29',
}}
/>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,57 @@
import React, { ReactElement, useCallback } from 'react';
import { AllInOneMenu } from '@ohif/ui';
import { WindowLevelPreset } from '../../types/WindowLevel';
import { CommandsManager } from '@ohif/core';
import { useTranslation } from 'react-i18next';
export type WindowLevelProps = {
viewportId: string;
presets: Array<Record<string, Array<WindowLevelPreset>>>;
commandsManager: CommandsManager;
};
export function WindowLevel({
viewportId,
commandsManager,
presets,
}: WindowLevelProps): ReactElement {
const { t } = useTranslation('WindowLevelActionMenu');
const onSetWindowLevel = useCallback(
props => {
commandsManager.run({
commandName: 'setViewportWindowLevel',
commandOptions: {
...props,
viewportId,
},
context: 'CORNERSTONE',
});
},
[commandsManager, viewportId]
);
return (
<AllInOneMenu.ItemPanel>
{presets.map((modalityPresets, modalityIndex) => (
<React.Fragment key={modalityIndex}>
{Object.entries(modalityPresets).map(([modality, presetsArray]) => (
<React.Fragment key={modality}>
<AllInOneMenu.HeaderItem>
{t('Modality Presets', { modality })}
</AllInOneMenu.HeaderItem>
{presetsArray.map((preset, index) => (
<AllInOneMenu.Item
key={`${modality}-${index}`}
label={preset.description}
secondaryLabel={`${preset.window} / ${preset.level}`}
onClick={() => onSetWindowLevel(preset)}
/>
))}
</React.Fragment>
))}
</React.Fragment>
))}
</AllInOneMenu.ItemPanel>
);
}

View File

@@ -0,0 +1,193 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { AllInOneMenu, useViewportGrid } from '@ohif/ui';
import { Colormap } from './Colormap';
import { Colorbar } from './Colorbar';
import { setViewportColorbar } from './Colorbar';
import { WindowLevelPreset } from '../../types/WindowLevel';
import { ColorbarProperties } from '../../types/Colorbar';
import { VolumeRenderingQualityRange } from '../../types/ViewportPresets';
import { WindowLevel } from './WindowLevel';
import { VolumeRenderingPresets } from './VolumeRenderingPresets';
import { VolumeRenderingOptions } from './VolumeRenderingOptions';
import { ViewportPreset } from '../../types/ViewportPresets';
import { VolumeViewport3D } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/core';
export const nonWLModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE'];
export type WindowLevelActionMenuProps = {
viewportId: string;
element: HTMLElement;
presets: Array<Record<string, Array<WindowLevelPreset>>>;
colorbarProperties: ColorbarProperties;
displaySets: Array<any>;
volumeRenderingPresets: Array<ViewportPreset>;
volumeRenderingQualityRange: VolumeRenderingQualityRange;
};
export function WindowLevelActionMenu({
viewportId,
element,
presets,
verticalDirection,
horizontalDirection,
commandsManager,
servicesManager,
colorbarProperties,
displaySets,
volumeRenderingPresets,
volumeRenderingQualityRange,
}: withAppTypes<WindowLevelActionMenuProps>): ReactElement {
const {
colormaps,
colorbarContainerPosition,
colorbarInitialColormap,
colorbarTickPosition,
width: colorbarWidth,
} = colorbarProperties;
const { colorbarService, cornerstoneViewportService } = servicesManager.services;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const backgroundColor = viewportInfo.getViewportOptions().background;
const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false;
const { t } = useTranslation('WindowLevelActionMenu');
const [viewportGrid] = useViewportGrid();
const { activeViewportId } = viewportGrid;
const [vpHeight, setVpHeight] = useState(element?.clientHeight);
const [menuKey, setMenuKey] = useState(0);
const [is3DVolume, setIs3DVolume] = useState(false);
const onSetColorbar = useCallback(() => {
setViewportColorbar(viewportId, displaySets, commandsManager, servicesManager, {
colormaps,
ticks: {
position: colorbarTickPosition,
},
width: colorbarWidth,
position: colorbarContainerPosition,
activeColormapName: colorbarInitialColormap,
});
}, [commandsManager]);
useEffect(() => {
const newVpHeight = element?.clientHeight;
if (vpHeight !== newVpHeight) {
setVpHeight(newVpHeight);
}
}, [element, vpHeight]);
useEffect(() => {
if (!colorbarService.hasColorbar(viewportId)) {
return;
}
window.setTimeout(() => {
colorbarService.removeColorbar(viewportId);
onSetColorbar();
}, 0);
}, [viewportId, displaySets, viewport]);
useEffect(() => {
setMenuKey(menuKey + 1);
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport instanceof VolumeViewport3D) {
setIs3DVolume(true);
} else {
setIs3DVolume(false);
}
}, [
displaySets,
viewportId,
presets,
volumeRenderingQualityRange,
volumeRenderingPresets,
colorbarProperties,
activeViewportId,
viewportGrid,
]);
return (
<AllInOneMenu.IconMenu
icon="viewport-window-level"
verticalDirection={verticalDirection}
horizontalDirection={horizontalDirection}
iconClassName={classNames(
// Visible on hover and for the active viewport
activeViewportId === viewportId ? 'visible' : 'invisible group-hover/pane:visible',
'flex shrink-0 cursor-pointer rounded active:text-white text-primary-light',
isLight ? ' hover:bg-secondary-dark' : 'hover:bg-secondary-light/60'
)}
menuStyle={{ maxHeight: vpHeight - 32, minWidth: 218 }}
onVisibilityChange={() => {
setVpHeight(element.clientHeight);
}}
menuKey={menuKey}
>
<AllInOneMenu.ItemPanel>
{!is3DVolume && (
<Colorbar
viewportId={viewportId}
displaySets={displaySets.filter(ds => !nonWLModalities.includes(ds.Modality))}
commandsManager={commandsManager}
servicesManager={servicesManager}
colorbarProperties={colorbarProperties}
/>
)}
{colormaps && !is3DVolume && (
<AllInOneMenu.SubMenu
key="colorLUTPresets"
itemLabel="Color LUT"
itemIcon="icon-color-lut"
>
<Colormap
colormaps={colormaps}
viewportId={viewportId}
displaySets={displaySets.filter(ds => !nonWLModalities.includes(ds.Modality))}
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
</AllInOneMenu.SubMenu>
)}
{presets && presets.length > 0 && !is3DVolume && (
<AllInOneMenu.SubMenu
key="windowLevelPresets"
itemLabel={t('Modality Window Presets')}
itemIcon="viewport-window-level"
>
<WindowLevel
viewportId={viewportId}
commandsManager={commandsManager}
presets={presets}
/>
</AllInOneMenu.SubMenu>
)}
{volumeRenderingPresets && is3DVolume && (
<VolumeRenderingPresets
servicesManager={servicesManager}
viewportId={viewportId}
commandsManager={commandsManager}
volumeRenderingPresets={volumeRenderingPresets}
/>
)}
{volumeRenderingQualityRange && is3DVolume && (
<AllInOneMenu.SubMenu itemLabel="Rendering Options">
<VolumeRenderingOptions
viewportId={viewportId}
commandsManager={commandsManager}
volumeRenderingQualityRange={volumeRenderingQualityRange}
servicesManager={servicesManager}
/>
</AllInOneMenu.SubMenu>
)}
</AllInOneMenu.ItemPanel>
</AllInOneMenu.IconMenu>
);
}

View File

@@ -0,0 +1,23 @@
// The following are the default window level presets and can be further
// configured via the customization service.
const defaultWindowLevelPresets = {
CT: [
{ description: 'Soft tissue', window: '400', level: '40' },
{ description: 'Lung', window: '1500', level: '-600' },
{ description: 'Liver', window: '150', level: '90' },
{ description: 'Bone', window: '2500', level: '480' },
{ description: 'Brain', window: '80', level: '40' },
],
PT: [
{ description: 'Default', window: '5', level: '2.5' },
{ description: 'SUV', window: '0', level: '3' },
{ description: 'SUV', window: '0', level: '5' },
{ description: 'SUV', window: '0', level: '7' },
{ description: 'SUV', window: '0', level: '8' },
{ description: 'SUV', window: '0', level: '10' },
{ description: 'SUV', window: '0', level: '15' },
],
};
export default defaultWindowLevelPresets;

View File

@@ -0,0 +1,54 @@
import React, { ReactNode } from 'react';
import { nonWLModalities, WindowLevelActionMenu } from './WindowLevelActionMenu';
export function getWindowLevelActionMenu({
viewportId,
element,
displaySets,
servicesManager,
commandsManager,
verticalDirection,
horizontalDirection,
}: withAppTypes<{
viewportId: string;
element: HTMLElement;
displaySets: AppTypes.DisplaySet[];
}>): ReactNode {
const { customizationService } = servicesManager.services;
const { presets } = customizationService.get('cornerstone.windowLevelPresets');
const colorbarProperties = customizationService.get('cornerstone.colorbar');
const { volumeRenderingPresets, volumeRenderingQualityRange } = customizationService.get(
'cornerstone.3dVolumeRendering'
);
const displaySetPresets = displaySets
.filter(displaySet => presets[displaySet.Modality])
.map(displaySet => {
return { [displaySet.Modality]: presets[displaySet.Modality] };
});
const modalities = displaySets
.map(displaySet => displaySet.Modality)
.filter(modality => !nonWLModalities.includes(modality));
if (modalities.length === 0) {
return null;
}
return (
<WindowLevelActionMenu
viewportId={viewportId}
element={element}
presets={displaySetPresets}
verticalDirection={verticalDirection}
horizontalDirection={horizontalDirection}
commandsManager={commandsManager}
servicesManager={servicesManager}
colorbarProperties={colorbarProperties}
displaySets={displaySets}
volumeRenderingPresets={volumeRenderingPresets}
volumeRenderingQualityRange={volumeRenderingQualityRange}
/>
);
}

View File

@@ -0,0 +1,180 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
} from 'react';
import PropTypes from 'prop-types';
import { Types, ViewportActionCornersLocations } from '@ohif/ui';
import ViewportActionCornersService, {
ActionComponentInfo,
} from '../services/ViewportActionCornersService/ViewportActionCornersService';
interface StateComponentInfo extends Types.ViewportActionCornersComponentInfo {
indexPriority: number;
}
type State = Record<string, Record<ViewportActionCornersLocations, Array<StateComponentInfo>>>;
const DEFAULT_STATE: State = {
// default here is the viewportId of the default viewport
default: {
[ViewportActionCornersLocations.topLeft]: [],
[ViewportActionCornersLocations.topRight]: [],
[ViewportActionCornersLocations.bottomLeft]: [],
[ViewportActionCornersLocations.bottomRight]: [],
},
// [anotherViewportId]: { ..... }
};
export const ViewportActionCornersContext = createContext(DEFAULT_STATE);
export function ViewportActionCornersProvider({ children, service }) {
const viewportActionCornersReducer = (state, action) => {
switch (action.type) {
case 'ADD_ACTION_COMPONENT': {
const { viewportId, id, component, location, indexPriority } = action.payload;
// Get the components at the specified location of the specified viewport.
let locationComponents = state?.[viewportId]?.[location]
? [...state[viewportId][location]]
: [];
// If the component (id) already exists at the location specified in the payload,
// then it must be replaced with the component in the payload so first
// remove it from that location.
const deletionIndex = locationComponents.findIndex(component => component.id === id);
if (deletionIndex !== -1) {
locationComponents = [
...locationComponents.slice(0, deletionIndex),
...locationComponents.slice(deletionIndex + 1),
];
}
// Insert the component from the payload but
// do not insert an undefined or null component.
if (component) {
let insertionIndex;
const isRightSide =
location === ViewportActionCornersLocations.topRight ||
location === ViewportActionCornersLocations.bottomRight;
if (indexPriority === undefined) {
// If no indexPriority is provided, add it to the appropriate end
insertionIndex = isRightSide ? 0 : locationComponents.length;
} else {
if (isRightSide) {
insertionIndex = locationComponents.findIndex(
component => indexPriority > component.indexPriority
);
} else {
insertionIndex = locationComponents.findIndex(
component => indexPriority <= component.indexPriority
);
}
if (insertionIndex === -1) {
// If no suitable position found, add to the appropriate end
insertionIndex = isRightSide ? 0 : locationComponents.length;
}
}
const defaultPriority = isRightSide ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
locationComponents = [
...locationComponents.slice(0, insertionIndex),
{
id,
component,
indexPriority: indexPriority ?? defaultPriority,
},
...locationComponents.slice(insertionIndex),
];
}
return {
...state,
[viewportId]: {
...state[viewportId],
[location]: locationComponents,
},
};
}
case 'CLEAR_ACTION_COMPONENTS': {
const viewportId = action.payload;
const nextState = { ...state };
delete nextState[viewportId];
return nextState;
}
default:
return { ...state };
}
};
const [viewportActionCornersState, dispatch] = useReducer(
viewportActionCornersReducer,
DEFAULT_STATE
);
const getState = useCallback(() => {
return viewportActionCornersState;
}, [viewportActionCornersState]);
const addComponent = useCallback(
(actionComponentInfo: ActionComponentInfo) => {
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo });
},
[dispatch]
);
const addComponents = useCallback(
(actionComponentInfos: Array<ActionComponentInfo>) => {
actionComponentInfos.forEach(actionComponentInfo =>
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo })
);
},
[dispatch]
);
const clear = useCallback(
(viewportId: string) => dispatch({ type: 'CLEAR_ACTION_COMPONENTS', payload: viewportId }),
[dispatch]
);
useEffect(() => {
if (service) {
service.setServiceImplementation({
getState,
addComponent,
addComponents,
clear,
});
}
}, [getState, service, addComponent, addComponents, clear]);
const viewportCornerActions = {
getState,
addComponent: props => service.addComponent(props),
addComponents: props => service.addComponents(props),
clear: props => service.clear(props),
};
const contextValue = useMemo(
() => [viewportActionCornersState, viewportCornerActions],
[viewportActionCornersState, viewportCornerActions]
);
return (
<ViewportActionCornersContext.Provider value={contextValue}>
{children}
</ViewportActionCornersContext.Provider>
);
}
ViewportActionCornersProvider.propTypes = {
children: PropTypes.node,
service: PropTypes.instanceOf(ViewportActionCornersService).isRequired,
};
export const useViewportActionCornersContext = () => useContext(ViewportActionCornersContext);

View File

@@ -0,0 +1,9 @@
export const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
export const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
const Enums = {
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION,
};
export default Enums;

View File

@@ -0,0 +1,213 @@
import { Enums } from '@cornerstonejs/tools';
import { toolNames } from './initCornerstoneTools';
import defaultWindowLevelPresets from './components/WindowLevelActionMenu/defaultWindowLevelPresets';
import { colormaps } from './utils/colormaps';
import { CONSTANTS } from '@cornerstonejs/core';
import { CornerstoneOverlay } from './Viewport/Overlays/CustomizableViewportOverlay';
const DefaultColormap = 'Grayscale';
const { VIEWPORT_PRESETS } = CONSTANTS;
const tools = {
active: [
{
toolName: toolNames.WindowLevel,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{
toolName: toolNames.StackScroll,
bindings: [{ mouseButton: Enums.MouseBindings.Wheel }],
},
],
enabled: [
{
toolName: toolNames.PlanarFreehandContourSegmentation,
configuration: {
displayOnePointAsCrosshairs: true,
},
},
],
};
function getCustomizationModule() {
return [
{
name: 'default',
value: [
CornerstoneOverlay,
{
id: 'cornerstone.overlayViewportTools',
tools,
},
{
id: 'cornerstone.windowLevelPresets',
presets: defaultWindowLevelPresets,
},
{
id: 'cornerstone.colorbar',
width: '16px',
colorbarTickPosition: 'left',
colormaps,
colorbarContainerPosition: 'right',
colorbarInitialColormap: DefaultColormap,
},
{
id: 'cornerstone.3dVolumeRendering',
volumeRenderingPresets: VIEWPORT_PRESETS,
volumeRenderingQualityRange: {
min: 1,
max: 4,
step: 1,
},
},
{
id: 'cornerstone.measurements',
Angle: {
displayText: [],
report: [],
},
CobbAngle: {
displayText: [],
report: [],
},
ArrowAnnotate: {
displayText: [],
report: [],
},
RectangleROi: {
displayText: [],
report: [],
},
CircleROI: {
displayText: [],
report: [],
},
EllipticalROI: {
displayText: [],
report: [],
},
Bidirectional: {
displayText: [],
report: [],
},
Length: {
displayText: [],
report: [],
},
LivewireContour: {
displayText: [],
report: [],
},
SplineROI: {
displayText: [
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
/**
{
displayName: 'Modality',
value: 'Modality',
type: 'value',
},
*/
],
report: [
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'areaUnits',
type: 'value',
},
],
},
PlanarFreehandROI: {
displayTextOpen: [
{
displayName: 'Length',
value: 'length',
type: 'value',
},
],
displayText: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
value: 'pixelValueUnits',
for: ['mean', 'max' /** 'stdDev **/],
type: 'unit',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
/**
{
displayName: 'Std Dev',
value: 'stdDev',
type: 'value',
},
*/
],
report: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'unit',
type: 'value',
},
],
},
},
],
},
];
}
export default getCustomizationModule;

View File

@@ -0,0 +1,47 @@
import { fourUp } from './hps/fourUp';
import { main3D } from './hps/main3D';
import { mpr } from './hps/mpr';
import { mprAnd3DVolumeViewport } from './hps/mprAnd3DVolumeViewport';
import { only3D } from './hps/only3D';
import { primary3D } from './hps/primary3D';
import { primaryAxial } from './hps/primaryAxial';
import { frameView } from './hps/frameView';
function getHangingProtocolModule() {
return [
{
name: mpr.id,
protocol: mpr,
},
{
name: mprAnd3DVolumeViewport.id,
protocol: mprAnd3DVolumeViewport,
},
{
name: fourUp.id,
protocol: fourUp,
},
{
name: main3D.id,
protocol: main3D,
},
{
name: primaryAxial.id,
protocol: primaryAxial,
},
{
name: only3D.id,
protocol: only3D,
},
{
name: primary3D.id,
protocol: primary3D,
},
{
name: frameView.id,
protocol: frameView,
},
];
}
export default getHangingProtocolModule;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Toolbox } from '@ohif/ui-next';
import PanelSegmentation from './panels/PanelSegmentation';
import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel';
import PanelMeasurementTable from './panels/PanelMeasurement';
const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => {
const wrappedPanelSegmentation = ({ configuration }) => {
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
);
};
const wrappedPanelSegmentationNoHeader = ({ configuration }) => {
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
);
};
const wrappedPanelSegmentationWithTools = ({ configuration }) => {
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="segmentationToolbox"
title="Segmentation Tools"
configuration={{
...configuration,
}}
/>
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
</>
);
};
const wrappedPanelMeasurement = ({ configuration }) => {
return (
<PanelMeasurementTable
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
);
};
return [
{
name: 'activeViewportWindowLevel',
component: () => {
return <ActiveViewportWindowLevel servicesManager={servicesManager} />;
},
},
{
name: 'panelMeasurement',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurement',
component: wrappedPanelMeasurement,
},
{
name: 'panelSegmentation',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentation,
},
{
name: 'panelSegmentationNoHeader',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentationNoHeader,
},
{
name: 'panelSegmentationWithTools',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentationWithTools,
},
];
};
export default getPanelModule;

View File

@@ -0,0 +1,170 @@
import OHIF from '@ohif/core';
import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core';
import dcmjs from 'dcmjs';
import { dicomWebUtils } from '@ohif/extension-default';
const { MetadataModules } = csEnums;
const { utils } = OHIF;
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
const SOP_CLASS_UIDS = {
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
};
const SOPClassHandlerId =
'@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler';
function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const instance = instances[0];
let singleFrameInstance = instance;
let currentFrames = +singleFrameInstance.NumberOfFrames || 1;
for (const instanceI of instances) {
const framesI = +instanceI.NumberOfFrames || 1;
if (framesI < currentFrames) {
singleFrameInstance = instanceI;
currentFrames = framesI;
}
}
let imageIdForThumbnail = null;
const dataSource = extensionManager.getActiveDataSource()[0];
if (singleFrameInstance) {
if (currentFrames == 1) {
// Not all DICOM server implementations support thumbnail service,
// So if we have a single-frame image, we will prefer it.
imageIdForThumbnail = singleFrameInstance.imageId;
}
if (!imageIdForThumbnail) {
// use the thumbnail service provided by DICOM server
imageIdForThumbnail = dataSource.getImageIdsForInstance({
instance: singleFrameInstance,
thumbnail: true,
});
}
}
const {
FrameOfReferenceUID,
SeriesDescription,
ContentDate,
ContentTime,
SeriesNumber,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SOPClassUID,
} = instance;
instances = instances.map(inst => {
// NOTE: According to DICOM standard a series should have a FrameOfReferenceUID
// When the Microscopy file was built by certain tool from multiple image files,
// each instance's FrameOfReferenceUID is sometimes different.
// Even though this means the file was not well formatted DICOM VL Whole Slide Microscopy Image,
// the case is so often, so let's override this value manually here.
//
// https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.7.4.html#sect_C.7.4.1.1.1
inst.FrameOfReferenceUID = instance.FrameOfReferenceUID;
return inst;
});
const othersFrameOfReferenceUID = instances
.filter(v => v)
.map(inst => inst.FrameOfReferenceUID)
.filter((value, index, array) => array.indexOf(value) === index);
if (othersFrameOfReferenceUID.length > 1) {
console.warn(
'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values',
othersFrameOfReferenceUID
);
}
const displaySet = {
plugin: 'microscopy',
Modality: 'SM',
viewportType: csEnums.ViewportType.WHOLE_SLIDE,
altImageText: 'Microscopy',
displaySetInstanceUID: utils.guid(),
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
FrameOfReferenceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription: SeriesDescription || 'Microscopy Data',
// Map ContentDate/Time to SeriesTime for series list sorting.
SeriesDate: ContentDate,
SeriesTime: ContentTime,
SeriesNumber,
firstInstance: singleFrameInstance, // top level instance in the image Pyramid
instance,
numImageFrames: 0,
numInstances: 1,
imageIdForThumbnail, // thumbnail image
others: instances, // all other level instances in the image Pyramid
instances,
othersFrameOfReferenceUID,
imageIds: instances.map(instance => instance.imageId),
};
// The microscopy viewer directly accesses the metadata already loaded, and
// uses the DICOMweb client library directly for loading, so it has to be
// provided here.
const dicomWebClient = dataSource.retrieve.getWadoDicomWebClient?.();
const instanceMap = new Map();
instances.forEach(instance => instanceMap.set(instance.imageId, instance));
if (dicomWebClient) {
const webClient = Object.create(dicomWebClient);
// This replaces just the dicom web metadata call with one which retrieves
// internally.
webClient.getDICOMwebMetadata = getDICOMwebMetadata.bind(webClient, instanceMap);
csUtils.genericMetadataProvider.addRaw(displaySet.imageIds[0], {
type: MetadataModules.WADO_WEB_CLIENT,
metadata: webClient,
});
} else {
// Might have some other way of getting the data in the future or internally?
// throw new Error('Unable to provide a DICOMWeb client library, microscopy will fail to view');
}
return [displaySet];
}
/**
* This method provides access to the internal DICOMweb metadata, used to avoid
* refetching the DICOMweb data. It gets assigned as a member function to the
* dicom web client.
*/
function getDICOMwebMetadata(instanceMap, imageId) {
const instance = instanceMap.get(imageId);
if (!instance) {
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
return this.super.getDICOMwebMetadata(imageId);
}
return transferDenaturalizedDataset(
denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
);
}
export function getDicomMicroscopySopClassHandler({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return {
name: 'DicomMicroscopySopClassHandler',
sopClassUids: [SOP_CLASS_UIDS.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE],
getDisplaySetsFromSeries,
};
}
export function getSopClassHandlerModule(params) {
return [getDicomMicroscopySopClassHandler(params)];
}

View File

@@ -0,0 +1,309 @@
import { Enums } from '@cornerstonejs/tools';
const getToggledClassName = (isToggled: boolean) => {
return isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light';
};
const getDisabledState = (disabledText?: string) => ({
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
});
export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) {
const {
toolGroupService,
toolbarService,
syncGroupService,
cornerstoneViewportService,
hangingProtocolService,
displaySetService,
viewportGridService,
} = servicesManager.services;
return [
// functions/helpers to be used by the toolbar buttons to decide if they should
// enabled or not
{
name: 'evaluate.viewport.supported',
evaluate: ({ viewportId, unsupportedViewportTypes, disabledText }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport && unsupportedViewportTypes?.includes(viewport.type)) {
return getDisabledState(disabledText);
}
return undefined;
},
},
{
name: 'evaluate.modality.supported',
evaluate: ({ viewportId, unsupportedModalities, supportedModalities, disabledText }) => {
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetUIDs?.length) {
return;
}
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
// Check for unsupported modalities (exclusion)
if (unsupportedModalities?.length) {
const hasUnsupportedModality = displaySets.some(displaySet =>
unsupportedModalities.includes(displaySet?.Modality)
);
if (hasUnsupportedModality) {
return getDisabledState(disabledText);
}
}
// Check for supported modalities (inclusion)
if (supportedModalities?.length) {
const hasAnySupportedModality = displaySets.some(displaySet =>
supportedModalities.includes(displaySet?.Modality)
);
if (!hasAnySupportedModality) {
return getDisabledState(disabledText || 'Tool not available for this modality');
}
}
},
},
{
name: 'evaluate.cornerstoneTool',
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup || (!toolGroup.hasTool(toolName) && !toolNames)) {
return getDisabledState(disabledText);
}
const isPrimaryActive = toolNames
? toolNames.includes(toolGroup.getActivePrimaryMouseButtonTool())
: toolGroup.getActivePrimaryMouseButtonTool() === toolName;
return {
disabled: false,
className: isPrimaryActive
? '!text-black bg-primary-light rounded'
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light rounded',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
},
{
name: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList',
evaluate: ({ viewportId, button, itemId }) => {
const { items } = button.props;
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return {
primary: button.props.primary,
items,
};
}
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
// check if the active toolName is part of the items then we need
// to move it to the primary button
const activeToolIndex = items.findIndex(item => {
const toolName = toolbarService.getToolNameForButton(item);
return toolName === activeToolName;
});
// if there is an active tool in the items dropdown bound to the primary mouse/touch
// we should show that no matter what
if (activeToolIndex > -1) {
return {
primary: items[activeToolIndex],
items,
};
}
if (!itemId) {
return {
primary: button.props.primary,
items,
};
}
// other wise we can move the clicked tool to the primary button
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
return {
primary: clickedItemProps,
items,
};
},
},
{
name: 'evaluate.action',
evaluate: ({ viewportId, button }) => {
return {
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
},
},
{
name: 'evaluate.cornerstoneTool.toggle.ifStrictlyDisabled',
evaluate: ({ viewportId, button, disabledText }) =>
_evaluateToggle({
viewportId,
button,
toolbarService,
disabledText,
offModes: [Enums.ToolModes.Disabled],
toolGroupService,
}),
},
{
name: 'evaluate.cornerstoneTool.toggle',
evaluate: ({ viewportId, button, disabledText }) =>
_evaluateToggle({
viewportId,
button,
toolbarService,
disabledText,
offModes: [Enums.ToolModes.Disabled, Enums.ToolModes.Passive],
toolGroupService,
}),
},
{
name: 'evaluate.cornerstone.synchronizer',
evaluate: ({ viewportId, button }) => {
let synchronizers = syncGroupService.getSynchronizersForViewport(viewportId);
if (!synchronizers?.length) {
return {
className: getToggledClassName(false),
};
}
const isArray = Array.isArray(button.commands);
const synchronizerType = isArray
? button.commands?.[0].commandOptions.type
: button.commands?.commandOptions.type;
synchronizers = syncGroupService.getSynchronizersOfType(synchronizerType);
if (!synchronizers?.length) {
return {
className: getToggledClassName(false),
};
}
// Todo: we need a better way to find the synchronizers based on their
// type, but for now we just check the first one and see if it is
// enabled
const synchronizer = synchronizers[0];
const isEnabled = synchronizer?._enabled;
return {
className: getToggledClassName(isEnabled),
};
},
},
{
name: 'evaluate.viewportProperties.toggle',
evaluate: ({ viewportId, button }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport.isDisabled) {
return;
}
const propId = button.id;
const properties = viewport.getProperties();
const camera = viewport.getCamera();
const prop = camera?.[propId] || properties?.[propId];
if (!prop) {
return {
disabled: false,
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
}
const isToggled = prop;
return {
className: getToggledClassName(isToggled),
};
},
},
{
name: 'evaluate.mpr',
evaluate: ({ viewportId, disabledText = 'Selected viewport is not reconstructable' }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetUIDs?.length) {
return;
}
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
const areReconstructable = displaySets.every(displaySet => {
return displaySet?.isReconstructable;
});
if (!areReconstructable) {
return getDisabledState(disabledText);
}
const isMpr = protocol?.id === 'mpr';
return {
disabled: false,
className: getToggledClassName(isMpr),
};
},
},
];
}
function _evaluateToggle({
viewportId,
toolbarService,
button,
disabledText,
offModes,
toolGroupService,
}) {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup.hasTool(toolName)) {
return getDisabledState(disabledText);
}
const isOff = offModes.includes(toolGroup.getToolOptions(toolName).mode);
return {
className: getToggledClassName(!isOff),
};
}

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import debounce from 'lodash.debounce';
import { roundNumber } from '@ohif/core/src/utils';
import {
SegmentationData,
SegmentationRepresentation,
} from '../services/SegmentationService/SegmentationService';
const excludedModalities = ['SM', 'OT', 'DOC', 'ECG'];
function mapSegmentationToDisplay(segmentation, customizationService) {
const { label, segments } = segmentation;
// Get the readable text mapping once
const { readableText: readableTextMap } = customizationService.getCustomization(
'PanelSegmentation.readableText',
{}
);
// Helper function to recursively map cachedStats to readable display text
function mapStatsToDisplay(stats, indent = 0) {
const primary = [];
const indentation = ' '.repeat(indent);
for (const key in stats) {
if (Object.prototype.hasOwnProperty.call(stats, key)) {
const value = stats[key];
const readableText = readableTextMap?.[key];
if (!readableText) {
continue;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Add empty row before category (except for the first category)
if (primary.length > 0) {
primary.push('');
}
// Add category title
primary.push(`${indentation}${readableText}`);
// Recursively handle nested objects
primary.push(...mapStatsToDisplay(value, indent + 1));
} else {
// For non-nested values, don't add empty rows
primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`);
}
}
}
return primary;
}
// Get customization for display text mapping
const displayTextMapper = segment => {
const defaultDisplay = {
primary: [],
secondary: [],
};
// If the segment has cachedStats, map it to readable text
if (segment.cachedStats) {
const primary = mapStatsToDisplay(segment.cachedStats);
defaultDisplay.primary = primary;
}
return defaultDisplay;
};
const updatedSegments = {};
Object.entries(segments).forEach(([segmentIndex, segment]) => {
updatedSegments[segmentIndex] = {
...segment,
displayText: displayTextMapper(segment),
};
});
// Map the segments and apply the display text mapper
return {
...segmentation,
label,
segments: updatedSegments,
};
}
/**
* Represents the combination of segmentation data and its representation in a viewport.
*/
type ViewportSegmentationRepresentation = {
segmentationsWithRepresentations: {
representation: SegmentationRepresentation;
segmentation: SegmentationData;
}[];
disabled: boolean;
};
/**
* Custom hook that provides segmentation data and their representations for the active viewport.
* @param options - The options object.
* @param options.servicesManager - The services manager object.
* @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications.
* @param options.debounceTime - Debounce time in milliseconds for updates.
* @returns An array of segmentation data and their representations for the active viewport.
*/
export function useActiveViewportSegmentationRepresentations({
servicesManager,
subscribeToDataModified = false,
debounceTime = 0,
}: withAppTypes<{ debounceTime?: number }>): ViewportSegmentationRepresentation {
const { segmentationService, viewportGridService, customizationService, displaySetService } =
servicesManager.services;
const [segmentationsWithRepresentations, setSegmentationsWithRepresentations] =
useState<ViewportSegmentationRepresentation>({
segmentationsWithRepresentations: [],
disabled: false,
});
useEffect(() => {
const update = () => {
const viewportId = viewportGridService.getActiveViewportId();
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetUIDs?.length) {
return;
}
const displaySet = displaySetService.getDisplaySetByUID(displaySetUIDs[0]);
if (!displaySet) {
return;
}
if (excludedModalities.includes(displaySet.Modality)) {
setSegmentationsWithRepresentations(prev => ({
segmentationsWithRepresentations: [],
disabled: true,
}));
return;
}
const segmentations = segmentationService.getSegmentations();
if (!segmentations?.length) {
setSegmentationsWithRepresentations(prev => ({
segmentationsWithRepresentations: [],
disabled: false,
}));
return;
}
const representations = segmentationService.getSegmentationRepresentations(viewportId);
const newSegmentationsWithRepresentations = representations.map(representation => {
const segmentation = segmentationService.getSegmentation(representation.segmentationId);
const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService);
return {
representation,
segmentation: mappedSegmentation,
};
});
setSegmentationsWithRepresentations({
segmentationsWithRepresentations: newSegmentationsWithRepresentations,
disabled: false,
});
};
const debouncedUpdate =
debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update;
update();
const subscriptions = [
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
debouncedUpdate
),
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
debouncedUpdate
),
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
debouncedUpdate
),
viewportGridService.subscribe(
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
debouncedUpdate
),
viewportGridService.subscribe(viewportGridService.EVENTS.GRID_STATE_CHANGED, debouncedUpdate),
];
if (subscribeToDataModified) {
subscriptions.push(
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
debouncedUpdate
)
);
}
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
if (debounceTime > 0) {
debouncedUpdate.cancel();
}
};
}, [
segmentationService,
viewportGridService,
customizationService,
displaySetService,
debounceTime,
subscribeToDataModified,
]);
return segmentationsWithRepresentations;
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import debounce from 'lodash.debounce';
function mapMeasurementToDisplay(measurement, displaySetService) {
const { referenceSeriesUID } = measurement;
const displaySets = displaySetService.getDisplaySetsForSeries(referenceSeriesUID);
if (!displaySets[0]?.instances) {
throw new Error('The tracked measurements panel should only be tracking "stack" displaySets.');
}
const { findingSites, finding, label: baseLabel, displayText: baseDisplayText } = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
// Initialize displayText with the structure used in Length.ts and CobbAngle.ts
const displayText = {
primary: [],
secondary: baseDisplayText?.secondary || [],
};
// Add baseDisplayText to primary if it exists
if (baseDisplayText) {
displayText.primary.push(...baseDisplayText.primary);
}
// Add finding sites to primary
if (findingSites) {
findingSites.forEach(site => {
if (site?.text && site.text !== label) {
displayText.primary.push(site.text);
}
});
}
// Add finding to primary if it's different from the label
if (finding && finding.text && finding.text !== label) {
displayText.primary.push(finding.text);
}
return {
...measurement,
displayText,
label,
};
}
/**
* A custom hook that provides mapped measurements based on the given services and filters.
*
* @param {Object} servicesManager - The services manager object.
* @param {Object} options - The options for filtering and mapping measurements.
* @param {Function} options.measurementFilter - Optional function to filter measurements.
* @param {Object} options.valueTypes - The value types for mapping measurements.
* @returns {Array} An array of mapped and filtered measurements.
*/
export function useMeasurements(servicesManager, { measurementFilter }) {
const { measurementService, displaySetService } = servicesManager.services;
const [displayMeasurements, setDisplayMeasurements] = useState([]);
useEffect(() => {
const updateDisplayMeasurements = () => {
let measurements = measurementService.getMeasurements();
if (measurementFilter) {
measurements = measurements.filter(measurementFilter);
}
const mappedMeasurements = measurements.map(m =>
mapMeasurementToDisplay(m, displaySetService)
);
setDisplayMeasurements(prevMeasurements => {
if (JSON.stringify(prevMeasurements) !== JSON.stringify(mappedMeasurements)) {
return mappedMeasurements;
}
return prevMeasurements;
});
};
const debouncedUpdate = debounce(updateDisplayMeasurements, 100);
updateDisplayMeasurements();
const events = [
measurementService.EVENTS.MEASUREMENT_ADDED,
measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
measurementService.EVENTS.MEASUREMENT_UPDATED,
measurementService.EVENTS.MEASUREMENT_REMOVED,
measurementService.EVENTS.MEASUREMENTS_CLEARED,
];
const subscriptions = events.map(
evt => measurementService.subscribe(evt, debouncedUpdate).unsubscribe
);
return () => {
subscriptions.forEach(unsub => unsub());
debouncedUpdate.cancel();
};
}, [measurementService, measurementFilter, displaySetService]);
return displayMeasurements;
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import debounce from 'lodash.debounce';
import { roundNumber } from '@ohif/core/src/utils';
import { SegmentationData } from '../services/SegmentationService/SegmentationService';
function mapSegmentationToDisplay(segmentation, customizationService) {
const { label, segments } = segmentation;
// Get the readable text mapping once
const { readableText: readableTextMap } = customizationService.getCustomization(
'PanelSegmentation.readableText',
{}
);
// Helper function to recursively map cachedStats to readable display text
function mapStatsToDisplay(stats, indent = 0) {
const primary = [];
const indentation = ' '.repeat(indent);
for (const key in stats) {
if (Object.prototype.hasOwnProperty.call(stats, key)) {
const value = stats[key];
const readableText = readableTextMap?.[key];
if (!readableText) {
continue;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Add empty row before category (except for the first category)
if (primary.length > 0) {
primary.push('');
}
// Add category title
primary.push(`${indentation}${readableText}`);
// Recursively handle nested objects
primary.push(...mapStatsToDisplay(value, indent + 1));
} else {
// For non-nested values, don't add empty rows
primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`);
}
}
}
return primary;
}
// Get customization for display text mapping
const displayTextMapper = segment => {
const defaultDisplay = {
primary: [],
secondary: [],
};
// If the segment has cachedStats, map it to readable text
if (segment.cachedStats) {
const primary = mapStatsToDisplay(segment.cachedStats);
defaultDisplay.primary = primary;
}
return defaultDisplay;
};
const updatedSegments = {};
Object.entries(segments).forEach(([segmentIndex, segment]) => {
updatedSegments[segmentIndex] = {
...segment,
displayText: displayTextMapper(segment),
};
});
// Map the segments and apply the display text mapper
return {
...segmentation,
label,
segments: updatedSegments,
};
}
/**
* Custom hook that provides segmentation data.
* @param options - The options object.
* @param options.servicesManager - The services manager object.
* @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications.
* @param options.debounceTime - Debounce time in milliseconds for updates.
* @returns An array of segmentation data.
*/
export function useSegmentations({
servicesManager,
subscribeToDataModified = false,
debounceTime = 0,
}: withAppTypes<{ debounceTime?: number }>): SegmentationData[] {
const { segmentationService, customizationService } = servicesManager.services;
const [segmentations, setSegmentations] = useState<SegmentationData[]>([]);
useEffect(() => {
const update = () => {
const segmentations = segmentationService.getSegmentations();
if (!segmentations?.length) {
setSegmentations([]);
return;
}
const mappedSegmentations = segmentations.map(segmentation =>
mapSegmentationToDisplay(segmentation, customizationService)
);
setSegmentations(mappedSegmentations);
};
const debouncedUpdate =
debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update;
update();
const subscriptions = [
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
debouncedUpdate
),
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
debouncedUpdate
),
];
if (subscribeToDataModified) {
subscriptions.push(
segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
debouncedUpdate
)
);
}
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
if (debounceTime > 0) {
debouncedUpdate.cancel();
}
};
}, [segmentationService, customizationService, debounceTime, subscribeToDataModified]);
return segmentations;
}

View File

@@ -0,0 +1,144 @@
export const fourUp = {
id: 'fourUp',
locked: true,
name: '3D four up',
icon: 'layout-advanced-3d-four-up',
isPreset: true,
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'fourUpStage',
name: 'fourUp',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
displayPreset: {
CT: 'CT-Bone',
MR: 'MR-Default',
default: 'CT-Bone',
},
},
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
export const main3D = {
id: 'main3D',
locked: true,
name: '3D main',
icon: 'layout-advanced-3d-main',
isPreset: true,
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'main3DStage',
name: 'main3D',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 1,
height: 1 / 2,
},
{
x: 0,
y: 1 / 2,
width: 1 / 3,
height: 1 / 2,
},
{
x: 1 / 3,
y: 1 / 2,
width: 1 / 3,
height: 1 / 2,
},
{
x: 2 / 3,
y: 1 / 2,
width: 1 / 3,
height: 1 / 2,
},
],
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
displayPreset: {
CT: 'CT-Bone',
MR: 'MR-Default',
default: 'CT-Bone',
},
},
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,138 @@
import { Types } from '@ohif/core';
const VOI_SYNC_GROUP = {
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
};
const HYDRATE_SEG_SYNC_GROUP = {
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
};
export const mpr: Types.HangingProtocol.Protocol = {
id: 'mpr',
name: 'MPR',
locked: true,
icon: 'layout-advanced-mpr',
isPreset: true,
createdDate: '2021-02-23',
modifiedDate: '2023-08-15',
availableTo: {},
editableBy: {},
numberOfPriorsReferenced: 0,
protocolMatchingRules: [],
imageLoadStrategy: 'nth',
callbacks: {},
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
name: 'MPR 1x3',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 3,
height: 1,
},
{
x: 1 / 3,
y: 0,
width: 1 / 3,
height: 1,
},
{
x: 2 / 3,
y: 0,
width: 1 / 3,
height: 1,
},
],
},
},
viewports: [
{
viewportOptions: {
viewportId: 'mpr-axial',
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'mpr-sagittal',
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'mpr-coronal',
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,151 @@
export const mprAnd3DVolumeViewport = {
id: 'mprAnd3DVolumeViewport',
locked: true,
name: 'mpr',
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'Modality',
constraint: {
equals: {
value: 'CT',
},
},
required: true,
},
],
},
},
stages: [
{
id: 'mpr3Stage',
name: 'mpr',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
displayPreset: {
CT: 'CT-Bone',
MR: 'MR-Default',
default: 'CT-Bone',
},
},
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,66 @@
export const only3D = {
id: 'only3D',
locked: true,
name: '3D only',
icon: 'layout-advanced-3d-only',
isPreset: true,
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'only3DStage',
name: 'only3D',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
displayPreset: {
CT: 'CT-Bone',
MR: 'MR-Default',
default: 'CT-Bone',
},
},
},
],
},
],
},
],
};

View File

@@ -0,0 +1,170 @@
export const primary3D = {
id: 'primary3D',
locked: true,
name: '3D primary',
icon: 'layout-advanced-3d-primary',
isPreset: true,
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'primary3DStage',
name: 'primary3D',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 2 / 3,
height: 1,
},
{
x: 2 / 3,
y: 0,
width: 1 / 3,
height: 1 / 3,
},
{
x: 2 / 3,
y: 1 / 3,
width: 1 / 3,
height: 1 / 3,
},
{
x: 2 / 3,
y: 2 / 3,
width: 1 / 3,
height: 1 / 3,
},
],
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
displayPreset: {
CT: 'CT-Bone',
MR: 'MR-Default',
default: 'CT-Bone',
},
},
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,142 @@
export const primaryAxial = {
id: 'primaryAxial',
locked: true,
name: 'Axial Primary',
icon: 'layout-advanced-axial-primary',
isPreset: true,
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'primaryAxialStage',
name: 'primaryAxial',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 2 / 3,
height: 1,
},
{
x: 2 / 3,
y: 0,
width: 1 / 3,
height: 1 / 2,
},
{
x: 2 / 3,
y: 1 / 2,
width: 1 / 3,
height: 1 / 2,
},
],
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
options: {
syncColormap: true,
},
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};

View File

@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View File

@@ -0,0 +1,258 @@
import React from 'react';
import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
import {
Enums as cs3DEnums,
imageLoadPoolManager,
imageRetrievalPoolManager,
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import Enums from './enums';
import init from './init';
import getCustomizationModule from './getCustomizationModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getToolbarModule from './getToolbarModule';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
import SegmentationService from './services/SegmentationService';
import CornerstoneCacheService from './services/CornerstoneCacheService';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
import ColorbarService from './services/ColorbarService';
import * as CornerstoneExtensionTypes from './types';
import { toolNames } from './initCornerstoneTools';
import { getEnabledElement, reset as enabledElementReset, setEnabledElement } from './state';
import dicomLoaderService from './utils/dicomLoaderService';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import { id } from './id';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
import { ViewportActionCornersProvider } from './contextProviders/ViewportActionCornersProvider';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
import { findNearbyToolData } from './utils/findNearbyToolData';
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
import { getSopClassHandlerModule } from './getSopClassHandlerModule';
import { getDynamicVolumeInfo } from '@cornerstonejs/core/utilities';
import {
useLutPresentationStore,
usePositionPresentationStore,
useSegmentationPresentationStore,
useSynchronizersStore,
} from './stores';
import { useToggleOneUpViewportGridStore } from '../../default/src/stores/useToggleOneUpViewportGridStore';
import { useActiveViewportSegmentationRepresentations } from './hooks/useActiveViewportSegmentationRepresentations';
import { useMeasurements } from './hooks/useMeasurements';
import getPanelModule from './getPanelModule';
import PanelSegmentation from './panels/PanelSegmentation';
import PanelMeasurement from './panels/PanelMeasurement';
import DicomUpload from './components/DicomUpload/DicomUpload';
import { useSegmentations } from './hooks/useSegmentations';
const { imageRetrieveMetadataProvider } = cornerstone.utilities;
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './Viewport/OHIFCornerstoneViewport');
});
const OHIFCornerstoneViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
const stackRetrieveOptions = {
retrieveOptions: {
single: {
streaming: true,
decodeLevel: 1,
},
},
};
/**
*
*/
const cornerstoneExtension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
onModeEnter: ({ servicesManager }: withAppTypes): void => {
const { cornerstoneViewportService, toolbarService, segmentationService } =
servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cornerstoneViewportService, [
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
]);
toolbarService.registerEventForToolbarUpdate(segmentationService, [
segmentationService.EVENTS.SEGMENTATION_REMOVED,
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
]);
toolbarService.registerEventForToolbarUpdate(cornerstone.eventTarget, [
cornerstoneTools.Enums.Events.TOOL_ACTIVATED,
]);
// Configure the interleaved/HTJ2K loader
imageRetrieveMetadataProvider.clear();
// The default volume interleaved options are to interleave the
// image retrieve, but don't perform progressive loading per image
// This interleaves images and replicates them for low-resolution depth volume
// reconstruction, which progressively improves
imageRetrieveMetadataProvider.add(
'volume',
cornerstone.ProgressiveRetrieveImages.interleavedRetrieveStages
);
// The default stack loading option is to progressive load HTJ2K images
// There are other possible options, but these need more thought about
// how to define them.
imageRetrieveMetadataProvider.add('stack', stackRetrieveOptions);
},
getPanelModule,
onModeExit: ({ servicesManager }: withAppTypes): void => {
const { cineService, segmentationService } = servicesManager.services;
// Empty out the image load and retrieval pools to prevent memory leaks
// on the mode exits
Object.values(cs3DEnums.RequestType).forEach(type => {
imageLoadPoolManager.clearRequestStack(type);
imageRetrievalPoolManager.clearRequestStack(type);
});
cineService.setIsCineEnabled(false);
enabledElementReset();
useLutPresentationStore.getState().clearLutPresentationStore();
usePositionPresentationStore.getState().clearPositionPresentationStore();
useSynchronizersStore.getState().clearSynchronizersStore();
useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore();
useSegmentationPresentationStore.getState().clearSegmentationPresentationStore();
segmentationService.removeAllSegmentations();
},
/**
* Register the Cornerstone 3D services and set them up for use.
*
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
*/
preRegistration: function (props: Types.Extensions.ExtensionParams): Promise<void> {
const { servicesManager, serviceProvidersManager } = props;
servicesManager.registerService(CornerstoneViewportService.REGISTRATION);
servicesManager.registerService(ToolGroupService.REGISTRATION);
servicesManager.registerService(SyncGroupService.REGISTRATION);
servicesManager.registerService(SegmentationService.REGISTRATION);
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
servicesManager.registerService(ViewportActionCornersService.REGISTRATION);
servicesManager.registerService(ColorbarService.REGISTRATION);
serviceProvidersManager.registerProvider(
ViewportActionCornersService.REGISTRATION.name,
ViewportActionCornersProvider
);
const { syncGroupService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
servicesManager.services.customizationService.setGlobalCustomization('dicomUploadComponent', {
component: props => <DicomUpload {...props} />,
});
return init.call(this, props);
},
getToolbarModule,
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
// const onNewImageHandler = jumpData => {
// commandsManager.runCommand('jumpToImage', jumpData);
// };
const { toolbarService } = servicesManager.services;
return (
<OHIFCornerstoneViewport
{...props}
toolbarService={toolbarService}
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
);
};
return [
{
name: 'cornerstone',
component: ExtendedOHIFCornerstoneViewport,
},
];
},
getCommandsModule,
getCustomizationModule,
getUtilityModule({ servicesManager }) {
return [
{
name: 'common',
exports: {
getCornerstoneLibraries: () => {
return { cornerstone, cornerstoneTools };
},
getEnabledElement,
dicomLoaderService,
},
},
{
name: 'core',
exports: {
Enums: cs3DEnums,
},
},
{
name: 'tools',
exports: {
toolNames,
Enums: cs3DToolsEnums,
},
},
{
name: 'volumeLoader',
exports: {
getDynamicVolumeInfo,
},
},
];
},
getSopClassHandlerModule,
};
export type { PublicViewportOptions };
export {
measurementMappingUtils,
CornerstoneExtensionTypes as Types,
toolNames,
getActiveViewportEnabledElement,
setEnabledElement,
findNearbyToolData,
getEnabledElement,
ImageOverlayViewerTool,
getSOPInstanceAttributes,
dicomLoaderService,
// Export all stores
useLutPresentationStore,
usePositionPresentationStore,
useSegmentationPresentationStore,
useSynchronizersStore,
Enums,
useMeasurements,
useActiveViewportSegmentationRepresentations,
useSegmentations,
PanelSegmentation,
PanelMeasurement,
DicomUpload,
};
export default cornerstoneExtension;

View File

@@ -0,0 +1,333 @@
import OHIF, { errorHandler } from '@ohif/core';
import React from 'react';
import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
import {
init as cs3DInit,
eventTarget,
EVENTS,
metaData,
volumeLoader,
imageLoadPoolManager,
getEnabledElement,
Settings,
utilities as csUtilities,
} from '@cornerstonejs/core';
import {
cornerstoneStreamingImageVolumeLoader,
cornerstoneStreamingDynamicImageVolumeLoader,
} from '@cornerstonejs/core/loaders';
import RequestTypes from '@cornerstonejs/core/enums/RequestType';
import initWADOImageLoader from './initWADOImageLoader';
import initCornerstoneTools from './initCornerstoneTools';
import { connectToolsToMeasurementService } from './initMeasurementService';
import initCineService from './initCineService';
import initStudyPrefetcherService from './initStudyPrefetcherService';
import interleaveCenterLoader from './utils/interleaveCenterLoader';
import nthLoader from './utils/nthLoader';
import interleaveTopToBottom from './utils/interleaveTopToBottom';
import initContextMenu from './initContextMenu';
import initDoubleClick from './initDoubleClick';
import initViewTiming from './utils/initViewTiming';
import { colormaps } from './utils/colormaps';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { useLutPresentationStore } from './stores/useLutPresentationStore';
import { usePositionPresentationStore } from './stores/usePositionPresentationStore';
import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore';
const { registerColormap } = csUtilities.colormap;
// TODO: Cypress tests are currently grabbing this from the window?
(window as any).cornerstone = cornerstone;
(window as any).cornerstoneTools = cornerstoneTools;
/**
*
*/
export default async function init({
servicesManager,
commandsManager,
extensionManager,
appConfig,
}: withAppTypes): Promise<void> {
// Note: this should run first before initializing the cornerstone
// DO NOT CHANGE THE ORDER
await cs3DInit({
peerImport: appConfig.peerImport,
});
// For debugging e2e tests that are failing on CI
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
cornerstone.setConfiguration({
...cornerstone.getConfiguration(),
rendering: {
...cornerstone.getConfiguration().rendering,
strictZSpacingForVolumeViewport: appConfig.strictZSpacingForVolumeViewport,
},
});
// For debugging large datasets, otherwise prefer the defaults
const { maxCacheSize } = appConfig;
if (maxCacheSize) {
cornerstone.cache.setMaxCacheSize(maxCacheSize);
}
initCornerstoneTools();
Settings.getRuntimeSettings().set('useCursors', Boolean(appConfig.useCursors));
const {
userAuthenticationService,
customizationService,
uiModalService,
uiNotificationService,
cornerstoneViewportService,
hangingProtocolService,
viewportGridService,
} = servicesManager.services;
window.services = servicesManager.services;
window.extensionManager = extensionManager;
window.commandsManager = commandsManager;
if (appConfig.showCPUFallbackMessage && cornerstone.getShouldUseCPURendering()) {
_showCPURenderingModal(uiModalService, hangingProtocolService);
}
const { getPresentationId: getLutPresentationId } = useLutPresentationStore.getState();
const { getPresentationId: getSegmentationPresentationId } =
useSegmentationPresentationStore.getState();
const { getPresentationId: getPositionPresentationId } = usePositionPresentationStore.getState();
// register presentation id providers
viewportGridService.addPresentationIdProvider(
'positionPresentationId',
getPositionPresentationId
);
viewportGridService.addPresentationIdProvider('lutPresentationId', getLutPresentationId);
viewportGridService.addPresentationIdProvider(
'segmentationPresentationId',
getSegmentationPresentationId
);
cornerstoneTools.segmentation.config.style.setStyle(
{ type: SegmentationRepresentations.Contour },
{
renderFill: false,
}
);
const metadataProvider = OHIF.classes.MetadataProvider;
volumeLoader.registerVolumeLoader(
'cornerstoneStreamingImageVolume',
cornerstoneStreamingImageVolumeLoader
);
volumeLoader.registerVolumeLoader(
'cornerstoneStreamingDynamicImageVolume',
cornerstoneStreamingDynamicImageVolumeLoader
);
hangingProtocolService.registerImageLoadStrategy('interleaveCenter', interleaveCenterLoader);
hangingProtocolService.registerImageLoadStrategy('interleaveTopToBottom', interleaveTopToBottom);
hangingProtocolService.registerImageLoadStrategy('nth', nthLoader);
// add metadata providers
metaData.addProvider(
csUtilities.calibratedPixelSpacingMetadataProvider.get.bind(
csUtilities.calibratedPixelSpacingMetadataProvider
)
); // this provider is required for Calibration tool
metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
// These are set reasonably low to allow for interleaved retrieves and slower
// connections.
imageLoadPoolManager.maxNumRequests = {
[RequestTypes.Interaction]: appConfig?.maxNumRequests?.interaction || 10,
[RequestTypes.Thumbnail]: appConfig?.maxNumRequests?.thumbnail || 5,
[RequestTypes.Prefetch]: appConfig?.maxNumRequests?.prefetch || 5,
[RequestTypes.Compute]: appConfig?.maxNumRequests?.compute || 10,
};
initWADOImageLoader(userAuthenticationService, appConfig, extensionManager);
/* Measurement Service */
this.measurementServiceSource = connectToolsToMeasurementService(servicesManager);
initCineService(servicesManager);
initStudyPrefetcherService(servicesManager);
// When a custom image load is performed, update the relevant viewports
hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
volumeInputArrayMap => {
const { lutPresentationStore } = useLutPresentationStore.getState();
const { segmentationPresentationStore } = useSegmentationPresentationStore.getState();
const { positionPresentationStore } = usePositionPresentationStore.getState();
for (const entry of volumeInputArrayMap.entries()) {
const [viewportId, volumeInputArray] = entry;
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
const { presentationIds } = ohifViewport.getViewportOptions();
const presentations = {
positionPresentation: positionPresentationStore[presentationIds?.positionPresentationId],
lutPresentation: lutPresentationStore[presentationIds?.lutPresentationId],
segmentationPresentation:
segmentationPresentationStore[presentationIds?.segmentationPresentationId],
};
cornerstoneViewportService.setVolumesForViewport(viewport, volumeInputArray, presentations);
}
}
);
// resize the cornerstone viewport service when the grid size changes
// IMPORTANT: this should happen outside of the OHIFCornerstoneViewport
// since it will trigger a rerender of each viewport and each resizing
// the offscreen canvas which would result in a performance hit, this should
// done only once per grid resize here. Doing it once here, allows us to reduce
// the refreshRage(in ms) to 10 from 50. I tried with even 1 or 5 ms it worked fine
viewportGridService.subscribe(viewportGridService.EVENTS.GRID_SIZE_CHANGED, () => {
cornerstoneViewportService.resize(true);
});
initContextMenu({
cornerstoneViewportService,
customizationService,
commandsManager,
});
initDoubleClick({
customizationService,
commandsManager,
});
/**
* Runs error handler for failed requests.
* @param event
*/
const imageLoadFailedHandler = ({ detail }) => {
const handler = errorHandler.getHTTPErrorHandler();
handler(detail.error);
};
eventTarget.addEventListener(EVENTS.IMAGE_LOAD_FAILED, imageLoadFailedHandler);
eventTarget.addEventListener(EVENTS.IMAGE_LOAD_ERROR, imageLoadFailedHandler);
function elementEnabledHandler(evt) {
const { element } = evt.detail;
element.addEventListener(EVENTS.CAMERA_RESET, evt => {
const { element } = evt.detail;
const enabledElement = getEnabledElement(element);
if (!enabledElement) {
return;
}
const { viewportId } = enabledElement;
commandsManager.runCommand('resetCrosshairs', { viewportId });
});
initViewTiming({ element });
}
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
colormaps.forEach(registerColormap);
// Event listener
eventTarget.addEventListenerDebounced(
EVENTS.ERROR_EVENT,
({ detail }) => {
uiNotificationService.show({
title: detail.type,
message: detail.message,
type: 'error',
});
},
100
);
// Call this function when initializing
initializeWebWorkerProgressHandler(servicesManager.services.uiNotificationService);
}
function initializeWebWorkerProgressHandler(uiNotificationService) {
const activeToasts = new Map();
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
const { progress, type, id } = detail;
const cacheKey = `${type}-${id}`;
if (progress === 0 && !activeToasts.has(cacheKey)) {
const progressPromise = new Promise((resolve, reject) => {
activeToasts.set(cacheKey, { resolve, reject });
});
uiNotificationService.show({
id: cacheKey,
title: `${type}`,
message: `${type}: ${progress}%`,
autoClose: false,
promise: progressPromise,
promiseMessages: {
loading: `Computing...`,
success: `Completed successfully`,
error: 'Web Worker failed',
},
});
} else {
if (progress === 100) {
const { resolve } = activeToasts.get(cacheKey);
resolve({ progress, type });
activeToasts.delete(cacheKey);
}
}
});
}
function CPUModal() {
return (
<div>
<p>
Your computer does not have enough GPU power to support the default GPU rendering mode. OHIF
has switched to CPU rendering mode. Please note that CPU rendering does not support all
features such as Volume Rendering, Multiplanar Reconstruction, and Segmentation Overlays.
</p>
</div>
);
}
function _showCPURenderingModal(uiModalService, hangingProtocolService) {
const callback = progress => {
if (progress === 100) {
uiModalService.show({
content: CPUModal,
title: 'OHIF Fell Back to CPU Rendering',
});
return true;
}
};
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
() => {
const done = callback(100);
if (done) {
unsubscribe();
}
}
);
}

View File

@@ -0,0 +1,70 @@
import { cache, Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
function _getVolumeFromViewport(viewport: Types.IBaseVolumeViewport) {
const volumeIds = viewport.getAllVolumeIds();
const volumes = volumeIds.map(id => cache.getVolume(id));
const dynamicVolume = volumes.find(volume => volume.isDynamicVolume());
return dynamicVolume ?? volumes[0];
}
/**
* Return all viewports that needs to be synchronized with the source
* viewport passed as parameter when cine is updated.
* @param servicesManager ServiceManager
* @param srcViewportIndex Source viewport index
* @returns array with viewport information.
*/
function _getSyncedViewports(servicesManager: AppTypes.ServicesManager, srcViewportId) {
const { viewportGridService, cornerstoneViewportService } = servicesManager.services;
const { viewports: viewportsStates } = viewportGridService.getState();
const srcViewportState = viewportsStates.get(srcViewportId);
if (srcViewportState?.viewportOptions?.viewportType !== 'volume') {
return [];
}
const srcViewport = cornerstoneViewportService.getCornerstoneViewport(srcViewportId);
const srcVolume = srcViewport ? _getVolumeFromViewport(srcViewport) : null;
if (!srcVolume?.isDynamicVolume()) {
return [];
}
const { volumeId: srcVolumeId } = srcVolume;
return Array.from(viewportsStates.values())
.filter(({ viewportId }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
return viewportId !== srcViewportId && viewport?.hasVolumeId(srcVolumeId);
})
.map(({ viewportId }) => ({ viewportId }));
}
function initCineService(servicesManager: AppTypes.ServicesManager) {
const { cineService } = servicesManager.services;
const getSyncedViewports = viewportId => {
return _getSyncedViewports(servicesManager, viewportId);
};
const playClip = (element, playClipOptions) => {
return utilities.cine.playClip(element, playClipOptions);
};
const stopClip = (element, stopClipOptions) => {
return utilities.cine.stopClip(element, stopClipOptions);
};
cineService.setServiceImplementation({
getSyncedViewports,
playClip,
stopClip,
});
}
export default initCineService;

View File

@@ -0,0 +1,113 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { setEnabledElement } from './state';
import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events;
const DEFAULT_CONTEXT_MENU_CLICKS = {
button1: {
commands: [
{
commandName: 'closeContextMenu',
},
],
},
button3: {
commands: [
{
commandName: 'showCornerstoneContextMenu',
commandOptions: {
requireNearbyToolData: true,
menuId: 'measurementsContextMenu',
},
},
],
},
};
/**
* Generates a name, consisting of:
* * alt when the alt key is down
* * ctrl when the cctrl key is down
* * shift when the shift key is down
* * 'button' followed by the button number (1 left, 3 right etc)
*/
function getEventName(evt) {
const button = evt.detail.event.which;
const nameArr = [];
if (evt.detail.event.altKey) {
nameArr.push('alt');
}
if (evt.detail.event.ctrlKey) {
nameArr.push('ctrl');
}
if (evt.detail.event.shiftKey) {
nameArr.push('shift');
}
nameArr.push('button');
nameArr.push(button);
return nameArr.join('');
}
function initContextMenu({
cornerstoneViewportService,
customizationService,
commandsManager,
}): void {
/*
* Run the commands associated with the given button press,
* defaults on button1 and button2
*/
const cornerstoneViewportHandleEvent = (name, evt) => {
const customizations =
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_CONTEXT_MENU_CLICKS;
const toRun = customizations[name];
if (!toRun) {
return;
}
// only find nearbyToolData if required, for the click (which closes the context menu
// we don't need to find nearbyToolData)
let nearbyToolData = null;
if (toRun.commands.some(command => command.commandOptions?.requireNearbyToolData)) {
nearbyToolData = findNearbyToolData(commandsManager, evt);
}
const options = {
nearbyToolData,
event: evt,
};
commandsManager.run(toRun, options);
};
const cornerstoneViewportHandleClick = evt => {
const name = getEventName(evt);
cornerstoneViewportHandleEvent(name, evt);
};
function elementEnabledHandler(evt) {
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) {
return;
}
// TODO check update upstream
setEnabledElement(viewportId, element);
element.addEventListener(cs3DToolsEvents.MOUSE_CLICK, cornerstoneViewportHandleClick);
}
function elementDisabledHandler(evt) {
const { element } = evt.detail;
element.removeEventListener(cs3DToolsEvents.MOUSE_CLICK, cornerstoneViewportHandleClick);
}
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
}
export default initContextMenu;

View File

@@ -0,0 +1,142 @@
import {
PanTool,
WindowLevelTool,
StackScrollTool,
VolumeRotateTool,
ZoomTool,
MIPJumpToClickTool,
LengthTool,
RectangleROITool,
RectangleROIThresholdTool,
EllipticalROITool,
CircleROITool,
BidirectionalTool,
ArrowAnnotateTool,
DragProbeTool,
ProbeTool,
AngleTool,
CobbAngleTool,
MagnifyTool,
CrosshairsTool,
RectangleScissorsTool,
SphereScissorsTool,
CircleScissorsTool,
BrushTool,
PaintFillTool,
init,
addTool,
annotation,
ReferenceLinesTool,
TrackballRotateTool,
AdvancedMagnifyTool,
UltrasoundDirectionalTool,
PlanarFreehandROITool,
PlanarFreehandContourSegmentationTool,
SplineROITool,
LivewireContourTool,
OrientationMarkerTool,
WindowLevelRegionTool,
} from '@cornerstonejs/tools';
import CalibrationLineTool from './tools/CalibrationLineTool';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
export default function initCornerstoneTools(configuration = {}) {
CrosshairsTool.isAnnotation = false;
ReferenceLinesTool.isAnnotation = false;
AdvancedMagnifyTool.isAnnotation = false;
PlanarFreehandContourSegmentationTool.isAnnotation = false;
init(configuration);
addTool(PanTool);
addTool(WindowLevelTool);
addTool(StackScrollTool);
addTool(VolumeRotateTool);
addTool(ZoomTool);
addTool(ProbeTool);
addTool(MIPJumpToClickTool);
addTool(LengthTool);
addTool(RectangleROITool);
addTool(RectangleROIThresholdTool);
addTool(EllipticalROITool);
addTool(CircleROITool);
addTool(BidirectionalTool);
addTool(ArrowAnnotateTool);
addTool(DragProbeTool);
addTool(AngleTool);
addTool(CobbAngleTool);
addTool(MagnifyTool);
addTool(CrosshairsTool);
addTool(RectangleScissorsTool);
addTool(SphereScissorsTool);
addTool(CircleScissorsTool);
addTool(BrushTool);
addTool(PaintFillTool);
addTool(ReferenceLinesTool);
addTool(CalibrationLineTool);
addTool(TrackballRotateTool);
addTool(ImageOverlayViewerTool);
addTool(AdvancedMagnifyTool);
addTool(UltrasoundDirectionalTool);
addTool(PlanarFreehandROITool);
addTool(SplineROITool);
addTool(LivewireContourTool);
addTool(OrientationMarkerTool);
addTool(WindowLevelRegionTool);
addTool(PlanarFreehandContourSegmentationTool);
// Modify annotation tools to use dashed lines on SR
const annotationStyle = {
textBoxFontSize: '15px',
lineWidth: '1.5',
};
const defaultStyles = annotation.config.style.getDefaultToolStyles();
annotation.config.style.setDefaultToolStyles({
global: {
...defaultStyles.global,
...annotationStyle,
},
});
}
const toolNames = {
Pan: PanTool.toolName,
ArrowAnnotate: ArrowAnnotateTool.toolName,
WindowLevel: WindowLevelTool.toolName,
StackScroll: StackScrollTool.toolName,
Zoom: ZoomTool.toolName,
VolumeRotate: VolumeRotateTool.toolName,
MipJumpToClick: MIPJumpToClickTool.toolName,
Length: LengthTool.toolName,
DragProbe: DragProbeTool.toolName,
Probe: ProbeTool.toolName,
RectangleROI: RectangleROITool.toolName,
RectangleROIThreshold: RectangleROIThresholdTool.toolName,
EllipticalROI: EllipticalROITool.toolName,
CircleROI: CircleROITool.toolName,
Bidirectional: BidirectionalTool.toolName,
Angle: AngleTool.toolName,
CobbAngle: CobbAngleTool.toolName,
Magnify: MagnifyTool.toolName,
Crosshairs: CrosshairsTool.toolName,
Brush: BrushTool.toolName,
PaintFill: PaintFillTool.toolName,
ReferenceLines: ReferenceLinesTool.toolName,
CalibrationLine: CalibrationLineTool.toolName,
TrackballRotateTool: TrackballRotateTool.toolName,
CircleScissors: CircleScissorsTool.toolName,
RectangleScissors: RectangleScissorsTool.toolName,
SphereScissors: SphereScissorsTool.toolName,
ImageOverlayViewer: ImageOverlayViewerTool.toolName,
AdvancedMagnify: AdvancedMagnifyTool.toolName,
UltrasoundDirectional: UltrasoundDirectionalTool.toolName,
SplineROI: SplineROITool.toolName,
LivewireContour: LivewireContourTool.toolName,
PlanarFreehandROI: PlanarFreehandROITool.toolName,
OrientationMarker: OrientationMarkerTool.toolName,
WindowLevelRegion: WindowLevelRegionTool.toolName,
PlanarFreehandContourSegmentation: PlanarFreehandContourSegmentationTool.toolName,
};
export { toolNames };

View File

@@ -0,0 +1,88 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { CommandsManager, CustomizationService, Types } from '@ohif/core';
import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events;
const DEFAULT_DOUBLE_CLICK = {
doubleClick: {
commandName: 'toggleOneUp',
commandOptions: {},
},
};
/**
* Generates a double click event name, consisting of:
* * alt when the alt key is down
* * ctrl when the cctrl key is down
* * shift when the shift key is down
* * 'doubleClick'
*/
function getDoubleClickEventName(evt: CustomEvent) {
const nameArr = [];
if (evt.detail.event.altKey) {
nameArr.push('alt');
}
if (evt.detail.event.ctrlKey) {
nameArr.push('ctrl');
}
if (evt.detail.event.shiftKey) {
nameArr.push('shift');
}
nameArr.push('doubleClick');
return nameArr.join('');
}
export type initDoubleClickArgs = {
customizationService: CustomizationService;
commandsManager: CommandsManager;
};
function initDoubleClick({ customizationService, commandsManager }: initDoubleClickArgs): void {
const cornerstoneViewportHandleDoubleClick = (evt: CustomEvent) => {
// Do not allow double click on a tool.
const nearbyToolData = findNearbyToolData(commandsManager, evt);
if (nearbyToolData) {
return;
}
const eventName = getDoubleClickEventName(evt);
// Allows for the customization of the double click on a viewport.
const customizations =
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_DOUBLE_CLICK;
const toRun = customizations[eventName];
if (!toRun) {
return;
}
commandsManager.run(toRun);
};
function elementEnabledHandler(evt: CustomEvent) {
const { element } = evt.detail;
element.addEventListener(
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
cornerstoneViewportHandleDoubleClick
);
}
function elementDisabledHandler(evt: CustomEvent) {
const { element } = evt.detail;
element.removeEventListener(
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
cornerstoneViewportHandleDoubleClick
);
}
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
}
export default initDoubleClick;

View File

@@ -0,0 +1,462 @@
import { eventTarget, Types } from '@cornerstonejs/core';
import { Enums, annotation } from '@cornerstonejs/tools';
import { DicomMetadataStore } from '@ohif/core';
import * as CSExtensionEnums from './enums';
import { toolNames } from './initCornerstoneTools';
import { onCompletedCalibrationLine } from './tools/CalibrationLineTool';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
import { triggerAnnotationRenderForViewportIds } from '@cornerstonejs/tools/utilities';
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const { removeAnnotation } = annotation.state;
const csToolsEvents = Enums.Events;
const initMeasurementService = (
measurementService,
displaySetService,
cornerstoneViewportService,
customizationService
) => {
/* Initialization */
const {
Length,
Bidirectional,
EllipticalROI,
CircleROI,
ArrowAnnotate,
Angle,
CobbAngle,
RectangleROI,
PlanarFreehandROI,
SplineROI,
LivewireContour,
Probe,
UltrasoundDirectional,
} = measurementServiceMappingsFactory(
measurementService,
displaySetService,
cornerstoneViewportService,
customizationService
);
const csTools3DVer1MeasurementSource = measurementService.createSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
/* Mappings */
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Length',
Length.matchingCriteria,
Length.toAnnotation,
Length.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Crosshairs',
Length.matchingCriteria,
() => {
console.warn('Crosshairs mapping not implemented.');
},
() => {
console.warn('Crosshairs mapping not implemented.');
}
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Bidirectional',
Bidirectional.matchingCriteria,
Bidirectional.toAnnotation,
Bidirectional.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'EllipticalROI',
EllipticalROI.matchingCriteria,
EllipticalROI.toAnnotation,
EllipticalROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'CircleROI',
CircleROI.matchingCriteria,
CircleROI.toAnnotation,
CircleROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'ArrowAnnotate',
ArrowAnnotate.matchingCriteria,
ArrowAnnotate.toAnnotation,
ArrowAnnotate.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'CobbAngle',
CobbAngle.matchingCriteria,
CobbAngle.toAnnotation,
CobbAngle.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Angle',
Angle.matchingCriteria,
Angle.toAnnotation,
Angle.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'RectangleROI',
RectangleROI.matchingCriteria,
RectangleROI.toAnnotation,
RectangleROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'PlanarFreehandROI',
PlanarFreehandROI.matchingCriteria,
PlanarFreehandROI.toAnnotation,
PlanarFreehandROI.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'SplineROI',
SplineROI.matchingCriteria,
SplineROI.toAnnotation,
SplineROI.toMeasurement
);
// On the UI side, the Calibration Line tool will work almost the same as the
// Length tool
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'CalibrationLine',
Length.matchingCriteria,
Length.toAnnotation,
Length.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'LivewireContour',
LivewireContour.matchingCriteria,
LivewireContour.toAnnotation,
LivewireContour.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Probe',
Probe.matchingCriteria,
Probe.toAnnotation,
Probe.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'UltrasoundDirectionalTool',
UltrasoundDirectional.matchingCriteria,
UltrasoundDirectional.toAnnotation,
UltrasoundDirectional.toMeasurement
);
return csTools3DVer1MeasurementSource;
};
const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesManager) => {
const {
measurementService,
displaySetService,
cornerstoneViewportService,
customizationService,
} = servicesManager.services;
const csTools3DVer1MeasurementSource = initMeasurementService(
measurementService,
displaySetService,
cornerstoneViewportService,
customizationService
);
connectMeasurementServiceToTools(measurementService, cornerstoneViewportService);
const { annotationToMeasurement, remove } = csTools3DVer1MeasurementSource;
//
function addMeasurement(csToolsEvent) {
try {
const annotationAddedEventDetail = csToolsEvent.detail;
const {
annotation: { metadata, annotationUID },
} = annotationAddedEventDetail;
const { toolName } = metadata;
if (csToolsEvent.type === completedEvt && toolName === toolNames.CalibrationLine) {
// show modal to input the measurement (mm)
onCompletedCalibrationLine(servicesManager, csToolsEvent)
.then(
() => {
console.log('Calibration applied.');
},
() => true
)
.finally(() => {
// we don't need the calibration line lingering around, remove the
// annotation from the display
removeAnnotation(annotationUID);
removeMeasurement(csToolsEvent);
// this will ensure redrawing of annotations
cornerstoneViewportService.resize();
});
} else {
// To force the measurementUID be the same as the annotationUID
// Todo: this should be changed when a measurement can include multiple annotations
// in the future
annotationAddedEventDetail.uid = annotationUID;
annotationToMeasurement(toolName, annotationAddedEventDetail);
}
} catch (error) {
console.warn('Failed to add measurement:', error);
}
}
function updateMeasurement(csToolsEvent) {
try {
const annotationModifiedEventDetail = csToolsEvent.detail;
const {
annotation: { metadata, annotationUID },
} = annotationModifiedEventDetail;
// If the measurement hasn't been added, don't modify it
const measurement = measurementService.getMeasurement(annotationUID);
if (!measurement) {
return;
}
const { toolName } = metadata;
annotationModifiedEventDetail.uid = annotationUID;
// Passing true to indicate this is an update and NOT a annotation (start) completion.
annotationToMeasurement(toolName, annotationModifiedEventDetail, true);
} catch (error) {
console.warn('Failed to update measurement:', error);
}
}
function selectMeasurement(csToolsEvent) {
try {
const annotationSelectionEventDetail = csToolsEvent.detail;
const { added: addedSelectedAnnotationUIDs, removed: removedSelectedAnnotationUIDs } =
annotationSelectionEventDetail;
if (removedSelectedAnnotationUIDs) {
removedSelectedAnnotationUIDs.forEach(annotationUID =>
measurementService.setMeasurementSelected(annotationUID, false)
);
}
if (addedSelectedAnnotationUIDs) {
addedSelectedAnnotationUIDs.forEach(annotationUID =>
measurementService.setMeasurementSelected(annotationUID, true)
);
}
} catch (error) {
console.warn('Failed to select/unselect measurements:', error);
}
}
/**
* When csTools fires a removed event, remove the same measurement
* from the measurement service
*
* @param {*} csToolsEvent
*/
function removeMeasurement(csToolsEvent) {
try {
const annotationRemovedEventDetail = csToolsEvent.detail;
const {
annotation: { annotationUID },
} = annotationRemovedEventDetail;
const measurement = measurementService.getMeasurement(annotationUID);
if (measurement) {
remove(annotationUID, annotationRemovedEventDetail);
}
} catch (error) {
console.warn('Failed to remove measurement:', error);
}
}
// on display sets added, check if there are any measurements in measurement service that need to be
// put into cornerstone tools
const addedEvt = csToolsEvents.ANNOTATION_ADDED;
const completedEvt = csToolsEvents.ANNOTATION_COMPLETED;
const updatedEvt = csToolsEvents.ANNOTATION_MODIFIED;
const removedEvt = csToolsEvents.ANNOTATION_REMOVED;
const selectionEvt = csToolsEvents.ANNOTATION_SELECTION_CHANGE;
eventTarget.addEventListener(addedEvt, addMeasurement);
eventTarget.addEventListener(completedEvt, addMeasurement);
eventTarget.addEventListener(updatedEvt, updateMeasurement);
eventTarget.addEventListener(removedEvt, removeMeasurement);
eventTarget.addEventListener(selectionEvt, selectMeasurement);
return csTools3DVer1MeasurementSource;
};
const connectMeasurementServiceToTools = (measurementService, cornerstoneViewportService) => {
const { MEASUREMENT_REMOVED, MEASUREMENTS_CLEARED, MEASUREMENT_UPDATED, RAW_MEASUREMENT_ADDED } =
measurementService.EVENTS;
measurementService.subscribe(MEASUREMENTS_CLEARED, ({ measurements }) => {
if (!Object.keys(measurements).length) {
return;
}
for (const measurement of Object.values(measurements)) {
const { uid, source } = measurement;
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
continue;
}
removeAnnotation(uid);
}
// trigger a render
cornerstoneViewportService.getRenderingEngine().render();
});
measurementService.subscribe(
MEASUREMENT_UPDATED,
({ source, measurement, notYetUpdatedAtSource }) => {
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
return;
}
if (notYetUpdatedAtSource === false) {
// This event was fired by cornerstone telling the measurement service to sync.
// Already in sync.
return;
}
const { uid, label, isLocked, isVisible } = measurement;
const sourceAnnotation = annotation.state.getAnnotation(uid);
const { data, metadata } = sourceAnnotation;
if (!data) {
return;
}
if (data.label !== label) {
data.label = label;
}
if (metadata.toolName === 'ArrowAnnotate') {
data.text = label;
}
// update the isLocked state
annotation.locking.setAnnotationLocked(uid, isLocked);
// update the isVisible state
annotation.visibility.setAnnotationVisibility(uid, isVisible);
// annotation.config.style.setAnnotationStyles(uid, {
// color: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
// });
// I don't like this but will fix later
const renderingEngine =
cornerstoneViewportService.getRenderingEngine() as Types.IRenderingEngine;
// Note: We could do a better job by triggering the render on the
// viewport itself, but the removeAnnotation does not include that info...
const viewportIds = renderingEngine.getViewports().map(viewport => viewport.id);
triggerAnnotationRenderForViewportIds(viewportIds);
}
);
measurementService.subscribe(
RAW_MEASUREMENT_ADDED,
({ source, measurement, data, dataSource }) => {
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
return;
}
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID } = measurement;
const instance = DicomMetadataStore.getInstance(
referenceStudyUID,
referenceSeriesUID,
SOPInstanceUID
);
let imageId;
let frameNumber = 1;
if (measurement?.metadata?.referencedImageId) {
imageId = measurement.metadata.referencedImageId;
frameNumber = getSOPInstanceAttributes(measurement.metadata.referencedImageId).frameNumber;
} else {
imageId = dataSource.getImageIdsForInstance({ instance });
}
/**
* This annotation is used by the cornerstone viewport.
* This is not the read-only annotation rendered by the SR viewport.
*/
const annotationManager = annotation.state.getAnnotationManager();
annotationManager.addAnnotation({
annotationUID: measurement.uid,
highlighted: false,
isLocked: false,
invalidated: false,
metadata: {
toolName: measurement.toolName,
FrameOfReferenceUID: measurement.FrameOfReferenceUID,
referencedImageId: imageId,
},
data: {
/**
* Don't remove this destructuring of data here.
* This is used to pass annotation specific data forward e.g. contour
*/
...(data.annotation.data || {}),
text: data.annotation.data.text,
handles: { ...data.annotation.data.handles },
cachedStats: { ...data.annotation.data.cachedStats },
label: data.annotation.data.label,
frameNumber,
},
});
}
);
measurementService.subscribe(
MEASUREMENT_REMOVED,
({ source, measurement: removedMeasurementId }) => {
if (source?.name && source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
return;
}
removeAnnotation(removedMeasurementId);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
// Note: We could do a better job by triggering the render on the
// viewport itself, but the removeAnnotation does not include that info...
renderingEngine.render();
}
);
};
export {
initMeasurementService,
connectToolsToMeasurementService,
connectMeasurementServiceToTools,
};

View File

@@ -0,0 +1,33 @@
import { cache, imageLoadPoolManager, imageLoader, Enums, eventTarget, EVENTS as csEvents } from '@cornerstonejs/core';
function initStudyPrefetcherService(servicesManager: AppTypes.ServicesManager) {
const { studyPrefetcherService } = servicesManager.services;
studyPrefetcherService.requestType = Enums.RequestType.Prefetch;
studyPrefetcherService.imageLoadPoolManager = imageLoadPoolManager;
studyPrefetcherService.imageLoader = imageLoader;
studyPrefetcherService.cache = {
isImageCached(imageId: string): boolean {
return !!cache.getImageLoadObject(imageId);
}
}
studyPrefetcherService.imageLoadEventsManager = {
addEventListeners(onImageLoaded, onImageLoadFailed) {
eventTarget.addEventListener(csEvents.IMAGE_LOADED, onImageLoaded);
eventTarget.addEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed);
return [
{
unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOADED, onImageLoaded)
},
{
unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed)
},
]
}
}
}
export default initStudyPrefetcherService;

View File

@@ -0,0 +1,56 @@
import { volumeLoader } from '@cornerstonejs/core';
import {
cornerstoneStreamingImageVolumeLoader,
cornerstoneStreamingDynamicImageVolumeLoader,
} from '@cornerstonejs/core/loaders';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import { errorHandler, utils } from '@ohif/core';
const { registerVolumeLoader } = volumeLoader;
export default function initWADOImageLoader(
userAuthenticationService,
appConfig,
extensionManager
) {
registerVolumeLoader('cornerstoneStreamingImageVolume', cornerstoneStreamingImageVolumeLoader);
registerVolumeLoader(
'cornerstoneStreamingDynamicImageVolume',
cornerstoneStreamingDynamicImageVolumeLoader
);
dicomImageLoader.init({
maxWebWorkers: Math.min(
Math.max(navigator.hardwareConcurrency - 1, 1),
appConfig.maxNumberOfWebWorkers
),
beforeSend: function (xhr) {
//TODO should be removed in the future and request emitted by DicomWebDataSource
const sourceConfig = extensionManager.getActiveDataSource()?.[0].getConfig() ?? {};
const headers = userAuthenticationService.getAuthorizationHeader();
const acceptHeader = utils.generateAcceptHeader(
sourceConfig.acceptHeader,
sourceConfig.requestTransferSyntaxUID,
sourceConfig.omitQuotationForMultipartRequest
);
const xhrRequestHeaders = {
Accept: acceptHeader,
};
if (headers) {
Object.assign(xhrRequestHeaders, headers);
}
return xhrRequestHeaders;
},
errorInterceptor: error => {
errorHandler.getHTTPErrorHandler(error);
},
});
}
export function destroy() {
console.debug('Destroying WADO Image Loader');
}

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useRef } from 'react';
import { useViewportGrid } from '@ohif/ui';
import { MeasurementTable } from '@ohif/ui-next';
import debounce from 'lodash.debounce';
import { useMeasurements } from '../hooks/useMeasurements';
import { showLabelAnnotationPopup, colorPickerDialog } from '@ohif/extension-default';
export default function PanelMeasurementTable({
servicesManager,
customHeader,
measurementFilter,
}: withAppTypes): React.ReactNode {
const measurementsPanelRef = useRef(null);
const [viewportGrid] = useViewportGrid();
const { measurementService, customizationService, uiDialogService } = servicesManager.services;
const displayMeasurements = useMeasurements(servicesManager, {
measurementFilter,
});
useEffect(() => {
if (displayMeasurements.length > 0) {
debounce(() => {
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight;
}, 300)();
}
}, [displayMeasurements.length]);
const onMeasurementItemClickHandler = (uid: string, isActive: boolean) => {
if (isActive) {
return;
}
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
};
const jumpToImage = (uid: string) => {
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid);
onMeasurementItemClickHandler(uid, true);
};
const removeMeasurement = (uid: string) => {
measurementService.remove(uid);
};
const renameMeasurement = (uid: string) => {
jumpToImage(uid);
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => {
measurementService.update(
uid,
{
...val,
},
true
);
});
};
const changeColorMeasurement = (uid: string) => {
const { color } = measurementService.getMeasurement(uid);
const rgbaColor = {
r: color[0],
g: color[1],
b: color[2],
a: color[3] / 255.0,
};
colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
});
};
const toggleLockMeasurement = (uid: string) => {
measurementService.toggleLockMeasurement(uid);
};
const toggleVisibilityMeasurement = (uid: string) => {
measurementService.toggleVisibilityMeasurement(uid);
};
const measurements = displayMeasurements.filter(
dm => dm.measurementType !== measurementService.VALUE_TYPES.POINT && dm.referencedImageId
);
const additionalFindings = displayMeasurements.filter(
dm => dm.measurementType === measurementService.VALUE_TYPES.POINT && dm.referencedImageId
);
return (
<>
<div
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
ref={measurementsPanelRef}
data-cy={'trackedMeasurements-panel'}
>
<MeasurementTable
title="Measurements"
data={measurements}
onClick={jumpToImage}
onDelete={removeMeasurement}
onToggleVisibility={toggleVisibilityMeasurement}
onToggleLocked={toggleLockMeasurement}
onRename={renameMeasurement}
// onColor={changeColorMeasurement}
>
<MeasurementTable.Header>
{customHeader && (
<>
{typeof customHeader === 'function'
? customHeader({
additionalFindings,
measurements,
})
: customHeader}
</>
)}
</MeasurementTable.Header>
<MeasurementTable.Body />
</MeasurementTable>
{additionalFindings.length > 0 && (
<MeasurementTable
data={additionalFindings}
title="Additional Findings"
onClick={jumpToImage}
onDelete={removeMeasurement}
onToggleVisibility={toggleVisibilityMeasurement}
onToggleLocked={toggleLockMeasurement}
onRename={renameMeasurement}
// onColor={changeColorMeasurement}
>
<MeasurementTable.Body />
</MeasurementTable>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { SegmentationTable } from '@ohif/ui-next';
import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations';
import { metaData } from '@cornerstonejs/core';
export default function PanelSegmentation({
servicesManager,
commandsManager,
children,
}: withAppTypes) {
const { customizationService, viewportGridService, displaySetService } = servicesManager.services;
const { segmentationsWithRepresentations, disabled } =
useActiveViewportSegmentationRepresentations({
servicesManager,
});
const handlers = {
onSegmentationAdd: async () => {
const viewportId = viewportGridService.getState().activeViewportId;
commandsManager.run('createLabelmapForViewport', { viewportId });
},
onSegmentationClick: (segmentationId: string) => {
commandsManager.run('setActiveSegmentation', { segmentationId });
},
onSegmentAdd: segmentationId => {
commandsManager.run('addSegment', { segmentationId });
},
onSegmentClick: (segmentationId, segmentIndex) => {
commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex });
},
onSegmentEdit: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentLabel', { segmentationId, segmentIndex });
},
onSegmentationEdit: segmentationId => {
commandsManager.run('editSegmentationLabel', { segmentationId });
},
onSegmentColorClick: (segmentationId, segmentIndex) => {
commandsManager.run('editSegmentColor', { segmentationId, segmentIndex });
},
onSegmentDelete: (segmentationId, segmentIndex) => {
commandsManager.run('deleteSegment', { segmentationId, segmentIndex });
},
onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => {
commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type });
},
onToggleSegmentLock: (segmentationId, segmentIndex) => {
commandsManager.run('toggleSegmentLock', { segmentationId, segmentIndex });
},
onToggleSegmentationRepresentationVisibility: (segmentationId, type) => {
commandsManager.run('toggleSegmentationVisibility', { segmentationId, type });
},
onSegmentationDownload: segmentationId => {
commandsManager.run('downloadSegmentation', { segmentationId });
},
storeSegmentation: async segmentationId => {
commandsManager.run('storeSegmentation', { segmentationId });
},
onSegmentationDownloadRTSS: segmentationId => {
commandsManager.run('downloadRTSS', { segmentationId });
},
setStyle: (segmentationId, type, key, value) => {
commandsManager.run('setSegmentationStyle', { segmentationId, type, key, value });
},
toggleRenderInactiveSegmentations: () => {
commandsManager.run('toggleRenderInactiveSegmentations');
},
onSegmentationRemoveFromViewport: segmentationId => {
commandsManager.run('removeSegmentationFromViewport', { segmentationId });
},
onSegmentationDelete: segmentationId => {
commandsManager.run('deleteSegmentation', { segmentationId });
},
setFillAlpha: ({ type }, value) => {
commandsManager.run('setFillAlpha', { type, value });
},
setOutlineWidth: ({ type }, value) => {
commandsManager.run('setOutlineWidth', { type, value });
},
setRenderFill: ({ type }, value) => {
commandsManager.run('setRenderFill', { type, value });
},
setRenderOutline: ({ type }, value) => {
commandsManager.run('setRenderOutline', { type, value });
},
setFillAlphaInactive: ({ type }, value) => {
commandsManager.run('setFillAlphaInactive', { type, value });
},
getRenderInactiveSegmentations: () => {
return commandsManager.run('getRenderInactiveSegmentations');
},
};
const { mode: SegmentationTableMode } = customizationService.getCustomization(
'PanelSegmentation.tableMode',
{
id: 'default.segmentationTable.mode',
mode: 'collapsed',
}
);
// custom onSegmentationAdd if provided
const { onSegmentationAdd } = customizationService.getCustomization(
'PanelSegmentation.onSegmentationAdd',
{
id: 'segmentation.onSegmentationAdd',
onSegmentationAdd: handlers.onSegmentationAdd,
}
);
const { disableEditing } = customizationService.getCustomization(
'PanelSegmentation.disableEditing',
{
id: 'default.disableEditing',
disableEditing: false,
}
);
const { showAddSegment } = customizationService.getCustomization(
'PanelSegmentation.showAddSegment',
{
id: 'default.showAddSegment',
showAddSegment: true,
}
);
const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => {
const { representationData, segmentationId } = segmentation;
const { Labelmap } = representationData;
if (!Labelmap) {
return {
segmentationId,
isExportable: true,
};
}
const referencedImageIds = Labelmap.referencedImageIds;
const firstImageId = referencedImageIds[0];
const instance = metaData.get('instance', firstImageId);
const { SOPInstanceUID, SeriesInstanceUID } = instance;
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
const isExportable = displaySet.isReconstructable;
return {
segmentationId,
isExportable,
};
});
return (
<>
<SegmentationTable
disabled={disabled}
data={segmentationsWithRepresentations}
mode={SegmentationTableMode}
title="Segmentations"
exportOptions={exportOptions}
disableEditing={disableEditing}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={handlers.onSegmentationClick}
onSegmentationDelete={handlers.onSegmentationDelete}
showAddSegment={showAddSegment}
onSegmentAdd={handlers.onSegmentAdd}
onSegmentClick={handlers.onSegmentClick}
onSegmentEdit={handlers.onSegmentEdit}
onSegmentationEdit={handlers.onSegmentationEdit}
onSegmentColorClick={handlers.onSegmentColorClick}
onSegmentDelete={handlers.onSegmentDelete}
onToggleSegmentVisibility={handlers.onToggleSegmentVisibility}
onToggleSegmentLock={handlers.onToggleSegmentLock}
onToggleSegmentationRepresentationVisibility={
handlers.onToggleSegmentationRepresentationVisibility
}
onSegmentationDownload={handlers.onSegmentationDownload}
storeSegmentation={handlers.storeSegmentation}
onSegmentationDownloadRTSS={handlers.onSegmentationDownloadRTSS}
setStyle={handlers.setStyle}
toggleRenderInactiveSegmentations={handlers.toggleRenderInactiveSegmentations}
onSegmentationRemoveFromViewport={handlers.onSegmentationRemoveFromViewport}
setFillAlpha={handlers.setFillAlpha}
setOutlineWidth={handlers.setOutlineWidth}
setRenderFill={handlers.setRenderFill}
setRenderOutline={handlers.setRenderOutline}
setFillAlphaInactive={handlers.setFillAlphaInactive}
renderInactiveSegmentations={handlers.getRenderInactiveSegmentations()}
>
{children}
<SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow />
{SegmentationTableMode === 'collapsed' ? (
<SegmentationTable.Collapsed>
<SegmentationTable.SelectorHeader />
<SegmentationTable.AddSegmentRow />
<SegmentationTable.Segments />
</SegmentationTable.Collapsed>
) : (
<SegmentationTable.Expanded>
<SegmentationTable.Header />
{/* <SegmentationTable.AddSegmentRow /> */}
<SegmentationTable.Segments />
</SegmentationTable.Expanded>
)}
</SegmentationTable>
</>
);
}

View File

@@ -0,0 +1,284 @@
import { PubSubService } from '@ohif/core';
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
import { StackViewport, VolumeViewport, getRenderingEngine } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
const { ViewportColorbar } = utilities.voi.colorbar;
export default class ColorbarService extends PubSubService {
static EVENTS = {
STATE_CHANGED: 'event::ColorbarService:stateChanged',
};
static defaultStyles = {
position: 'absolute',
boxSizing: 'border-box',
border: 'solid 1px #555',
cursor: 'initial',
};
static positionStyles = {
left: { left: '5%' },
right: { right: '5%' },
top: { top: '5%' },
bottom: { bottom: '5%' },
};
static defaultTickStyles = {
position: 'left',
style: {
font: '12px Arial',
color: '#fff',
maxNumTicks: 8,
tickSize: 5,
tickWidth: 1,
labelMargin: 3,
},
};
public static REGISTRATION = {
name: 'colorbarService',
create: () => {
return new ColorbarService();
},
};
colorbars = {};
constructor() {
super(ColorbarService.EVENTS);
}
/**
* Gets the volume ID for a given identifier by searching through the viewport's volume IDs.
* @param viewport - The viewport instance to search volumes in
* @param searchId - The identifier to search for within volume IDs
* @returns The matching volume ID if found, null otherwise
*/
private getVolumeIdForIdentifier(viewport, searchId: string): string | null {
const volumeIds = viewport.getAllVolumeIds?.() || [];
return volumeIds.length > 0 ? volumeIds.find(id => id.includes(searchId)) || null : null;
}
/**
* Adds a colorbar to a specific viewport identified by `viewportId`, using the provided `displaySetInstanceUIDs` and `options`.
* This method sets up the colorbar, associates it with the viewport, and applies initial configurations based on the provided options.
*
* @param viewportId The identifier for the viewport where the colorbar will be added.
* @param displaySetInstanceUIDs An array of display set instance UIDs to associate with the colorbar.
* @param options Configuration options for the colorbar, including position, colormaps, active colormap name, ticks, and width.
*/
public addColorbar(viewportId, displaySetInstanceUIDs, options = {} as ColorbarOptions) {
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) {
return;
}
const { element } = viewport;
const actorEntries = viewport.getActors();
if (!actorEntries || actorEntries.length === 0) {
return;
}
const { position, width: thickness, activeColormapName, colormaps } = options;
const numContainers = displaySetInstanceUIDs.length;
const containers = this.createContainers(
numContainers,
element,
position,
thickness,
viewportId
);
displaySetInstanceUIDs.forEach((displaySetInstanceUID, index) => {
const volumeId = this.getVolumeIdForIdentifier(viewport, displaySetInstanceUID);
const properties = viewport?.getProperties(volumeId);
const colormap = properties?.colormap;
if (activeColormapName && !colormap) {
this.setViewportColormap(
viewportId,
displaySetInstanceUID,
colormaps[activeColormapName],
true
);
}
const colorbarContainer = containers[index];
const colorbar = new ViewportColorbar({
id: `ctColorbar-${viewportId}-${index}`,
element,
colormaps: options.colormaps || {},
// if there's an existing colormap set, we use it, otherwise we use the activeColormapName, otherwise, grayscale
activeColormapName: colormap?.name || options?.activeColormapName || 'Grayscale',
container: colorbarContainer,
ticks: {
...ColorbarService.defaultTickStyles,
...options.ticks,
},
volumeId: viewport instanceof VolumeViewport ? volumeId : undefined,
});
if (this.colorbars[viewportId]) {
this.colorbars[viewportId].push({ colorbar, container: colorbarContainer });
} else {
this.colorbars[viewportId] = [{ colorbar, container: colorbarContainer }];
}
});
this._broadcastEvent(ColorbarService.EVENTS.STATE_CHANGED, {
viewportId,
changeType: ChangeTypes.Added,
});
}
/**
* Removes the colorbar associated with a given viewport ID. This involves cleaning up any created DOM elements and internal references.
*
* @param viewportId The identifier for the viewport from which the colorbar will be removed.
*/
public removeColorbar(viewportId) {
const colorbarInfo = this.colorbars[viewportId];
if (!colorbarInfo) {
return;
}
colorbarInfo.forEach(({ colorbar, container }) => {
container.parentNode.removeChild(container);
});
delete this.colorbars[viewportId];
this._broadcastEvent(ColorbarService.EVENTS.STATE_CHANGED, {
viewportId,
changeType: ChangeTypes.Removed,
});
}
/**
* Checks whether a colorbar is associated with a given viewport ID.
*
* @param viewportId The identifier for the viewport to check.
* @returns `true` if a colorbar exists for the specified viewport, otherwise `false`.
*/
public hasColorbar(viewportId) {
return this.colorbars[viewportId] ? true : false;
}
/**
* Retrieves the current state of colorbars, including all active colorbars and their configurations.
*
* @returns An object representing the current state of all colorbars managed by this service.
*/
public getState() {
return this.colorbars;
}
/**
* Retrieves colorbar information for a specific viewport ID.
*
* @param viewportId The identifier for the viewport to retrieve colorbar information for.
* @returns The colorbar information associated with the specified viewport, if available.
*/
public getViewportColorbar(viewportId) {
return this.colorbars[viewportId];
}
/**
* Handles the cleanup and removal of all colorbars from the viewports. This is typically called
* when exiting the mode or context in which the colorbars are used, ensuring that no DOM
* elements or references are left behind.
*/
public onModeExit() {
const viewportIds = Object.keys(this.colorbars);
viewportIds.forEach(viewportId => {
this.removeColorbar(viewportId);
});
}
/**
* Sets the colormap for a viewport. This function is used internally to update the colormap the viewport
*
* @param viewportId The identifier of the viewport to update.
* @param displaySetInstanceUID The display set instance UID associated with the viewport.
* @param colormap The colormap object to set on the viewport.
* @param immediate A boolean indicating whether the viewport should be re-rendered immediately after setting the colormap.
*/
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
const viewport = renderingEngine.getViewport(viewportId);
const actorEntries = viewport?.getActors();
if (!viewport || !actorEntries || actorEntries.length === 0) {
return;
}
const setViewportProperties = (viewport, uid) => {
const volumeId = this.getVolumeIdForIdentifier(viewport, uid);
viewport.setProperties({ colormap }, volumeId);
};
if (viewport instanceof StackViewport) {
setViewportProperties(viewport, viewportId);
}
if (viewport instanceof VolumeViewport) {
setViewportProperties(viewport, displaySetInstanceUID);
}
if (immediate) {
viewport.render();
}
}
/**
* Creates the container elements for colorbars based on the specified parameters. This function dynamically
* generates and styles DOM elements to host the colorbars, positioning them according to the specified options.
*
* @param numContainers The number of containers to create, typically corresponding to the number of colorbars.
* @param element The DOM element within which the colorbar containers will be placed.
* @param position The position of the colorbar containers (e.g., 'top', 'bottom', 'left', 'right').
* @param thickness The thickness of the colorbar containers, affecting their width or height depending on their position.
* @param viewportId The identifier of the viewport for which the containers are being created.
* @returns An array of the created container DOM elements.
*/
private createContainers(numContainers, element, position, thickness, viewportId) {
const containers = [];
const dimensions = {
1: 50,
2: 33,
};
const dimension = dimensions[numContainers] || 50 / numContainers;
Array.from({ length: numContainers }).forEach((_, i) => {
const colorbarContainer = document.createElement('div');
colorbarContainer.id = `ctColorbarContainer-${viewportId}-${i + 1}`;
Object.assign(colorbarContainer.style, ColorbarService.defaultStyles);
if (['top', 'bottom'].includes(position)) {
Object.assign(colorbarContainer.style, {
width: `${dimension}%`,
height: thickness || '2.5%',
left: `${(i + 1) * dimension}%`,
transform: 'translateX(-50%)',
...ColorbarService.positionStyles[position],
});
} else if (['left', 'right'].includes(position)) {
Object.assign(colorbarContainer.style, {
height: `${dimension}%`,
width: thickness || '2.5%',
top: `${(i + 1) * dimension}%`,
transform: 'translateY(-50%)',
...ColorbarService.positionStyles[position],
});
}
element.appendChild(colorbarContainer);
containers.push(colorbarContainer);
});
return containers;
}
}

View File

@@ -0,0 +1,2 @@
import ColorbarService from './ColorbarService';
export default ColorbarService;

View File

@@ -0,0 +1,324 @@
import { Types } from '@ohif/core';
import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
class CornerstoneCacheService {
static REGISTRATION = {
name: 'cornerstoneCacheService',
altName: 'CornerstoneCacheService',
create: ({ servicesManager }: Types.Extensions.ExtensionParams): CornerstoneCacheService => {
return new CornerstoneCacheService(servicesManager);
},
};
stackImageIds: Map<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = new Map();
readonly servicesManager: AppTypes.ServicesManager;
constructor(servicesManager: AppTypes.ServicesManager) {
this.servicesManager = servicesManager;
}
public getCacheSize() {
return cs3DCache.getCacheSize();
}
public getCacheFreeSpace() {
return cs3DCache.getBytesAvailable();
}
public async createViewportData(
displaySets: Types.DisplaySet[],
viewportOptions: AppTypes.ViewportGrid.GridViewportOptions,
dataSource: unknown,
initialImageIndex?: number
): Promise<StackViewportData | VolumeViewportData> {
const viewportType = viewportOptions.viewportType as string;
const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets);
let viewportData: StackViewportData | VolumeViewportData;
if (
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
) {
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
} else if (cs3DViewportType === Enums.ViewportType.STACK) {
// Everything else looks like a stack
viewportData = await this._getStackViewportData(
dataSource,
displaySets,
initialImageIndex,
cs3DViewportType
);
} else {
viewportData = await this._getOtherViewportData(
dataSource,
displaySets,
initialImageIndex,
cs3DViewportType
);
}
viewportData.viewportType = cs3DViewportType;
return viewportData;
}
public async invalidateViewportData(
viewportData: VolumeViewportData | StackViewportData,
invalidatedDisplaySetInstanceUID: string,
dataSource,
displaySetService
): Promise<VolumeViewportData | StackViewportData> {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
// remove images from the cache to be able to re-load them
imageIds.forEach(imageId => {
if (cs3DCache.getImageLoadObject(imageId)) {
cs3DCache.removeImageLoadObject(imageId);
}
});
return {
viewportType: Enums.ViewportType.STACK,
data: {
StudyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
imageIds,
},
};
}
// Todo: grab the volume and get the id from the viewport itself
const volumeId = `${VOLUME_LOADER_SCHEME}:${invalidatedDisplaySetInstanceUID}`;
const volume = cs3DCache.getVolume(volumeId);
if (volume) {
if (volume.imageIds) {
// also for each imageId in the volume, remove the imageId from the cache
// since that will hold the old metadata as well
volume.imageIds.forEach(imageId => {
if (cs3DCache.getImageLoadObject(imageId)) {
cs3DCache.removeImageLoadObject(imageId);
}
});
}
// this shouldn't be via removeVolumeLoadObject, since that will
// remove the texture as well, but here we really just need a remove
// from registry so that we load it again
cs3DCache._volumeCache.delete(volumeId);
this.volumeImageIds.delete(volumeId);
}
const displaySets = viewportData.data.map(({ displaySetInstanceUID }) =>
displaySetService.getDisplaySetByUID(displaySetInstanceUID)
);
const newViewportData = await this._getVolumeViewportData(
dataSource,
displaySets,
viewportData.viewportType
);
return newViewportData;
}
private async _getOtherViewportData(
dataSource,
displaySets,
_initialImageIndex,
viewportType: Enums.ViewportType
): Promise<StackViewportData> {
// TODO - handle overlays and secondary display sets, but for now assume
// the 1st display set is the one of interest
const [displaySet] = displaySets;
if (!displaySet.imageIds) {
displaySet.imagesIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
}
const { imageIds: data, viewportType: dsViewportType } = displaySet;
return {
viewportType: dsViewportType || viewportType,
data: displaySets,
};
}
private async _getStackViewportData(
dataSource,
displaySets,
initialImageIndex,
viewportType: Enums.ViewportType
): Promise<StackViewportData> {
const { uiNotificationService } = this.servicesManager.services;
const overlayDisplaySets = displaySets.filter(ds => ds.isOverlayDisplaySet);
for (const overlayDisplaySet of overlayDisplaySets) {
if (overlayDisplaySet.load && overlayDisplaySet.load instanceof Function) {
const { userAuthenticationService } = this.servicesManager.services;
const headers = userAuthenticationService.getAuthorizationHeader();
try {
await overlayDisplaySet.load({ headers });
} catch (e) {
uiNotificationService.show({
title: 'Error loading displaySet',
message: e.message,
type: 'error',
});
console.error(e);
}
}
}
// Ensuring the first non-overlay `displaySet` is always the primary one
const StackViewportData = [];
for (const displaySet of displaySets) {
const { displaySetInstanceUID, StudyInstanceUID, isCompositeStack } = displaySet;
if (displaySet.load && displaySet.load instanceof Function) {
const { userAuthenticationService } = this.servicesManager.services;
const headers = userAuthenticationService.getAuthorizationHeader();
try {
await displaySet.load({ headers });
} catch (e) {
uiNotificationService.show({
title: 'Error loading displaySet',
message: e.message,
type: 'error',
});
console.error(e);
}
}
let stackImageIds = this.stackImageIds.get(displaySet.displaySetInstanceUID);
if (!stackImageIds) {
stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
// assign imageIds to the displaySet
displaySet.imageIds = stackImageIds;
this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds);
}
StackViewportData.push({
StudyInstanceUID,
displaySetInstanceUID,
isCompositeStack,
imageIds: stackImageIds,
initialImageIndex,
});
}
return {
viewportType,
data: StackViewportData,
};
}
private async _getVolumeViewportData(
dataSource,
displaySets,
viewportType: Enums.ViewportType
): Promise<VolumeViewportData> {
// Todo: Check the cache for multiple scenarios to see if we need to
// decache the volume data from other viewports or not
const volumeData = [];
for (const displaySet of displaySets) {
const { Modality } = displaySet;
const isParametricMap = Modality === 'PMAP';
const isSeg = Modality === 'SEG';
// Don't create volumes for the displaySets that have custom load
// function (e.g., SEG, RT, since they rely on the reference volumes
// and they take care of their own loading after they are created in their
// getSOPClassHandler method
if (displaySet.load && displaySet.load instanceof Function) {
const { userAuthenticationService } = this.servicesManager.services;
const headers = userAuthenticationService.getAuthorizationHeader();
try {
await displaySet.load({ headers });
} catch (e) {
const { uiNotificationService } = this.servicesManager.services;
uiNotificationService.show({
title: 'Error loading displaySet',
message: e.message,
type: 'error',
});
console.error(e);
}
// Parametric maps have a `load` method but it should not be loaded in the
// same way as SEG and RTSTRUCT but like a normal volume
if (!isParametricMap) {
volumeData.push({
studyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
});
// Todo: do some cache check and empty the cache if needed
continue;
}
}
const volumeLoaderSchema = displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME;
const volumeId = `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`;
let volumeImageIds = this.volumeImageIds.get(displaySet.displaySetInstanceUID);
let volume = cs3DCache.getVolume(volumeId);
// Parametric maps do not have image ids but they already have volume data
// therefore a new volume should not be created.
if (!isParametricMap && !isSeg && (!volumeImageIds || !volume)) {
volumeImageIds = this._getCornerstoneVolumeImageIds(displaySet, dataSource);
volume = await volumeLoader.createAndCacheVolume(volumeId, {
imageIds: volumeImageIds,
});
this.volumeImageIds.set(displaySet.displaySetInstanceUID, volumeImageIds);
// Add imageIds to the displaySet for volumes
displaySet.imageIds = volumeImageIds;
}
volumeData.push({
StudyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
volume,
volumeId,
imageIds: volumeImageIds,
isDynamicVolume: displaySet.isDynamicVolume,
});
}
return {
viewportType,
data: volumeData,
};
}
private _getCornerstoneStackImageIds(displaySet, dataSource): string[] {
return dataSource.getImageIdsForDisplaySet(displaySet);
}
private _getCornerstoneVolumeImageIds(displaySet, dataSource): string[] {
if (displaySet.imageIds) {
return displaySet.imageIds;
}
const stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
return stackImageIds;
}
}
export default CornerstoneCacheService;

View File

@@ -0,0 +1,3 @@
import CornerstoneCacheService from './CornerstoneCacheService';
export default CornerstoneCacheService;

View File

@@ -0,0 +1,33 @@
/**
* Maps a DICOM RT Struct ROI Contour to a RTStruct data that can be used
* in Segmentation Service
*
* @param structureSet - A DICOM RT Struct ROI Contour
* @param rtDisplaySetUID - A CornerstoneTools DisplaySet UID
* @returns An array of object that includes data, id, segmentIndex, color
* and geometry Id
*/
export function mapROIContoursToRTStructData(structureSet: unknown, rtDisplaySetUID: unknown) {
return structureSet.ROIContours.map(({ contourPoints, ROINumber, ROIName, colorArray }) => {
const data = contourPoints.map(({ points, ...rest }) => {
const newPoints = points.map(({ x, y, z }) => {
return [x, y, z];
});
return {
...rest,
points: newPoints,
};
});
const id = ROIName || ROINumber;
return {
data,
id,
segmentIndex: ROINumber,
color: colorArray,
geometryId: `${rtDisplaySetUID}:${id}:segmentIndex-${ROINumber}`,
};
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
import SegmentationService from './SegmentationService';
export default SegmentationService;

View File

@@ -0,0 +1,264 @@
import { synchronizers, SynchronizerManager, Synchronizer } from '@cornerstonejs/tools';
import { getRenderingEngines, utilities } from '@cornerstonejs/core';
import { pubSubServiceInterface, Types } from '@ohif/core';
import createHydrateSegmentationSynchronizer from './createHydrateSegmentationSynchronizer';
const EVENTS = {
TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated',
};
/**
* @params options - are an optional set of options associated with the first
* sync group declared.
*/
export type SyncCreator = (id: string, options?: Record<string, unknown>) => Synchronizer;
export type SyncGroup = {
type: string;
id?: string;
// Source and target default to true if not specified
source?: boolean;
target?: boolean;
options?: Record<string, unknown>;
};
const POSITION = 'cameraposition';
const VOI = 'voi';
const ZOOMPAN = 'zoompan';
const STACKIMAGE = 'stackimage';
const IMAGE_SLICE = 'imageslice';
const HYDRATE_SEG = 'hydrateseg';
const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup =>
typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup;
export default class SyncGroupService {
static REGISTRATION = {
name: 'syncGroupService',
altName: 'SyncGroupService',
create: ({ servicesManager }: Types.Extensions.ExtensionParams): SyncGroupService => {
return new SyncGroupService(servicesManager);
},
};
servicesManager: AppTypes.ServicesManager;
listeners: { [key: string]: (...args: any[]) => void } = {};
EVENTS: { [key: string]: string };
synchronizerCreators: Record<string, SyncCreator> = {
[POSITION]: synchronizers.createCameraPositionSynchronizer,
[VOI]: synchronizers.createVOISynchronizer,
[ZOOMPAN]: synchronizers.createZoomPanSynchronizer,
// todo: remove stack image since it is legacy now and the image_slice
// handles both stack and volume viewports
[STACKIMAGE]: synchronizers.createImageSliceSynchronizer,
[IMAGE_SLICE]: synchronizers.createImageSliceSynchronizer,
[HYDRATE_SEG]: createHydrateSegmentationSynchronizer,
};
synchronizersByType: { [key: string]: Synchronizer[] } = {};
constructor(servicesManager: AppTypes.ServicesManager) {
this.servicesManager = servicesManager;
this.listeners = {};
this.EVENTS = EVENTS;
//
Object.assign(this, pubSubServiceInterface);
}
private _createSynchronizer(type: string, id: string, options): Synchronizer | undefined {
// Initialize if not already done
this.synchronizersByType[type] = this.synchronizersByType[type] || [];
const syncCreator = this.synchronizerCreators[type.toLowerCase()];
if (syncCreator) {
// Pass the servicesManager along with other parameters
const synchronizer = syncCreator(id, { ...options, servicesManager: this.servicesManager });
if (synchronizer) {
this.synchronizersByType[type].push(synchronizer);
return synchronizer;
}
} else {
console.debug(`Unknown synchronizer type: ${type}, id: ${id}`);
}
}
public getSyncCreatorForType(type: string): SyncCreator {
return this.synchronizerCreators[type.toLowerCase()];
}
/**
* Creates a synchronizer type.
* @param type is the type of the synchronizer to create
* @param creator
*/
public addSynchronizerType(type: string, creator: SyncCreator): void {
this.synchronizerCreators[type.toLowerCase()] = creator;
}
public getSynchronizer(id: string): Synchronizer | void {
return SynchronizerManager.getSynchronizer(id);
}
/**
* Registers a custom synchronizer.
* @param id - The id of the synchronizer.
* @param createFunction - The function that creates the synchronizer.
*/
public registerCustomSynchronizer(id: string, createFunction: SyncCreator): void {
this.synchronizerCreators[id] = createFunction;
}
/**
* Retrieves an array of synchronizers of a specific type.
* @param type - The type of synchronizers to retrieve.
* @returns An array of synchronizers of the specified type.
*/
public getSynchronizersOfType(type: string): Synchronizer[] {
return this.synchronizersByType[type];
}
protected _getOrCreateSynchronizer(
type: string,
id: string,
options: Record<string, unknown>
): Synchronizer | undefined {
let synchronizer = SynchronizerManager.getSynchronizer(id);
if (!synchronizer) {
synchronizer = this._createSynchronizer(type, id, options);
}
return synchronizer;
}
public addViewportToSyncGroup(
viewportId: string,
renderingEngineId: string,
syncGroups?: SyncGroup | string | SyncGroup[] | string[]
): void {
if (!syncGroups) {
return;
}
const syncGroupsArray = Array.isArray(syncGroups) ? syncGroups : [syncGroups];
syncGroupsArray.forEach(syncGroup => {
const syncGroupObj = asSyncGroup(syncGroup);
const { type, target = true, source = true, options = {}, id = type } = syncGroupObj;
const synchronizer = this._getOrCreateSynchronizer(type, id, options);
if (!synchronizer) {
return;
}
synchronizer.setOptions(viewportId, options);
const viewportInfo = { viewportId, renderingEngineId };
if (target && source) {
synchronizer.add(viewportInfo);
return;
} else if (source) {
synchronizer.addSource(viewportInfo);
} else if (target) {
synchronizer.addTarget(viewportInfo);
}
});
}
public destroy(): void {
SynchronizerManager.destroy();
}
public getSynchronizersForViewport(viewportId: string): Synchronizer[] {
const renderingEngine =
getRenderingEngines().find(re => {
return re.getViewports().find(vp => vp.id === viewportId);
}) || getRenderingEngines()[0];
const synchronizers = SynchronizerManager.getAllSynchronizers();
return synchronizers.filter(
s =>
s.hasSourceViewport(renderingEngine.id, viewportId) ||
s.hasTargetViewport(renderingEngine.id, viewportId)
);
}
public removeViewportFromSyncGroup(
viewportId: string,
renderingEngineId: string,
syncGroupId?: string
): void {
const synchronizers = SynchronizerManager.getAllSynchronizers();
const filteredSynchronizers = syncGroupId
? synchronizers.filter(s => s.id === syncGroupId)
: synchronizers;
filteredSynchronizers.forEach(synchronizer => {
if (!synchronizer) {
return;
}
// Only image slice synchronizer register spatial registration
if (this.isImageSliceSyncronizer(synchronizer)) {
this.unRegisterSpatialRegistration(synchronizer);
}
synchronizer.remove({
viewportId,
renderingEngineId,
});
// check if any viewport is left in any of the sync groups, if not, delete that sync group
const sourceViewports = synchronizer.getSourceViewports();
const targetViewports = synchronizer.getTargetViewports();
if (!sourceViewports.length && !targetViewports.length) {
SynchronizerManager.destroySynchronizer(synchronizer.id);
}
});
}
/**
* Clean up the spatial registration metadata created by synchronizer
* This is needed to be able to re-sync images slices if needed
* @param synchronizer
*/
unRegisterSpatialRegistration(synchronizer: Synchronizer) {
const sourceViewports = synchronizer.getSourceViewports().map(vp => vp.viewportId);
const targetViewports = synchronizer.getTargetViewports().map(vp => vp.viewportId);
// Create an array of pair of viewports to remove from spatialRegistrationMetadataProvider
// All sourceViewports combined with all targetViewports
const toUnregister = sourceViewports
.map((sourceViewportId: string) => {
return targetViewports.map(targetViewportId => [targetViewportId, sourceViewportId]);
})
.reduce((acc, c) => acc.concat(c), []);
toUnregister.forEach(viewportIdPair => {
utilities.spatialRegistrationMetadataProvider.add(viewportIdPair, undefined);
});
}
/**
* Check if the synchronizer type is IMAGE_SLICE
* Need to convert to lowercase here because the types are lowercase
* e.g: synchronizerCreators
* @param synchronizer
*/
isImageSliceSyncronizer(synchronizer: Synchronizer) {
return this.getSynchronizerType(synchronizer).toLowerCase() === IMAGE_SLICE;
}
/**
* Returns the syncronizer type
* @param synchronizer
*/
getSynchronizerType(synchronizer: Synchronizer): string {
const synchronizerTypes = Object.keys(this.synchronizersByType);
const syncType = synchronizerTypes.find(syncType =>
this.getSynchronizersOfType(syncType).includes(synchronizer)
);
return syncType;
}
}

View File

@@ -0,0 +1,79 @@
import { Types, getEnabledElementByViewportId } from '@cornerstonejs/core';
import {
SynchronizerManager,
Synchronizer,
Enums,
Types as ToolsTypes,
} from '@cornerstonejs/tools';
const { createSynchronizer } = SynchronizerManager;
const { SEGMENTATION_REPRESENTATION_ADDED } = Enums.Events;
export default function createHydrateSegmentationSynchronizer(
synchronizerName: string,
{ servicesManager, ...options }: { servicesManager: AppTypes.ServicesManager; options }
): Synchronizer {
const stackImageSynchronizer = createSynchronizer(
synchronizerName,
SEGMENTATION_REPRESENTATION_ADDED,
(synchronizerInstance, sourceViewport, targetViewport, sourceEvent) =>
segmentationRepresentationModifiedCallback(
synchronizerInstance,
sourceViewport,
targetViewport,
sourceEvent,
{ servicesManager, options }
),
{
eventSource: 'eventTarget',
}
);
return stackImageSynchronizer;
}
const segmentationRepresentationModifiedCallback = async (
synchronizerInstance: Synchronizer,
sourceViewport: Types.IViewportId,
targetViewport: Types.IViewportId,
sourceEvent: Event,
{ servicesManager, options }: { servicesManager: AppTypes.ServicesManager; options: unknown }
) => {
const event = sourceEvent as ToolsTypes.EventTypes.SegmentationRepresentationModifiedEventType;
const { segmentationId, viewportId } = event.detail;
const { segmentationService, hangingProtocolService } = servicesManager.services;
const targetViewportId = targetViewport.viewportId;
const { viewport } = getEnabledElementByViewportId(targetViewportId);
const targetFrameOfReferenceUID = viewport.getFrameOfReferenceUID();
if (!targetFrameOfReferenceUID) {
console.debug('No frame of reference UID found for the target viewport');
return;
}
const targetViewportRepresentation = segmentationService.getSegmentationRepresentations(
targetViewportId,
{ segmentationId }
);
if (targetViewportRepresentation.length > 0) {
return;
}
// whatever type the source viewport has, we need to add that to the target viewport
const sourceViewportRepresentation = segmentationService.getSegmentationRepresentations(
sourceViewport.viewportId,
{ segmentationId }
);
const type = sourceViewportRepresentation[0].type;
await segmentationService.addSegmentationRepresentation(targetViewportId, {
segmentationId,
type,
});
};

View File

@@ -0,0 +1,3 @@
import SyncGroupService from './SyncGroupService';
export default SyncGroupService;

View File

@@ -0,0 +1,321 @@
import { ToolGroupManager, Enums, Types } from '@cornerstonejs/tools';
import { eventTarget } from '@cornerstonejs/core';
import { Types as OhifTypes, pubSubServiceInterface } from '@ohif/core';
import getActiveViewportEnabledElement from '../../utils/getActiveViewportEnabledElement';
const EVENTS = {
VIEWPORT_ADDED: 'event::cornerstone::toolgroupservice:viewportadded',
TOOLGROUP_CREATED: 'event::cornerstone::toolgroupservice:toolgroupcreated',
TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:toolactivated',
PRIMARY_TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:primarytoolactivated',
};
type Tool = {
toolName: string;
bindings?: typeof Enums.MouseBindings | Enums.KeyboardBindings;
};
type Tools = {
active: Tool[];
passive?: Tool[];
enabled?: Tool[];
disabled?: Tool[];
};
export default class ToolGroupService {
public static REGISTRATION = {
name: 'toolGroupService',
altName: 'ToolGroupService',
create: ({ servicesManager }: OhifTypes.Extensions.ExtensionParams): ToolGroupService => {
return new ToolGroupService(servicesManager);
},
};
servicesManager: AppTypes.ServicesManager;
cornerstoneViewportService: any;
viewportGridService: any;
uiNotificationService: any;
private toolGroupIds: Set<string> = new Set();
/**
* Service-specific
*/
listeners: { [key: string]: Function[] };
EVENTS: { [key: string]: string };
constructor(servicesManager: AppTypes.ServicesManager) {
const { cornerstoneViewportService, viewportGridService, uiNotificationService } =
servicesManager.services;
this.cornerstoneViewportService = cornerstoneViewportService;
this.viewportGridService = viewportGridService;
this.uiNotificationService = uiNotificationService;
this.listeners = {};
this.EVENTS = EVENTS;
Object.assign(this, pubSubServiceInterface);
this._init();
}
onModeExit() {
this.destroy();
}
private _init() {
eventTarget.addEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
}
/**
* Retrieves a tool group from the ToolGroupManager by tool group ID.
* If no tool group ID is provided, it retrieves the tool group of the active viewport.
* @param toolGroupId - Optional ID of the tool group to retrieve.
* @returns The tool group or undefined if it is not found.
*/
public getToolGroup(toolGroupId?: string): Types.IToolGroup | void {
let toolGroupIdToUse = toolGroupId;
if (!toolGroupIdToUse) {
// Use the active viewport's tool group if no tool group id is provided
const enabledElement = getActiveViewportEnabledElement(this.viewportGridService);
if (!enabledElement) {
return;
}
const { renderingEngineId, viewportId } = enabledElement;
const toolGroup = ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngineId);
if (!toolGroup) {
console.warn(
'No tool group found for viewportId:',
viewportId,
'and renderingEngineId:',
renderingEngineId
);
return;
}
toolGroupIdToUse = toolGroup.id;
}
const toolGroup = ToolGroupManager.getToolGroup(toolGroupIdToUse);
return toolGroup;
}
public getToolGroupIds(): string[] {
return Array.from(this.toolGroupIds);
}
public getToolGroupForViewport(viewportId: string): Types.IToolGroup | void {
const renderingEngine = this.cornerstoneViewportService.getRenderingEngine();
return ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngine.id);
}
public getActiveToolForViewport(viewportId: string): string {
const toolGroup = this.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return;
}
return toolGroup.getActivePrimaryMouseButtonTool();
}
public destroy(): void {
ToolGroupManager.destroy();
this.toolGroupIds = new Set();
eventTarget.removeEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
}
public destroyToolGroup(toolGroupId: string): void {
ToolGroupManager.destroyToolGroup(toolGroupId);
this.toolGroupIds.delete(toolGroupId);
}
public removeViewportFromToolGroup(
viewportId: string,
renderingEngineId: string,
deleteToolGroupIfEmpty?: boolean
): void {
const toolGroup = ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngineId);
if (!toolGroup) {
return;
}
toolGroup.removeViewports(renderingEngineId, viewportId);
const viewportIds = toolGroup.getViewportIds();
if (viewportIds.length === 0 && deleteToolGroupIfEmpty) {
ToolGroupManager.destroyToolGroup(toolGroup.id);
}
}
public addViewportToToolGroup(
viewportId: string,
renderingEngineId: string,
toolGroupId?: string
): void {
if (!toolGroupId) {
// If toolGroupId is not provided, add the viewport to all toolGroups
const toolGroups = ToolGroupManager.getAllToolGroups();
toolGroups.forEach(toolGroup => {
toolGroup.addViewport(viewportId, renderingEngineId);
});
} else {
let toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
if (!toolGroup) {
toolGroup = this.createToolGroup(toolGroupId);
}
toolGroup.addViewport(viewportId, renderingEngineId);
}
this._broadcastEvent(EVENTS.VIEWPORT_ADDED, {
viewportId,
toolGroupId,
});
}
public createToolGroup(toolGroupId: string): Types.IToolGroup {
if (this.getToolGroup(toolGroupId)) {
throw new Error(`ToolGroup ${toolGroupId} already exists`);
}
// if the toolGroup doesn't exist, create it
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
this.toolGroupIds.add(toolGroupId);
this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, {
toolGroupId,
});
return toolGroup;
}
public addToolsToToolGroup(toolGroupId: string, tools: Array<Tool>, configs: any = {}): void {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
// this.changeConfigurationIfNecessary(toolGroup, volumeId);
this._addTools(toolGroup, tools, configs);
this._setToolsMode(toolGroup, tools);
}
public createToolGroupAndAddTools(toolGroupId: string, tools: Array<Tool>): Types.IToolGroup {
const toolGroup = this.createToolGroup(toolGroupId);
this.addToolsToToolGroup(toolGroupId, tools);
return toolGroup;
}
/**
* Get the tool's configuration based on the tool name and tool group id
* @param toolGroupId - The id of the tool group that the tool instance belongs to.
* @param toolName - The name of the tool
* @returns The configuration of the tool.
*/
public getToolConfiguration(toolGroupId: string, toolName: string) {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
if (!toolGroup) {
return null;
}
const tool = toolGroup.getToolInstance(toolName);
if (!tool) {
return null;
}
return tool.configuration;
}
/**
* Set the tool instance configuration. This will update the tool instance configuration
* on the toolGroup
* @param toolGroupId - The id of the tool group that the tool instance belongs to.
* @param toolName - The name of the tool
* @param config - The configuration object that you want to set.
*/
public setToolConfiguration(toolGroupId, toolName, config) {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
const toolInstance = toolGroup.getToolInstance(toolName);
toolInstance.configuration = config;
}
public getActivePrimaryMouseButtonTool(toolGroupId?: string): string {
return this.getToolGroup(toolGroupId)?.getActivePrimaryMouseButtonTool();
}
private _setToolsMode(toolGroup, tools) {
const { active, passive, enabled, disabled } = tools;
if (active) {
active.forEach(({ toolName, bindings }) => {
toolGroup.setToolActive(toolName, { bindings });
});
}
if (passive) {
passive.forEach(({ toolName }) => {
toolGroup.setToolPassive(toolName);
});
}
if (enabled) {
enabled.forEach(({ toolName }) => {
toolGroup.setToolEnabled(toolName);
});
}
if (disabled) {
disabled.forEach(({ toolName }) => {
toolGroup.setToolDisabled(toolName);
});
}
}
private _addTools(toolGroup, tools) {
const addTools = tools => {
tools.forEach(({ toolName, parentTool, configuration }) => {
if (parentTool) {
toolGroup.addToolInstance(toolName, parentTool, {
...configuration,
});
} else {
toolGroup.addTool(toolName, { ...configuration });
}
});
};
if (tools.active) {
addTools(tools.active);
}
if (tools.passive) {
addTools(tools.passive);
}
if (tools.enabled) {
addTools(tools.enabled);
}
if (tools.disabled) {
addTools(tools.disabled);
}
}
private _onToolActivated = (evt: Types.EventTypes.ToolActivatedEventType) => {
const { toolGroupId, toolName, toolBindingsOptions } = evt.detail;
const isPrimaryTool = toolBindingsOptions.bindings?.some(
binding => binding.mouseButton === Enums.MouseBindings.Primary
);
const callbackProps = {
toolGroupId,
toolName,
toolBindingsOptions,
};
this._broadcastEvent(EVENTS.TOOL_ACTIVATED, callbackProps);
if (isPrimaryTool) {
this._broadcastEvent(EVENTS.PRIMARY_TOOL_ACTIVATED, callbackProps);
}
};
}

View File

@@ -0,0 +1,3 @@
import ToolGroupService from './ToolGroupService';
export default ToolGroupService;

View File

@@ -0,0 +1,71 @@
import { PubSubService } from '@ohif/core';
import { ViewportActionCornersLocations } from '@ohif/ui';
import { ReactNode } from 'react';
export type ActionComponentInfo = {
viewportId: string;
id: string;
component: ReactNode;
location: ViewportActionCornersLocations;
indexPriority?: number;
};
class ViewportActionCornersService extends PubSubService {
public static readonly EVENTS = {};
public static readonly LOCATIONS = ViewportActionCornersLocations;
public static REGISTRATION = {
name: 'viewportActionCornersService',
altName: 'ViewportActionCornersService',
create: ({ configuration = {} }) => {
return new ViewportActionCornersService();
},
};
serviceImplementation = {};
public LOCATIONS = ViewportActionCornersService.LOCATIONS;
constructor() {
super(ViewportActionCornersService.EVENTS);
this.serviceImplementation = {};
}
public setServiceImplementation({
getState: getStateImplementation,
addComponent: addComponentImplementation,
addComponents: addComponentsImplementation,
clear: clearComponentsImplementation,
}): void {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (addComponentImplementation) {
this.serviceImplementation._addComponent = addComponentImplementation;
}
if (addComponentsImplementation) {
this.serviceImplementation._addComponents = addComponentsImplementation;
}
if (clearComponentsImplementation) {
this.serviceImplementation._clear = clearComponentsImplementation;
}
}
public getState() {
return this.serviceImplementation._getState();
}
public addComponent(component: ActionComponentInfo) {
this.serviceImplementation._addComponent(component);
}
public addComponents(components: Array<ActionComponentInfo>) {
this.serviceImplementation._addComponents(components);
}
public clear(viewportId: string) {
this.serviceImplementation._clear(viewportId);
}
}
export default ViewportActionCornersService;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import { Types } from '@cornerstonejs/core';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import { DisplaySetOptions, PublicViewportOptions } from './Viewport';
import { Presentations } from '../../types/Presentation';
/**
* Handles cornerstone viewport logic including enabling, disabling, and
* updating the viewport.
*/
export interface IViewportService {
servicesManager: AppTypes.ServicesManager;
hangingProtocolService: unknown;
renderingEngine: unknown;
viewportGridResizeObserver: unknown;
viewportsInfo: unknown;
sceneVolumeInputs: unknown;
viewportDivElements: unknown;
ViewportPropertiesMap: unknown;
volumeUIDs: unknown;
listeners: { [key: string]: {} };
displaySetsNeedRerendering: unknown;
viewportDisplaySets: unknown;
EVENTS: { [key: string]: string };
_broadcastEvent: unknown;
/**
* Adds the HTML element to the viewportService
* @param {*} elementRef
*/
enableViewport(viewportId: string, elementRef: HTMLDivElement): void;
/**
* It retrieves the renderingEngine if it does exist, or creates one otherwise
* @returns {RenderingEngine} rendering engine
*/
getRenderingEngine(): Types.IRenderingEngine;
/**
* It creates a resize observer for the viewport element, and observes
* the element for resizing events
* @param {*} elementRef
*/
resize(isGridResize: boolean): void;
/**
* Removes the viewport from cornerstone, and destroys the rendering engine
*/
destroy(): void;
/**
* Disables the viewport inside the renderingEngine, if no viewport is left
* it destroys the renderingEngine.
* @param viewportId
*/
disableElement(viewportId: string): void;
/**
* Uses the renderingEngine to enable the element for the given viewport index
* and sets the displaySet data to the viewport
* @param {*} displaySet
* @param {*} dataSource
* @returns
*/
setViewportData(
viewportId: string,
viewportData: StackViewportData | VolumeViewportData,
publicViewportOptions: PublicViewportOptions,
publicDisplaySetOptions: DisplaySetOptions[],
presentations?: Presentations
): void;
}

View File

@@ -0,0 +1,361 @@
import {
Types,
Enums,
getEnabledElementByViewportId,
VolumeViewport,
utilities,
} from '@cornerstonejs/core';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import JumpPresets from '../../utils/JumpPresets';
import { SyncGroup } from '../SyncGroupService/SyncGroupService';
export type InitialImageOptions = {
index?: number;
preset?: JumpPresets;
useOnce?: boolean;
};
export type ViewportOptions = {
id?: string;
viewportType: Enums.ViewportType;
toolGroupId: string;
viewportId: string;
// Presentation ID to store/load presentation state from
presentationIds?: AppTypes.PresentationIds;
orientation?: Enums.OrientationAxis;
background?: Types.Point3;
displayArea?: Types.DisplayArea;
syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions;
rotation?: number;
flipHorizontal?: boolean;
viewReference?: Types.ViewReference;
customViewportProps?: Record<string, unknown>;
/*
* Allows drag and drop of display sets not matching viewport options, but
* doesn't show them initially. Displays initially blank if no required match
*/
allowUnmatchedView?: boolean;
};
export type PublicViewportOptions = {
id?: string;
viewportType?: string;
toolGroupId?: string;
presentationIds?: string[];
viewportId?: string;
orientation?: Enums.OrientationAxis;
background?: Types.Point3;
displayArea?: Types.DisplayArea;
syncGroups?: SyncGroup[];
rotation?: number;
flipHorizontal?: boolean;
initialImageOptions?: InitialImageOptions;
customViewportProps?: Record<string, unknown>;
allowUnmatchedView?: boolean;
};
export type DisplaySetSelector = {
id?: string;
options?: PublicDisplaySetOptions;
};
export type PublicDisplaySetOptions = {
/** The display set options can have an id in order to distinguish
* it from other similar items.
*/
id?: string;
voi?: VOI;
voiInverted?: boolean;
blendMode?: string;
slabThickness?: number;
colormap?: string;
displayPreset?: string;
};
export type DisplaySetOptions = {
id?: string;
voi?: VOI;
voiInverted: boolean;
blendMode?: Enums.BlendModes;
slabThickness?: number;
colormap?: { name: string; opacity?: number };
displayPreset?: string;
};
type VOI = {
windowWidth: number;
windowCenter: number;
};
export type DisplaySet = {
displaySetInstanceUID: string;
};
const STACK = 'stack';
const DEFAULT_TOOLGROUP_ID = 'default';
// Return true if the data contains the given display set UID OR the imageId
// if it is a composite object.
const dataContains = ({ data, displaySetUID, imageId, viewport }): boolean => {
if (imageId && data.isCompositeStack && data.imageIds) {
return !!data.imageIds.find(dataId => dataId === imageId);
}
if (imageId && (data.volumeId || viewport instanceof VolumeViewport)) {
const isAcquisition = !!viewport.getCurrentImageId();
if (!isAcquisition) {
return false;
}
const imageURI = utilities.imageIdToURI(imageId);
const hasImageId = viewport.hasImageURI(imageURI);
if (hasImageId) {
return true;
}
}
if (data.displaySetInstanceUID === displaySetUID) {
return true;
}
return false;
};
class ViewportInfo {
private viewportId = '';
private element: HTMLDivElement;
private viewportOptions: ViewportOptions;
private displaySetOptions: Array<DisplaySetOptions>;
private viewportData: StackViewportData | VolumeViewportData;
private renderingEngineId: string;
private viewReference: Types.ViewReference;
constructor(viewportId: string) {
this.viewportId = viewportId;
this.setPublicViewportOptions({});
this.setPublicDisplaySetOptions([{}]);
}
/**
* Return true if the viewport contains the given display set UID,
* OR if it is a composite stack and contains the given imageId
*/
public contains(displaySetUID: string, imageId: string): boolean {
if (!this.viewportData?.data) {
return false;
}
const { viewport } = getEnabledElementByViewportId(this.viewportId) || {};
if (this.viewportData.data.length) {
return !!this.viewportData.data.find(data =>
dataContains({ data, displaySetUID, imageId, viewport })
);
}
return dataContains({
data: this.viewportData.data,
displaySetUID,
imageId,
viewport,
});
}
public destroy = (): void => {
this.element = null;
this.viewportData = null;
this.viewportOptions = null;
this.displaySetOptions = null;
};
public setRenderingEngineId(renderingEngineId: string): void {
this.renderingEngineId = renderingEngineId;
}
public getRenderingEngineId(): string {
return this.renderingEngineId;
}
public setViewportId(viewportId: string): void {
this.viewportId = viewportId;
}
public setElement(element: HTMLDivElement): void {
this.element = element;
}
public setViewportData(viewportData: StackViewportData | VolumeViewportData): void {
this.viewportData = viewportData;
}
public getViewportData(): StackViewportData | VolumeViewportData {
return this.viewportData;
}
public getElement(): HTMLDivElement {
return this.element;
}
public getViewportId(): string {
return this.viewportId;
}
public getViewReference(): Types.ViewReference {
return this.viewportOptions?.viewReference;
}
public setPublicDisplaySetOptions(
publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[]
): Array<DisplaySetOptions> {
// map the displaySetOptions and check if they are undefined then set them to default values
const displaySetOptions = this.mapDisplaySetOptions(publicDisplaySetOptions);
this.setDisplaySetOptions(displaySetOptions);
return this.displaySetOptions;
}
public hasDisplaySet(displaySetInstanceUID: string): boolean {
// Todo: currently this does not work for non image & referenceImage displaySets.
// Since SEG and other derived displaySets are loaded in a different way, and not
// via cornerstoneViewportService
let viewportData = this.getViewportData();
if (
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC ||
viewportData.viewportType === Enums.ViewportType.VOLUME_3D
) {
viewportData = viewportData as VolumeViewportData;
return viewportData.data.some(
({ displaySetInstanceUID: dsUID }) => dsUID === displaySetInstanceUID
);
}
viewportData = viewportData as StackViewportData;
return viewportData.data.displaySetInstanceUID === displaySetInstanceUID;
}
/**
*
* @param viewportOptionsEntry - the base values for the options
* @param viewportTypeDisplaySet - allows overriding the viewport type
*/
public setPublicViewportOptions(
viewportOptionsEntry: PublicViewportOptions,
viewportTypeDisplaySet?: string
): ViewportOptions {
const ohifViewportType = viewportTypeDisplaySet || viewportOptionsEntry.viewportType || STACK;
const { presentationIds } = viewportOptionsEntry;
let { toolGroupId = DEFAULT_TOOLGROUP_ID } = viewportOptionsEntry;
// Just assign the orientation for any viewport type and let the viewport deal with it
const orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
const viewportType = getCornerstoneViewportType(ohifViewportType);
if (!toolGroupId) {
toolGroupId = DEFAULT_TOOLGROUP_ID;
}
this.setViewportOptions({
...viewportOptionsEntry,
viewportId: this.viewportId,
viewportType: viewportType as Enums.ViewportType,
orientation,
toolGroupId,
presentationIds,
});
return this.viewportOptions;
}
public setViewportOptions(viewportOptions: ViewportOptions): void {
this.viewportOptions = viewportOptions;
}
public getViewportOptions(): ViewportOptions {
return this.viewportOptions;
}
public getPresentationIds(): AppTypes.PresentationIds | null {
const { presentationIds } = this.viewportOptions;
return presentationIds;
}
public setDisplaySetOptions(displaySetOptions: Array<DisplaySetOptions>): void {
this.displaySetOptions = displaySetOptions;
}
public getSyncGroups(): SyncGroup[] {
this.viewportOptions.syncGroups ||= [];
return this.viewportOptions.syncGroups;
}
public getDisplaySetOptions(): Array<DisplaySetOptions> {
return this.displaySetOptions;
}
public getViewportType(): Enums.ViewportType {
return this.viewportOptions.viewportType || Enums.ViewportType.STACK;
}
public getToolGroupId(): string {
return this.viewportOptions.toolGroupId;
}
public getBackground(): Types.Point3 {
return this.viewportOptions.background || [0, 0, 0];
}
public getOrientation(): Enums.OrientationAxis {
return this.viewportOptions.orientation;
}
public getDisplayArea(): Types.DisplayArea {
return this.viewportOptions.displayArea;
}
public getInitialImageOptions(): InitialImageOptions {
return this.viewportOptions.initialImageOptions;
}
// Handle incoming public display set options or a display set select
// with a contained options.
private mapDisplaySetOptions(
options: PublicDisplaySetOptions[] | DisplaySetSelector[] = [{}]
): Array<DisplaySetOptions> {
const displaySetOptions: Array<DisplaySetOptions> = [];
options.forEach(item => {
let option = item?.options || item;
if (!option) {
option = {
blendMode: undefined,
slabThickness: undefined,
colormap: undefined,
voi: {},
voiInverted: false,
};
}
const blendMode = getCornerstoneBlendMode(option.blendMode);
displaySetOptions.push({
voi: option.voi,
voiInverted: option.voiInverted,
colormap: option.colormap,
slabThickness: option.slabThickness,
blendMode,
displayPreset: option.displayPreset,
});
});
return displaySetOptions;
}
}
export default ViewportInfo;

View File

@@ -0,0 +1,3 @@
const RENDERING_ENGINE_ID = 'OHIFCornerstoneRenderingEngine';
export { RENDERING_ENGINE_ID };

View File

@@ -0,0 +1,34 @@
const state = {
// The `defaultContext` of an extension's commandsModule
DEFAULT_CONTEXT: 'CORNERSTONE',
enabledElements: {},
};
/**
* Sets the enabled element `dom` reference for an active viewport.
* @param {HTMLElement} dom Active viewport element.
* @return void
*/
const setEnabledElement = (viewportId: string, element: HTMLElement, context?: string): void => {
const targetContext = context || state.DEFAULT_CONTEXT;
state.enabledElements[viewportId] = {
element,
context: targetContext,
};
};
/**
* Grabs the enabled element `dom` reference of an active viewport.
*
* @return {HTMLElement} Active viewport element.
*/
const getEnabledElement = viewportId => {
return state.enabledElements[viewportId];
};
const reset = () => {
state.enabledElements = {};
};
export { setEnabledElement, getEnabledElement, reset };

View File

@@ -0,0 +1,4 @@
export { useLutPresentationStore } from './useLutPresentationStore';
export { usePositionPresentationStore } from './usePositionPresentationStore';
export { useSegmentationPresentationStore } from './useSegmentationPresentationStore';
export { useSynchronizersStore } from './useSynchronizersStore';

View File

@@ -0,0 +1,42 @@
const JOIN_STR = '&';
// The default lut presentation id if none defined
const DEFAULT_STR = 'default';
// This code finds the first unique index to add to the presentation id so that
// two viewports containing the same display set in the same type of viewport
// can have different presentation information. This allows comparison of
// a single display set in two or more viewports, when the user has simply
// dragged and dropped the view in twice. For example, it allows displaying
// bone, brain and soft tissue views of a single display set, and to still
// remember the specific changes to each viewport.
const addUniqueIndex = (
arr,
key,
viewports: AppTypes.ViewportGrid.Viewports,
isUpdatingSameViewport
) => {
arr.push(0);
// If we are updating the viewport, we should not increment the index
if (isUpdatingSameViewport) {
return;
}
// The 128 is just a value that is larger than how many viewports we
// display at once, used as an upper bound on how many unique presentation
// ID's might exist for a single display set at once.
for (let displayInstance = 0; displayInstance < 128; displayInstance++) {
arr[arr.length - 1] = displayInstance;
const testId = arr.join(JOIN_STR);
if (
!Array.from(viewports.values()).find(
viewport => viewport.viewportOptions?.presentationIds?.[key] === testId
)
) {
break;
}
}
};
export { addUniqueIndex, DEFAULT_STR, JOIN_STR };

View File

@@ -0,0 +1,166 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { LutPresentation } from '../types/Presentation';
import { addUniqueIndex, DEFAULT_STR, JOIN_STR } from './presentationUtils';
/**
* Identifier for the LUT Presentation store type.
*/
const PRESENTATION_TYPE_ID = 'lutPresentationId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* Represents the state and actions for managing LUT presentations.
*/
type LutPresentationState = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores LUT presentations indexed by their presentation ID.
*/
lutPresentationStore: Record<string, LutPresentation>;
/**
* Sets the LUT presentation for a given key.
*
* @param key - The key identifying the LUT presentation.
* @param value - The `LutPresentation` to associate with the key.
*/
setLutPresentation: (key: string, value: LutPresentation) => void;
/**
* Clears all LUT presentations from the store.
*/
clearLutPresentationStore: () => void;
/**
* Retrieves the presentation ID based on the provided parameters.
*
* @param id - The presentation ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport in grid
* @param options.viewports - All available viewports in grid
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @returns The presentation ID or undefined.
*/
getPresentationId: (
id: string,
options: {
viewport: AppTypes.ViewportGrid.Viewport;
viewports: AppTypes.ViewportGrid.Viewports;
isUpdatingSameViewport: boolean;
}
) => string | undefined;
};
/**
* Generates a presentation ID for LUT based on the viewport configuration.
*
* @param id - The ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport.
* @param options.viewports - All available viewports.
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @returns The LUT presentation ID or undefined.
*/
const getLutPresentationId = (
id: string,
{
viewport,
viewports,
isUpdatingSameViewport,
}: {
viewport: AppTypes.ViewportGrid.Viewport;
viewports: AppTypes.ViewportGrid.Viewports;
isUpdatingSameViewport: boolean;
}
): string | undefined => {
if (id !== PRESENTATION_TYPE_ID) {
return;
}
const getLutId = (ds): string => {
if (!ds || !ds.options) {
return DEFAULT_STR;
}
if (ds.options.id) {
return ds.options.id;
}
const arr = Object.entries(ds.options).map(([key, val]) => `${key}=${val}`);
if (!arr.length) {
return DEFAULT_STR;
}
return arr.join(JOIN_STR);
};
if (!viewport || !viewport.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
return;
}
const { displaySetOptions, displaySetInstanceUIDs } = viewport;
const lutId = getLutId(displaySetOptions[0]);
const lutPresentationArr = [lutId];
for (const uid of displaySetInstanceUIDs) {
lutPresentationArr.push(uid);
}
addUniqueIndex(lutPresentationArr, PRESENTATION_TYPE_ID, viewports, isUpdatingSameViewport);
return lutPresentationArr.join(JOIN_STR);
};
/**
* Creates the LUT Presentation store.
*
* @param set - The zustand set function.
* @returns The LUT Presentation store state and actions.
*/
const createLutPresentationStore = (set): LutPresentationState => ({
type: PRESENTATION_TYPE_ID,
lutPresentationStore: {},
/**
* Sets the LUT presentation for a given key.
*/
setLutPresentation: (key, value) =>
set(
state => ({
lutPresentationStore: {
...state.lutPresentationStore,
[key]: value,
},
}),
false,
'setLutPresentation'
),
/**
* Clears all LUT presentations from the store.
*/
clearLutPresentationStore: () =>
set({ lutPresentationStore: {} }, false, 'clearLutPresentationStore'),
/**
* Retrieves the presentation ID based on the provided parameters.
*/
getPresentationId: getLutPresentationId,
});
/**
* Zustand store for managing LUT presentations.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useLutPresentationStore = create<LutPresentationState>()(
DEBUG_STORE
? devtools(createLutPresentationStore, { name: 'LutPresentationStore' })
: createLutPresentationStore
);

View File

@@ -0,0 +1,172 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { PositionPresentation } from '../types/Presentation';
import { addUniqueIndex, JOIN_STR } from './presentationUtils';
const PRESENTATION_TYPE_ID = 'positionPresentationId';
const DEBUG_STORE = true;
/**
* Represents the state and actions for managing position presentations.
*/
type PositionPresentationState = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores position presentations indexed by their presentation ID.
*/
positionPresentationStore: Record<string, PositionPresentation>;
/**
* Sets the position presentation for a given key.
*
* @param key - The key identifying the position presentation.
* @param value - The `PositionPresentation` to associate with the key.
*/
setPositionPresentation: (key: string, value: PositionPresentation) => void;
/**
* Clears all position presentations from the store.
*/
clearPositionPresentationStore: () => void;
/**
* Retrieves the presentation ID based on the provided parameters.
*
* @param id - The ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport.
* @param options.viewports - All available viewports.
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @returns The position presentation ID or undefined.
*/
getPresentationId: (
id: string,
options: {
viewport: any;
viewports: any;
isUpdatingSameViewport: boolean;
}
) => string | undefined;
getPositionPresentationId: (
viewport: any,
viewports?: any,
isUpdatingSameViewport?: boolean
) => string | undefined;
};
/**
* Generates a position presentation ID based on the viewport configuration.
*
* @param id - The ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport.
* @param options.viewports - All available viewports.
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @returns The position presentation ID or undefined.
*/
const getPresentationId = (
id: string,
{
viewport,
viewports,
isUpdatingSameViewport,
}: {
viewport: any;
viewports: any;
isUpdatingSameViewport: boolean;
}
): string | undefined => {
if (id !== PRESENTATION_TYPE_ID) {
return;
}
if (!viewport?.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
return;
}
return getPositionPresentationId(viewport, viewports, isUpdatingSameViewport);
};
function getPositionPresentationId(viewport, viewports, isUpdatingSameViewport) {
const { viewportOptions = {}, displaySetInstanceUIDs = [], displaySetOptions = [] } = viewport;
const { id: viewportOptionId, orientation } = viewportOptions;
const positionPresentationArr = [orientation || 'acquisition'];
if (viewportOptionId) {
positionPresentationArr.push(viewportOptionId);
}
if (displaySetOptions?.some(ds => ds.options?.blendMode || ds.options?.displayPreset)) {
positionPresentationArr.push(`custom`);
}
for (const uid of displaySetInstanceUIDs) {
positionPresentationArr.push(uid);
}
if (viewports && viewports.length && isUpdatingSameViewport !== undefined) {
addUniqueIndex(
positionPresentationArr,
PRESENTATION_TYPE_ID,
viewports,
isUpdatingSameViewport
);
} else {
positionPresentationArr.push(0);
}
return positionPresentationArr.join(JOIN_STR);
}
/**
* Creates the Position Presentation store.
*
* @param set - The zustand set function.
* @returns The Position Presentation store state and actions.
*/
const createPositionPresentationStore = set => ({
type: PRESENTATION_TYPE_ID,
positionPresentationStore: {},
/**
* Sets the position presentation for a given key.
*/
setPositionPresentation: (key, value) =>
set(
state => ({
positionPresentationStore: {
...state.positionPresentationStore,
[key]: value,
},
}),
false,
'setPositionPresentation'
),
/**
* Clears all position presentations from the store.
*/
clearPositionPresentationStore: () =>
set({ positionPresentationStore: {} }, false, 'clearPositionPresentationStore'),
/**
* Retrieves the presentation ID based on the provided parameters.
*/
getPresentationId,
getPositionPresentationId: getPositionPresentationId,
});
/**
* Zustand store for managing position presentations.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const usePositionPresentationStore = create<PositionPresentationState>()(
DEBUG_STORE
? devtools(createPositionPresentationStore, { name: 'PositionPresentationStore' })
: createPositionPresentationStore
);

View File

@@ -0,0 +1,256 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { SegmentationPresentation, SegmentationPresentationItem } from '../types/Presentation';
import { JOIN_STR } from './presentationUtils';
import { getViewportOrientationFromImageOrientationPatient } from '../utils/getViewportOrientationFromImageOrientationPatient';
const PRESENTATION_TYPE_ID = 'segmentationPresentationId';
const DEBUG_STORE = false;
/**
* The keys are the presentationId.
*/
type SegmentationPresentationStore = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores segmentation presentations indexed by their presentation ID.
*/
segmentationPresentationStore: Record<string, SegmentationPresentation>;
/**
* Sets the segmentation presentation for a given segmentation ID.
*
* @param presentationId - The presentation ID.
* @param value - The `SegmentationPresentation` to associate with the ID.
*/
setSegmentationPresentation: (presentationId: string, value: SegmentationPresentation) => void;
/**
* Clears all segmentation presentations from the store.
*/
clearSegmentationPresentationStore: () => void;
/**
* Retrieves the presentation ID based on the provided parameters.
*
* @param id - The ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport.
* @param options.viewports - All available viewports.
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @param options.servicesManager - The services manager instance.
* @returns The segmentation presentation ID or undefined.
*/
getPresentationId: (
id: string,
options: {
viewport: AppTypes.ViewportGrid.Viewport;
viewports: AppTypes.ViewportGrid.Viewports;
isUpdatingSameViewport: boolean;
servicesManager: AppTypes.ServicesManager;
}
) => string | undefined;
/**
* Adds a new segmentation presentation state.
*
* @param presentationId - The presentation ID.
* @param segmentationPresentation - The `SegmentationPresentation` to add.
* @param servicesManager - The services manager instance.
*/
addSegmentationPresentationItem: (
presentationId: string,
segmentationPresentationItem: SegmentationPresentationItem
) => void;
/**
* Gets the current segmentation presentation ID.
*
* @param params - Parameters for retrieving the segmentation presentation ID.
* @param params.viewport - The current viewport.
* @param params.servicesManager - The services manager instance.
* @returns The current segmentation presentation ID.
*/
getSegmentationPresentationId: ({
viewport,
servicesManager,
}: {
viewport: AppTypes.ViewportGrid.Viewport;
servicesManager: AppTypes.ServicesManager;
}) => string;
};
/**
* Generates a segmentation presentation ID based on the viewport configuration.
*
* @param id - The ID to check.
* @param options - Configuration options.
* @param options.viewport - The current viewport.
* @param options.viewports - All available viewports.
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
* @param options.servicesManager - The services manager instance.
* @returns The segmentation presentation ID or undefined.
*/
const getPresentationId = (
id: string,
{
viewport,
viewports,
isUpdatingSameViewport,
servicesManager,
}: {
viewport: AppTypes.ViewportGrid.Viewport;
viewports: AppTypes.ViewportGrid.Viewports;
isUpdatingSameViewport: boolean;
servicesManager: AppTypes.ServicesManager;
}
): string | undefined => {
if (id !== PRESENTATION_TYPE_ID) {
return;
}
return _getSegmentationPresentationId({ viewport, servicesManager });
};
/**
* Helper function to generate the segmentation presentation ID.
*
* @param params - Parameters for generating the segmentation presentation ID.
* @param params.viewport - The current viewport.
* @param params.servicesManager - The services manager instance.
* @returns The segmentation presentation ID or undefined.
*/
const _getSegmentationPresentationId = ({
viewport,
servicesManager,
}: {
viewport: AppTypes.ViewportGrid.Viewport;
servicesManager: AppTypes.ServicesManager;
}) => {
if (!viewport?.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
return;
}
const { displaySetInstanceUIDs, viewportOptions } = viewport;
let orientation = viewportOptions.orientation;
if (!orientation) {
// Calculate orientation from the viewport sample image
const displaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
displaySetInstanceUIDs[0]
);
const sampleImage = displaySet.images?.[0];
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
}
const segmentationPresentationArr = [];
segmentationPresentationArr.push(...displaySetInstanceUIDs);
// Uncomment if unique indexing is needed
// addUniqueIndex(
// segmentationPresentationArr,
// 'segmentationPresentationId',
// viewports,
// isUpdatingSameViewport
// );
return segmentationPresentationArr.join(JOIN_STR);
};
/**
* Creates the Segmentation Presentation store.
*
* @param set - The zustand set function.
* @returns The Segmentation Presentation store state and actions.
*/
const createSegmentationPresentationStore = set => ({
type: PRESENTATION_TYPE_ID,
segmentationPresentationStore: {},
/**
* Clears all segmentation presentations from the store.
*/
clearSegmentationPresentationStore: () =>
set({ segmentationPresentationStore: {} }, false, 'clearSegmentationPresentationStore'),
/**
* Adds a new segmentation presentation item to the store.
*
* segmentationPresentationItem: {
* segmentationId: string;
* type: SegmentationRepresentations;
* hydrated: boolean | null;
* config?: unknown;
* }
*/
addSegmentationPresentationItem: (
presentationId: string,
segmentationPresentationItem: SegmentationPresentationItem
) =>
set(
state => ({
segmentationPresentationStore: {
...state.segmentationPresentationStore,
[presentationId]: [
...(state.segmentationPresentationStore[presentationId] || []),
segmentationPresentationItem,
],
},
}),
false,
'addSegmentationPresentationItem'
),
/**
* Sets the segmentation presentation for a given presentation ID. A segmentation
* presentation is an array of SegmentationPresentationItem.
*
* segmentationPresentationItem: {
* segmentationId: string;
* type: SegmentationRepresentations;
* hydrated: boolean | null;
* config?: unknown;
* }
*
* segmentationPresentation: SegmentationPresentationItem[]
*/
setSegmentationPresentation: (presentationId: string, values: SegmentationPresentation) =>
set(
state => ({
segmentationPresentationStore: {
...state.segmentationPresentationStore,
[presentationId]: values,
},
}),
false,
'setSegmentationPresentation'
),
/**
* Retrieves the presentation ID based on the provided parameters.
*/
getPresentationId,
/**
* Retrieves the current segmentation presentation ID.
*/
getSegmentationPresentationId: _getSegmentationPresentationId,
});
/**
* Zustand store for managing segmentation presentations.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useSegmentationPresentationStore = create<SegmentationPresentationStore>()(
DEBUG_STORE
? devtools(createSegmentationPresentationStore, { name: 'Segmentation Presentation Store' })
: createSegmentationPresentationStore
);

View File

@@ -0,0 +1,84 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* Identifier for the synchronizers store type.
*/
const PRESENTATION_TYPE_ID = 'synchronizersStoreId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* Information about a single synchronizer.
*/
type SynchronizerInfo = {
id: string;
type: string;
sourceViewports: Array<{ viewportId: string; renderingEngineId: string }>;
targetViewports: Array<{ viewportId: string; renderingEngineId: string }>;
};
/**
* State shape for the Synchronizers store.
*/
type SynchronizersState = {
/**
* Stores synchronizer information indexed by a unique key.
*/
synchronizersStore: Record<string, SynchronizerInfo[]>;
/**
* Sets the synchronizers for a specific viewport.
*
* @param viewportId - The ID of the viewport.
* @param synchronizers - An array of SynchronizerInfo.
*/
setSynchronizers: (viewportId: string, synchronizers: SynchronizerInfo[]) => void;
/**
* Clears the entire synchronizers store.
*/
clearSynchronizersStore: () => void;
};
/**
* Creates the Synchronizers store.
*
* @param set - The zustand set function.
* @returns The synchronizers store state and actions.
*/
const createSynchronizersStore = (set): SynchronizersState => ({
synchronizersStore: {},
type: PRESENTATION_TYPE_ID,
setSynchronizers: (viewportId: string, synchronizers: SynchronizerInfo[]) => {
set(
state => ({
synchronizersStore: {
...state.synchronizersStore,
[viewportId]: synchronizers,
},
}),
false,
'setSynchronizers'
);
},
clearSynchronizersStore: () => {
set({ synchronizersStore: {} }, false, 'clearSynchronizersStore');
},
});
/**
* Zustand store for managing synchronizers.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useSynchronizersStore = create<SynchronizersState>()(
DEBUG_STORE
? devtools(createSynchronizersStore, { name: 'SynchronizersStore' })
: createSynchronizersStore
);

View File

@@ -0,0 +1,51 @@
import { SynchronizerManager, Synchronizer } from '@cornerstonejs/tools';
import { EVENTS, getRenderingEngine, type Types, utilities } from '@cornerstonejs/core';
const frameViewSyncCallback = (
synchronizerInstance: Synchronizer,
sourceViewport: Types.IViewportId,
targetViewport: Types.IViewportId
) => {
const renderingEngine = getRenderingEngine(targetViewport.renderingEngineId);
if (!renderingEngine) {
throw new Error(`No RenderingEngine for Id: ${targetViewport.renderingEngineId}`);
}
const sViewport = renderingEngine.getViewport(sourceViewport.viewportId) as Types.IStackViewport;
const { viewportIndex: targetViewportIndex } = synchronizerInstance.getOptions(
targetViewport.viewportId
);
const { viewportIndex: sourceViewportIndex } = synchronizerInstance.getOptions(
sourceViewport.viewportId
);
if (targetViewportIndex === undefined || sourceViewportIndex === undefined) {
throw new Error('No viewportIndex provided');
}
const tViewport = renderingEngine.getViewport(targetViewport.viewportId) as Types.IStackViewport;
const sourceSliceIndex = sViewport.getSliceIndex();
const sliceDifference = Number(targetViewportIndex) - Number(sourceViewportIndex);
const targetSliceIndex = sourceSliceIndex + sliceDifference;
if (targetSliceIndex === tViewport.getSliceIndex()) {
return;
}
utilities.jumpToSlice(tViewport.element, {
imageIndex: targetSliceIndex,
});
};
const createFrameViewSynchronizer = (synchronizerName: string): Synchronizer => {
const synchronizer = SynchronizerManager.createSynchronizer(
synchronizerName,
EVENTS.CAMERA_MODIFIED,
frameViewSyncCallback
);
return synchronizer;
};
export { createFrameViewSynchronizer };

View File

@@ -0,0 +1,118 @@
import { LengthTool, utilities } from '@cornerstonejs/tools';
import { callInputDialog } from '@ohif/extension-default';
import getActiveViewportEnabledElement from '../utils/getActiveViewportEnabledElement';
const { calibrateImageSpacing } = utilities;
/**
* Calibration Line tool works almost the same as the
*/
class CalibrationLineTool extends LengthTool {
static toolName = 'CalibrationLine';
_renderingViewport: any;
_lengthToolRenderAnnotation = this.renderAnnotation;
renderAnnotation = (enabledElement, svgDrawingHelper) => {
const { viewport } = enabledElement;
this._renderingViewport = viewport;
return this._lengthToolRenderAnnotation(enabledElement, svgDrawingHelper);
};
_getTextLines(data, targetId) {
const [canvasPoint1, canvasPoint2] = data.handles.points.map(p =>
this._renderingViewport.worldToCanvas(p)
);
// for display, round to 2 decimal points
const lengthPx = Math.round(calculateLength2(canvasPoint1, canvasPoint2) * 100) / 100;
const textLines = [`${lengthPx}px`];
return textLines;
}
}
function calculateLength2(point1, point2) {
const dx = point1[0] - point2[0];
const dy = point1[1] - point2[1];
return Math.sqrt(dx * dx + dy * dy);
}
function calculateLength3(pos1, pos2) {
const dx = pos1[0] - pos2[0];
const dy = pos1[1] - pos2[1];
const dz = pos1[2] - pos2[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
export default CalibrationLineTool;
export function onCompletedCalibrationLine(
servicesManager: AppTypes.ServicesManager,
csToolsEvent
) {
const { uiDialogService, viewportGridService } = servicesManager.services;
// calculate length (mm) with the current Pixel Spacing
const annotationAddedEventDetail = csToolsEvent.detail;
const {
annotation: { metadata, data: annotationData },
} = annotationAddedEventDetail;
const { referencedImageId: imageId } = metadata;
const enabledElement = getActiveViewportEnabledElement(viewportGridService);
const { viewport } = enabledElement;
const length =
Math.round(
calculateLength3(annotationData.handles.points[0], annotationData.handles.points[1]) * 100
) / 100;
const adjustCalibration = newLength => {
const spacingScale = newLength / length;
// trigger resize of the viewport to adjust the world/pixel mapping
calibrateImageSpacing(imageId, viewport.getRenderingEngine(), {
type: 'User',
scale: 1 / spacingScale,
});
};
return new Promise((resolve, reject) => {
if (!uiDialogService) {
reject('UIDialogService is not initiated');
return;
}
callInputDialog(
uiDialogService,
{
text: '',
label: `${length}`,
},
(value, id) => {
if (id === 'save') {
adjustCalibration(Number.parseFloat(value));
resolve(true);
} else {
reject('cancel');
}
},
false,
{
dialogTitle: 'Calibration',
inputLabel: 'Actual Physical distance (mm)',
// the input value must be a number
validateFunc: val => {
try {
const v = Number.parseFloat(val);
return !isNaN(v) && v !== 0.0;
} catch {
return false;
}
},
}
);
});
}

View File

@@ -0,0 +1,269 @@
import { VolumeViewport, metaData, utilities } from '@cornerstonejs/core';
import { IStackViewport, IVolumeViewport } from '@cornerstonejs/core/types';
import { AnnotationDisplayTool, drawing } from '@cornerstonejs/tools';
import { guid, b64toBlob } from '@ohif/core/src/utils';
import OverlayPlaneModuleProvider from './OverlayPlaneModuleProvider';
interface CachedStat {
color: number[]; // [r, g, b, a]
overlays: {
// ...overlayPlaneModule
_id: string;
type: 'G' | 'R'; // G for Graphics, R for ROI
color?: number[]; // Rendered color [r, g, b, a]
dataUrl?: string; // Rendered image in Data URL expression
}[];
}
/**
* Image Overlay Viewer tool is not a traditional tool that requires user interactin.
* But it is used to display Pixel Overlays. And it will provide toggling capability.
*
* The documentation for Overlay Plane Module of DICOM can be found in [C.9.2 of
* Part-3 of DICOM standard](https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.9.2.html)
*
* Image Overlay rendered by this tool can be toggled on and off using
* toolGroup.setToolEnabled() and toolGroup.setToolDisabled()
*/
class ImageOverlayViewerTool extends AnnotationDisplayTool {
static toolName = 'ImageOverlayViewer';
/**
* The overlay plane module provider add method is exposed here to be used
* when updating the overlay for this tool to use for displaying data.
*/
public static addOverlayPlaneModule = OverlayPlaneModuleProvider.add;
constructor(
toolProps = {},
defaultToolProps = {
supportedInteractionTypes: [],
configuration: {
fillColor: [255, 127, 127, 255],
},
}
) {
super(toolProps, defaultToolProps);
}
onSetToolDisabled = (): void => {};
protected getReferencedImageId(viewport: IStackViewport | IVolumeViewport): string {
if (viewport instanceof VolumeViewport) {
return;
}
const targetId = this.getTargetId(viewport);
return targetId.split('imageId:')[1];
}
renderAnnotation = (enabledElement, svgDrawingHelper) => {
const { viewport } = enabledElement;
const imageId = this.getReferencedImageId(viewport);
if (!imageId) {
return;
}
const overlayMetadata = metaData.get('overlayPlaneModule', imageId);
const overlays = overlayMetadata?.overlays;
// no overlays
if (!overlays?.length) {
return;
}
// Fix the x, y positions
overlays.forEach(overlay => {
overlay.x ||= 0;
overlay.y ||= 0;
});
// Will clear cached stat data when the overlay data changes
ImageOverlayViewerTool.addOverlayPlaneModule(imageId, overlayMetadata);
this._getCachedStat(imageId, overlayMetadata, this.configuration.fillColor).then(cachedStat => {
cachedStat.overlays.forEach(overlay => {
this._renderOverlay(enabledElement, svgDrawingHelper, overlay);
});
});
return true;
};
/**
* Render to DOM
*
* @param enabledElement
* @param svgDrawingHelper
* @param overlayData
* @returns
*/
private _renderOverlay(enabledElement, svgDrawingHelper, overlayData) {
const { viewport } = enabledElement;
const imageId = this.getReferencedImageId(viewport);
if (!imageId) {
return;
}
// Decide the rendering position of the overlay image on the current canvas
const { _id, columns: width, rows: height, x, y } = overlayData;
const overlayTopLeftWorldPos = utilities.imageToWorldCoords(imageId, [
x - 1, // Remind that top-left corner's (x, y) is be (1, 1)
y - 1,
]);
const overlayTopLeftOnCanvas = viewport.worldToCanvas(overlayTopLeftWorldPos);
const overlayBottomRightWorldPos = utilities.imageToWorldCoords(imageId, [width, height]);
const overlayBottomRightOnCanvas = viewport.worldToCanvas(overlayBottomRightWorldPos);
// add image to the annotations svg layer
const svgns = 'http://www.w3.org/2000/svg';
const svgNodeHash = `image-overlay-${_id}`;
const existingImageElement = svgDrawingHelper.getSvgNode(svgNodeHash);
const attributes = {
'data-id': svgNodeHash,
width: overlayBottomRightOnCanvas[0] - overlayTopLeftOnCanvas[0],
height: overlayBottomRightOnCanvas[1] - overlayTopLeftOnCanvas[1],
x: overlayTopLeftOnCanvas[0],
y: overlayTopLeftOnCanvas[1],
href: overlayData.dataUrl,
};
if (
isNaN(attributes.x) ||
isNaN(attributes.y) ||
isNaN(attributes.width) ||
isNaN(attributes.height)
) {
console.warn('Invalid rendering attribute for image overlay', attributes['data-id']);
return false;
}
if (existingImageElement) {
drawing.setAttributesIfNecessary(attributes, existingImageElement);
svgDrawingHelper.setNodeTouched(svgNodeHash);
} else {
const newImageElement = document.createElementNS(svgns, 'image');
drawing.setNewAttributesIfValid(attributes, newImageElement);
svgDrawingHelper.appendNode(newImageElement, svgNodeHash);
}
return true;
}
private async _getCachedStat(
imageId: string,
overlayMetadata,
color: number[]
): Promise<CachedStat> {
const missingOverlay = overlayMetadata.overlays.filter(
overlay => overlay.pixelData && !overlay.dataUrl
);
if (missingOverlay.length === 0) {
return overlayMetadata;
}
const overlays = await Promise.all(
overlayMetadata.overlays
.filter(overlay => overlay.pixelData)
.map(async (overlay, idx) => {
let pixelData = null;
if (overlay.pixelData.Value) {
pixelData = overlay.pixelData.Value;
} else if (overlay.pixelData instanceof Array) {
pixelData = overlay.pixelData[0];
} else if (overlay.pixelData.retrieveBulkData) {
pixelData = await overlay.pixelData.retrieveBulkData();
} else if (overlay.pixelData.InlineBinary) {
const blob = b64toBlob(overlay.pixelData.InlineBinary);
const arrayBuffer = await blob.arrayBuffer();
pixelData = arrayBuffer;
}
if (!pixelData) {
return;
}
const dataUrl = this._renderOverlayToDataUrl(
{ width: overlay.columns, height: overlay.rows },
overlay.color || color,
pixelData
);
return {
...overlay,
_id: guid(),
dataUrl, // this will be a data url expression of the rendered image
color,
};
})
);
overlayMetadata.overlays = overlays;
return overlayMetadata;
}
/**
* compare two RGBA expression of colors.
*
* @param color1
* @param color2
* @returns
*/
private _isSameColor(color1: number[], color2: number[]) {
return (
color1 &&
color2 &&
color1[0] === color2[0] &&
color1[1] === color2[1] &&
color1[2] === color2[2] &&
color1[3] === color2[3]
);
}
/**
* pixelData of overlayPlane module is an array of bits corresponding
* to each of the underlying pixels of the image.
* Let's create pixel data from bit array of overlay data
*
* @param pixelDataRaw
* @param color
* @returns
*/
private _renderOverlayToDataUrl({ width, height }, color, pixelDataRaw) {
const pixelDataView = new DataView(pixelDataRaw);
const totalBits = width * height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height); // make it transparent
ctx.globalCompositeOperation = 'copy';
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0, bitIdx = 0, byteIdx = 0; i < totalBits; i++) {
if (pixelDataView.getUint8(byteIdx) & (1 << bitIdx)) {
data[i * 4] = color[0];
data[i * 4 + 1] = color[1];
data[i * 4 + 2] = color[2];
data[i * 4 + 3] = color[3];
}
// next bit, byte
if (bitIdx >= 7) {
bitIdx = 0;
byteIdx++;
} else {
bitIdx++;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
}
export default ImageOverlayViewerTool;

View File

@@ -0,0 +1,40 @@
import { metaData } from '@cornerstonejs/core';
const _cachedOverlayMetadata: Map<string, any[]> = new Map();
/**
* Image Overlay Viewer tool is not a traditional tool that requires user interactin.
* But it is used to display Pixel Overlays. And it will provide toggling capability.
*
* The documentation for Overlay Plane Module of DICOM can be found in [C.9.2 of
* Part-3 of DICOM standard](https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.9.2.html)
*
* Image Overlay rendered by this tool can be toggled on and off using
* toolGroup.setToolEnabled() and toolGroup.setToolDisabled()
*/
const OverlayPlaneModuleProvider = {
/** Adds the metadata for overlayPlaneModule */
add: (imageId, metadata) => {
if (_cachedOverlayMetadata.get(imageId) === metadata) {
// This is a no-op here as the tool re-caches the data
return;
}
_cachedOverlayMetadata.set(imageId, metadata);
},
/** Standard getter for metadata */
get: (type: string, query: string | string[]) => {
if (Array.isArray(query)) {
return;
}
if (type !== 'overlayPlaneModule') {
return;
}
return _cachedOverlayMetadata.get(query);
},
};
// Needs to be higher priority than default provider
metaData.addProvider(OverlayPlaneModuleProvider.get, 10_000);
export default OverlayPlaneModuleProvider;

View File

@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-namespace */
import CornerstoneCacheServiceType from '../services/CornerstoneCacheService';
import CornerstoneViewportServiceType from '../services/ViewportService/CornerstoneViewportService';
import SegmentationServiceType from '../services/SegmentationService';
import SyncGroupServiceType from '../services/SyncGroupService';
import ToolGroupServiceType from '../services/ToolGroupService';
import ViewportActionCornersServiceType from '../services/ViewportActionCornersService/ViewportActionCornersService';
import ColorbarServiceType from '../services/ColorbarService';
import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
import type {
SegmentRepresentation as SegmentRep,
SegmentationData as SegData,
SegmentationRepresentation as SegRep,
SegmentationInfo as SegInfo,
} from '../services/SegmentationService/SegmentationService';
declare global {
namespace AppTypes {
export type CornerstoneCacheService = CornerstoneCacheServiceType;
export type CornerstoneViewportService = CornerstoneViewportServiceType;
export type SegmentationService = SegmentationServiceType;
export type SyncGroupService = SyncGroupServiceType;
export type ToolGroupService = ToolGroupServiceType;
export type ViewportActionCornersService = ViewportActionCornersServiceType;
export type ColorbarService = ColorbarServiceType;
export interface Services {
cornerstoneViewportService?: CornerstoneViewportServiceType;
toolGroupService?: ToolGroupServiceType;
syncGroupService?: SyncGroupServiceType;
segmentationService?: SegmentationServiceType;
cornerstoneCacheService?: CornerstoneCacheServiceType;
viewportActionCornersService?: ViewportActionCornersServiceType;
colorbarService?: ColorbarServiceType;
}
export namespace Segmentation {
export type SegmentRepresentation = SegmentRep;
export type SegmentationData = SegData;
export type SegmentationRepresentation = SegRep;
export type SegmentationInfo = SegInfo;
}
export interface PresentationIds {
lutPresentationId: string;
positionPresentationId: string;
segmentationPresentationId: string;
}
export interface Test {
services?: Services;
cornerstone?: typeof cornerstone;
cornerstoneTools?: typeof cornerstoneTools;
}
}
}

Some files were not shown because too many files have changed in this diff Show More