diff --git a/.github/workflows/deploy_ghpages.yml b/.github/workflows/deploy_ghpages.yml index ba1cbdf78e6..d9a36ff5f31 100644 --- a/.github/workflows/deploy_ghpages.yml +++ b/.github/workflows/deploy_ghpages.yml @@ -2,7 +2,7 @@ name: Deploy viewer to github pages on: push: - branches: [ "gradienthealth/Stack-Segmentation" ] + branches: [ "gradienthealth/segmentation_mode_sheet_integration" ] #pull_request: #branches: [ "main" ] @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v3 with: repository: gradienthealth/cornerstone3D-beta - ref: gradienthealth/stack-segmentation-support-with-zip-image-loader + ref: gradienthealth/segmentation_mode_sheet_integration path: ./cornerstone3D - name: Build cornerstone3D @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v3 with: repository: gradienthealth/GradientExtensionsAndModes - ref: gradienthealth/Segmentation-with-DicomJSON + ref: gradienthealth/segmentation_mode_sheet_integration path: ./GradientExtensionsAndModes #- name: Build GradientExtensionsAndModes diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index 98d8a241fd3..1d41748fcfc 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -19,6 +19,7 @@ import { getTargetViewport, } from './utils/hydrationUtils'; import generateLabelmaps2DFromImageIdMap from './utils/generateLabelmaps2DFromImageIdMap'; +import getSegmentLabel from './utils/getSegmentLabel'; const { datasetToBlob } = dcmjs.data; @@ -100,7 +101,7 @@ const commandsModule = ({ toolGroupId, segmentIndex: 1, properties: { - label: 'Segment 1', + label: getSegmentLabel(segmentationService.getSegmentation(segmentationId)), }, }); @@ -318,28 +319,45 @@ const commandsModule = ({ * @returns {Object|void} Returns the naturalized report if successfully stored, * otherwise throws an error. */ - storeSegmentation: async ({ segmentationId, dataSource }) => { - const promptResult = await createReportDialogPrompt(uiDialogService, { - extensionManager, - }); - - if (promptResult.action !== 1 && promptResult.value) { - return; - } - + storeSegmentation: async ({ segmentationId, dataSource, skipLabelDialog = false }) => { const segmentation = segmentationService.getSegmentation(segmentationId); if (!segmentation) { throw new Error('No segmentation found'); } + const { label, displaySetInstanceUID } = segmentation; + + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + const shouldOverWrite = displaySet && displaySet.Modality === 'SEG'; + + let promptResult: { action?: number; value?: string } = {}; + + if (!(skipLabelDialog || shouldOverWrite)) { + promptResult = await createReportDialogPrompt(uiDialogService, { + extensionManager, + }); + + if (promptResult.action !== 1 && !promptResult.value) { + return; + } + } - const { label } = segmentation; const SeriesDescription = promptResult.value || label || 'Research Derived Series'; + segmentation.label = SeriesDescription; const generatedData = actions.generateSegmentation({ segmentationId, options: { SeriesDescription, + // Use SeriesInstanceUID, SOPInstanceUID, SeriesNumber, Manufacturer and SeriesDate + // if displaySet of the segmentation already exists. + // Study level and patient metadata will be used automatically. + ...(shouldOverWrite && { + SeriesInstanceUID: displaySet.SeriesInstanceUID, + SOPInstanceUID: displaySet.instances[0].SOPInstanceUID, + SeriesNumber: displaySet.SeriesNumber, + Manufacturer: displaySet.instances[0].Manufacturer, + }), }, }); @@ -358,8 +376,6 @@ const commandsModule = ({ // add the information for where we stored it to the instance as well naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot; - DicomMetadataStore.addInstances([naturalizedReport], true); - return naturalizedReport; }, /** diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js index 8a3d095e9ed..a0043fefcda 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -192,6 +192,7 @@ async function _loadSegments({ extensionManager, servicesManager, segDisplaySet, } }); + /* Skip the warning message as it is annoying on auto segmentations loading. if (!usedRecommendedDisplayCIELabValue) { // Display a notification about the non-utilization of RecommendedDisplayCIELabValue uiNotificationService.show({ @@ -201,7 +202,7 @@ async function _loadSegments({ extensionManager, servicesManager, segDisplaySet, type: 'warning', duration: 5000, }); - } + }*/ Object.assign(segDisplaySet, results); } diff --git a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx index 1b4c3a2d27d..141bb8f5935 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -1,11 +1,25 @@ import { createReportAsync } from '@ohif/extension-default'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useReducer } from 'react'; import PropTypes from 'prop-types'; import { SegmentationGroupTable, LegacyButtonGroup, LegacyButton } from '@ohif/ui'; import callInputDialog from './callInputDialog'; import callColorPickerDialog from './colorPickerDialog'; import { useTranslation } from 'react-i18next'; +import getSegmentLabel from '../utils/getSegmentLabel'; + +const savedStatusReducer = (state, action) => { + return { + ...state, + ...action.payload, + }; +}; + +const SAVED_STATUS_ICON = { + SAVED: 'notifications-success', + MODIFIED: 'notifications-warning', + ERROR: 'notifications-error', +}; export default function PanelSegmentation({ servicesManager, @@ -13,7 +27,14 @@ export default function PanelSegmentation({ extensionManager, configuration, }) { - const { segmentationService, viewportGridService, uiDialogService } = servicesManager.services; + const { + segmentationService, + viewportGridService, + uiDialogService, + displaySetService, + userAuthenticationService, + CropDisplayAreaService, + } = servicesManager.services; const { t } = useTranslation('PanelSegmentation'); @@ -23,6 +44,7 @@ export default function PanelSegmentation({ ); const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations()); + const [savedStatusStates, dispatch] = useReducer(savedStatusReducer, {}); useEffect(() => { // ~~ Subscription @@ -47,7 +69,78 @@ export default function PanelSegmentation({ }; }, []); + useEffect(() => { + let changedSegmentations: any[] = [], + timerId; + const timoutInSeconds = 5; + + const { unsubscribe } = segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED, + ({ segmentation }) => { + clearTimeout(timerId); + dispatch({ payload: { [segmentation.id]: SAVED_STATUS_ICON.MODIFIED } }); + + if ( + !changedSegmentations.find( + changedSegmentation => changedSegmentation.id === segmentation.id + ) + ) { + changedSegmentations.push(segmentation); + } + + timerId = setTimeout(() => { + const datasources = extensionManager.getActiveDataSource(); + + const promises = changedSegmentations.map(segmentation => + createReportAsync({ + servicesManager: servicesManager, + getReport: () => + commandsManager.runCommand('storeSegmentation', { + segmentationId: segmentation.id, + dataSource: datasources[0], + skipLabelDialog: true, + }), + reportType: 'Segmentation', + showLoadingModal: false, + throwErrors: true, + }) + ); + + Promise.allSettled(promises).then(results => { + const payload = results.reduce((acc, result, index) => { + if (result.value) { + changedSegmentations[index].displaySetInstanceUID = result.value[0]; + displaySetService.getDisplaySetByUID(result.value[0])?.getReferenceDisplaySet(); + } + + return { + ...acc, + [changedSegmentations[index].id]: + result.status === 'fulfilled' ? SAVED_STATUS_ICON.SAVED : SAVED_STATUS_ICON.ERROR, + }; + }, {}); + + dispatch({ payload }); + + const savedSegmentations = Object.keys(payload).filter( + id => payload[id] === SAVED_STATUS_ICON.SAVED + ); + changedSegmentations = changedSegmentations.filter( + cs => !savedSegmentations.includes(cs.id) + ); + }); + }, timoutInSeconds * 1000); + } + ); + + return () => { + unsubscribe(); + }; + }, []); + const setSegmentationActive = segmentationId => { + setReferencedDisplaySet(segmentationId); + const isSegmentationActive = segmentations.find(seg => seg.id === segmentationId)?.isActive; if (isSegmentationActive) { @@ -57,6 +150,31 @@ export default function PanelSegmentation({ segmentationService.setActiveSegmentationForToolGroup(segmentationId); }; + // Set referenced displaySet of the segmentation to the viewport + // if it is not displayed in any of the viewports. + const setReferencedDisplaySet = segmentationId => { + const segDisplayset = displaySetService.getDisplaySetByUID(segmentationId); + if (!segDisplayset) { + return; + } + + const referencedDisplaySetInstanceUID = segDisplayset.referencedDisplaySetInstanceUID; + const { viewports, activeViewportId } = viewportGridService.getState(); + let referencedImageLoaded = false; + viewports.forEach(viewport => { + if (viewport.displaySetInstanceUIDs.includes(referencedDisplaySetInstanceUID)) { + referencedImageLoaded = true; + } + }); + + if (!referencedImageLoaded) { + viewportGridService.setDisplaySetsForViewport({ + viewportId: activeViewportId, + displaySetInstanceUIDs: [referencedDisplaySetInstanceUID], + }); + } + }; + const getToolGroupIds = segmentationId => { const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId); @@ -68,6 +186,7 @@ export default function PanelSegmentation({ }; const onSegmentationClick = (segmentationId: string) => { + setReferencedDisplaySet(segmentationId); segmentationService.setActiveSegmentationForToolGroup(segmentationId); }; @@ -78,10 +197,12 @@ export default function PanelSegmentation({ const onSegmentAdd = segmentationId => { setSegmentationActive(segmentationId); - segmentationService.addSegment(segmentationId); + const label = getSegmentLabel(segmentations.find(seg => seg.id === segmentationId)); + segmentationService.addSegment(segmentationId, { properties: { label } }); }; const onSegmentClick = (segmentationId, segmentIndex) => { + setReferencedDisplaySet(segmentationId); segmentationService.setActiveSegment(segmentationId, segmentIndex); const toolGroupIds = getToolGroupIds(segmentationId); @@ -210,16 +331,25 @@ export default function PanelSegmentation({ const storeSegmentation = async segmentationId => { setSegmentationActive(segmentationId); const datasources = extensionManager.getActiveDataSource(); + let displaySetInstanceUIDs; + + try { + displaySetInstanceUIDs = await createReportAsync({ + servicesManager, + getReport: () => + commandsManager.runCommand('storeSegmentation', { + segmentationId, + dataSource: datasources[0], + }), + reportType: 'Segmentation', + throwErrors: true, + }); - const displaySetInstanceUIDs = await createReportAsync({ - servicesManager, - getReport: () => - commandsManager.runCommand('storeSegmentation', { - segmentationId, - dataSource: datasources[0], - }), - reportType: 'Segmentation', - }); + dispatch({ payload: { [segmentationId]: SAVED_STATUS_ICON.SAVED } }); + } catch (error) { + console.warn(error.message); + dispatch({ payload: { [segmentationId]: SAVED_STATUS_ICON.ERROR } }); + } // Show the exported report in the active viewport as read only (similar to SR) if (displaySetInstanceUIDs) { @@ -242,13 +372,18 @@ export default function PanelSegmentation({ }); }; + const params = new URLSearchParams(window.location.search); + const showAddSegmentation = params.get('disableAddSegmentation') !== 'true'; + return ( <>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value) } + CropDisplayAreaService={CropDisplayAreaService} />
diff --git a/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx b/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx index f2d580cff46..e25e3045e42 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState, useReducer } from 'react'; import { AdvancedToolbox, InputDoubleRange, useViewportGrid } from '@ohif/ui'; import { Types } from '@ohif/extension-cornerstone'; import { utilities } from '@cornerstonejs/tools'; +import { EVENTS, eventTarget } from '@cornerstonejs/core'; const { segmentation: segmentationUtils } = utilities; @@ -24,11 +25,11 @@ const ACTIONS = { const initialState = { Brush: { - brushSize: 15, + brushSize: 2, mode: 'CircularBrush', // Can be 'CircularBrush' or 'SphereBrush' }, Eraser: { - brushSize: 15, + brushSize: 2, mode: 'CircularEraser', // Can be 'CircularEraser' or 'SphereEraser' }, Shapes: { @@ -69,6 +70,7 @@ function SegmentationToolbox({ servicesManager, extensionManager }) { const [toolsEnabled, setToolsEnabled] = useState(false); const [state, dispatch] = useReducer(toolboxReducer, initialState); + const [brushProperties, setBrushProperties] = useState({ min: 2, max: 3, step: 0.01 }); const updateActiveTool = useCallback(() => { if (!viewports?.size || activeViewportId === undefined) { @@ -105,6 +107,64 @@ function SegmentationToolbox({ servicesManager, extensionManager }) { [toolbarService, dispatch] ); + const setBrushSizesFromParams = () => { + const params = new URLSearchParams(window.location.search); + const toolCategories = ['Brush', 'Eraser']; + const defaultBrushSizeInMm = +params.get('defaultBrushSize') || 2; + let minBrushSizeInMm = +params.get('minBrushSize') || 2; + let maxBrushSizeInMm = +params.get('maxBrushSize') || 3; + + const highestPixelSpacing = getPixelToMmConversionFactor(servicesManager); + const lowestBrushRadius = highestPixelSpacing / 2; + + if (minBrushSizeInMm < lowestBrushRadius) { + minBrushSizeInMm = lowestBrushRadius; + } + if (maxBrushSizeInMm < lowestBrushRadius) { + maxBrushSizeInMm = highestPixelSpacing; + } + + setBrushProperties({ + min: +minBrushSizeInMm.toFixed(2), + max: +maxBrushSizeInMm.toFixed(2), + step: +((maxBrushSizeInMm - minBrushSizeInMm) / 100).toFixed(2), + }); + toolCategories.forEach(toolCategory => { + onBrushSizeChange(defaultBrushSizeInMm, toolCategory); + }); + }; + + useEffect(() => { + if (getPixelToMmConversionFactor(servicesManager)) { + setBrushSizesFromParams(); + return; + } + + const elementEnabledHandler = evt => { + const setDefaultBrushSize = () => { + setBrushSizesFromParams(); + + evt.detail.element.removeEventListener( + EVENTS.VOLUME_VIEWPORT_NEW_VOLUME, + setDefaultBrushSize + ); + eventTarget.removeEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, setDefaultBrushSize); + }; + + evt.detail.element.addEventListener(EVENTS.VOLUME_VIEWPORT_NEW_VOLUME, setDefaultBrushSize); + eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, setDefaultBrushSize); + eventTarget.removeEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler); + }; + + const viewportElement = getActiveViewportElement(servicesManager, extensionManager); + if (viewportElement) { + elementEnabledHandler({ detail: { element: viewportElement } }); + return; + } + + eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler); + }, []); + /** * sets the tools enabled IF there are segmentations */ @@ -249,10 +309,10 @@ function SegmentationToolbox({ servicesManager, extensionManager }) { name: 'Radius (mm)', id: 'brush-radius', type: 'range', - min: 0.5, - max: 99.5, + min: brushProperties.min, + max: brushProperties.max, value: state.Brush.brushSize, - step: 0.5, + step: brushProperties.step, onChange: value => onBrushSizeChange(value, 'Brush'), }, { @@ -281,10 +341,10 @@ function SegmentationToolbox({ servicesManager, extensionManager }) { name: 'Radius (mm)', type: 'range', id: 'eraser-radius', - min: 0.5, - max: 99.5, + min: brushProperties.min, + max: brushProperties.max, value: state.Eraser.brushSize, - step: 0.5, + step: brushProperties.step, onChange: value => onBrushSizeChange(value, 'Eraser'), }, { @@ -402,4 +462,30 @@ function _getToolNamesFromCategory(category) { return toolNames; } +function getPixelToMmConversionFactor(servicesManager) { + const { viewportGridService, cornerstoneViewportService } = servicesManager.services; + const { activeViewportId } = viewportGridService.getState(); + const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId); + const imageData = viewport?.getImageData(); + + if (!imageData) { + return; + } + + const { spacing } = imageData; + return Math.max(spacing[0], spacing[1]); +} + +function getActiveViewportElement(servicesManager, extensionManager) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' + ); + const { getEnabledElement } = utilityModule.exports; + const { viewportGridService } = servicesManager.services; + + const { activeViewportId } = viewportGridService.getState(); + const { element } = getEnabledElement(activeViewportId) || {}; + return element; +} + export default SegmentationToolbox; diff --git a/extensions/cornerstone-dicom-seg/src/utils/getSegmentLabel.ts b/extensions/cornerstone-dicom-seg/src/utils/getSegmentLabel.ts new file mode 100644 index 00000000000..7215361b598 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/utils/getSegmentLabel.ts @@ -0,0 +1,8 @@ +const getSegmentLabel = (segmentation): string => { + const segmentationName = segmentation.label.includes('Vessel') ? 'Vessel' : 'Segment'; + const segmentCount = segmentation.segments.filter(segment => segment).length; + + return segmentationName + ' ' + (segmentCount + 1); +}; + +export default getSegmentLabel; diff --git a/extensions/cornerstone/jest.config.js b/extensions/cornerstone/jest.config.js index 2978b062ed1..9e0ecb3e4ec 100644 --- a/extensions/cornerstone/jest.config.js +++ b/extensions/cornerstone/jest.config.js @@ -5,6 +5,11 @@ module.exports = { ...base, name: pkg.name, displayName: pkg.name, + moduleNameMapper: { + ...base.moduleNameMapper, + '^@ohif/(.*)$': '/../../platform/$1/src', + '^@cornerstonejs/tools(.*)$': '/../../node_modules/@cornerstonejs/tools', + }, // rootDir: "../.." // testMatch: [ // //`/platform/${pack.name}/**/*.spec.js` diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 52645938125..44cc430a2ab 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -5,6 +5,7 @@ import { utilities as csUtils, Types as CoreTypes, BaseVolumeViewport, + metaData, } from '@cornerstonejs/core'; import { ToolGroupManager, @@ -21,6 +22,7 @@ import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; import { CornerstoneServices } from './types'; +import { getImageFlips } from './utils/getImageFlips'; function commandsModule({ servicesManager, @@ -35,6 +37,7 @@ function commandsModule({ cornerstoneViewportService, uiNotificationService, measurementService, + customizationService, } = servicesManager.services as CornerstoneServices; const { measurementServiceSource } = this; @@ -359,6 +362,19 @@ function commandsModule({ ], }); }, + recordSetToolActive: ({ toolName }) => { + toolbarService.recordInteraction({ + interactionType: 'tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName, + }, + }, + ], + }); + }, showDownloadViewportModal: () => { const { activeViewportId } = viewportGridService.getState(); @@ -470,6 +486,16 @@ function commandsModule({ viewport.resetProperties?.(); viewport.resetCamera(); + const { criteria: isOrientationCorrectionNeeded } = customizationService.get( + 'orientationCorrectionCriterion' + ); + const instance = metaData.get('instance', viewport.getCurrentImageId()); + + if ((isOrientationCorrectionNeeded as (input) => boolean)?.(instance)) { + const { hFlip, vFlip } = getImageFlips(instance); + (hFlip || vFlip) && viewport.setCamera({ flipHorizontal: hFlip, flipVertical: vFlip }); + } + viewport.render(); }, scaleViewport: ({ direction }) => { @@ -653,6 +679,9 @@ function commandsModule({ setToolActive: { commandFn: actions.setToolActive, }, + recordSetToolActive: { + commandFn: actions.recordSetToolActive, + }, rotateViewportCW: { commandFn: actions.rotateViewport, options: { rotation: 90 }, diff --git a/extensions/cornerstone/src/getCustomizationModule.ts b/extensions/cornerstone/src/getCustomizationModule.ts index acb06424462..9d22282e261 100644 --- a/extensions/cornerstone/src/getCustomizationModule.ts +++ b/extensions/cornerstone/src/getCustomizationModule.ts @@ -1,6 +1,7 @@ import { Enums } from '@cornerstonejs/tools'; import { toolNames } from './initCornerstoneTools'; import DicomUpload from './components/DicomUpload/DicomUpload'; +import isOrientationCorrectionNeeded from './utils/isOrientationCorrectionNeeded'; const tools = { active: [ @@ -37,6 +38,8 @@ function getCustomizationModule() { id: 'cornerstone.overlayViewportTools', tools, }, + // TODO: Move this customization to MG specific mode when introduced in OHIF. + { id: 'orientationCorrectionCriterion', criteria: isOrientationCorrectionNeeded }, ], }, ]; diff --git a/extensions/cornerstone/src/hps/mpr.ts b/extensions/cornerstone/src/hps/mpr.ts index 4c625c73172..a26c509e445 100644 --- a/extensions/cornerstone/src/hps/mpr.ts +++ b/extensions/cornerstone/src/hps/mpr.ts @@ -8,7 +8,7 @@ export const mpr: Types.HangingProtocol.Protocol = { modifiedDate: '2023-08-15', availableTo: {}, editableBy: {}, - // Unknown number of priors referenced - so just match any study + // Unknown number of priors referenced - so just match any study. numberOfPriorsReferenced: 0, protocolMatchingRules: [], imageLoadStrategy: 'nth', diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index ac53c8f0f27..531e979f67a 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -30,6 +30,7 @@ import * as csWADOImageLoader from './initWADOImageLoader.js'; import { measurementMappingUtils } from './utils/measurementServiceMappings'; import type { PublicViewportOptions } from './services/ViewportService/Viewport'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; +import { getImageFlips } from './utils/getImageFlips'; const Component = React.lazy(() => { return import(/* webpackPrefetch: true */ './Viewport/OHIFCornerstoneViewport'); @@ -116,6 +117,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, getEnabledElement, dicomLoaderService, + getImageFlips, }, }, { diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 2b9707c5b92..0fd41b11d69 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -471,11 +471,15 @@ class SegmentationService extends PubSubService { // if first segmentation, we can use the default colorLUT, otherwise // we need to generate a new one and use a new colorLUT - const colorLUTIndex = 0; + let colorLUTIndex = 0; + const newColorLUT = this.generateNewColorLUT(); if (Object.keys(this.segmentations).length !== 0) { - const newColorLUT = this.generateNewColorLUT(); - const colorLUTIndex = this.getNextColorLUTIndex(); + colorLUTIndex = this.getNextColorLUTIndex(); cstSegmentation.config.color.addColorLUT(newColorLUT, colorLUTIndex); + } else if (!cstSegmentation.state.getColorLUT(0)) { + // If all loaded segmentations are removed and a new one is adding there may + // not be a colorLUT for index 0 as all of them should have been removed. + cstSegmentation.config.color.addColorLUT(newColorLUT, 0); } this.segmentations[segmentationId] = { @@ -870,15 +874,9 @@ class SegmentationService extends PubSubService { for (const [index, centroid] of centroids) { const count = centroid.count; const normalizedCentroid = { - x: centroid.x / count, - y: centroid.y / count, - z: centroid.z / count, + image: [centroid.x / count, centroid.y / count, centroid.z / count], }; - normalizedCentroid.world = imageData.indexToWorld([ - normalizedCentroid.x, - normalizedCentroid.y, - normalizedCentroid.z, - ]); + normalizedCentroid.world = imageData.indexToWorld(normalizedCentroid.image); result.set(index, normalizedCentroid); } @@ -2110,6 +2108,16 @@ class SegmentationService extends PubSubService { if (removeFromCache && cache.getVolumeLoadObject(segmentationId)) { cache.removeVolumeLoadObject(segmentationId); } + + const segmentation = this.getSegmentation(segmentationId); + const segmentationImageMap = + segmentation.representationData[segmentation.type].imageIdReferenceMap; + if (removeFromCache && segmentationImageMap) { + segmentationImageMap.forEach( + segImageId => + cache.getVolumeLoadObject(segImageId) && cache.removeImageLoadObject(segImageId) + ); + } } private _updateCornerstoneSegmentations({ segmentationId, notYetUpdatedAtSource }) { diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 1180d8036f5..4882ca743cd 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -10,8 +10,9 @@ import { VolumeViewport3D, cache, Enums as csEnums, + metaData, + eventTarget, } from '@cornerstonejs/core'; - import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools'; import { IViewportService } from './IViewportService'; import { RENDERING_ENGINE_ID } from './constants'; @@ -20,6 +21,7 @@ import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCa import { Presentation, Presentations } from '../../types/Presentation'; import JumpPresets from '../../utils/JumpPresets'; +import { getImageFlips } from '../../utils/getImageFlips'; const EVENTS = { VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged', @@ -325,6 +327,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi viewportInfo: ViewportInfo, presentations: Presentations ): Promise { + const { segmentationService, displaySetService, customizationService } = + this.servicesManager.services; const displaySetOptions = viewportInfo.getDisplaySetOptions(); const { imageIds, initialImageIndex, displaySetInstanceUID } = viewportData.data; @@ -354,24 +358,48 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi } } - const segmentations = this.servicesManager.services.segmentationService.getSegmentations(false); + const { criteria: isOrientationCorrectionNeeded } = customizationService.get( + 'orientationCorrectionCriterion' + ); + const instance = metaData.get('instance', imageIds[initialImageIndexToUse]); + + let hFlip = false, + vFlip = false; + if ((isOrientationCorrectionNeeded as (input) => boolean)?.(instance)) { + ({ hFlip, vFlip } = getImageFlips(instance)); + } + + const segmentations = segmentationService.getSegmentations(false); const toolgroupId = viewportInfo.getToolGroupId(); for (const segmentation of segmentations) { const toolGroupSegmentationRepresentations = - this.servicesManager.services.segmentationService.getSegmentationRepresentationsForToolGroup( - toolgroupId - ) || []; + segmentationService.getSegmentationRepresentationsForToolGroup(toolgroupId) || []; const isSegmentationInToolGroup = toolGroupSegmentationRepresentations.find( representation => representation.segmentationId === segmentation.id ); + const callback = evt => { + if (viewport.id !== evt.detail.viewportId) { + return; + } + + eventTarget.removeEventListener(csEnums.Events.STACK_VIEWPORT_IMAGES_ADDED, callback); + + const camera = presentations.positionPresentation?.camera; + if (isSegmentationInToolGroup && camera) { + viewport.setCamera(camera); + } else { + (hFlip || vFlip) && viewport.setCamera({ flipHorizontal: hFlip, flipVertical: vFlip }); + } + }; + + eventTarget.addEventListener(csEnums.Events.STACK_VIEWPORT_IMAGES_ADDED, callback); + if (!isSegmentationInToolGroup) { - const segDisplaySet = this.servicesManager.services.displaySetService.getDisplaySetByUID( - segmentation.id - ); + const segDisplaySet = displaySetService.getDisplaySetByUID(segmentation.id); segDisplaySet && - this.servicesManager.services.segmentationService.addSegmentationRepresentationToToolGroup( + segmentationService.addSegmentationRepresentationToToolGroup( toolgroupId, segmentation.id, segDisplaySet.isOverlayDisplaySet @@ -382,6 +410,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi return viewport.setStack(imageIds, initialImageIndexToUse).then(() => { viewport.setProperties({ ...properties }); const camera = presentations.positionPresentation?.camera; + + !camera && + (hFlip || vFlip) && + viewport.setCamera({ flipHorizontal: hFlip, flipVertical: vFlip }); + if (camera) { viewport.setCamera(camera); } diff --git a/extensions/cornerstone/src/utils/createImageDataForStackImage.ts b/extensions/cornerstone/src/utils/createImageDataForStackImage.ts index 74fa35d1e57..efa4150efd1 100644 --- a/extensions/cornerstone/src/utils/createImageDataForStackImage.ts +++ b/extensions/cornerstone/src/utils/createImageDataForStackImage.ts @@ -1,4 +1,4 @@ -import { vec3 } from 'gl-matrix'; +import { vec3, mat3 } from 'gl-matrix'; import { metaData, Types as csCoreTypes, cache } from '@cornerstonejs/core'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -16,11 +16,11 @@ export default function createImageDataForStackImage(imageIdReferenceMap: Map { + if (typeof patientOrientation === 'string') { + patientOrientation = patientOrientation.split('\\'); + } + + return { + rowDirection: patientOrientation[0], + columnDirection: patientOrientation[1][0], + }; +}; + +const getOrientationStringLPS = vector => { + const sampleVectorDirectionMap = { + '1,0,0': 'L', + '-1,0,0': 'R', + '0,1,0': 'P', + '0,-1,0': 'A', + '0,0,1': 'H', + '0,0,-1': 'F', + }; + + return sampleVectorDirectionMap[vector.toString()]; +}; + +jest.mock('@cornerstonejs/tools ', () => ({ + utilities: { orientation: { getOrientationStringLPS } }, +})); +jest.mock('@ohif/core', () => ({ + defaults: { orientationDirectionVectorMap }, + utils: { getDirectionsFromPatientOrientation }, +})); + +describe('getImageFlips', () => { + test('should return empty object if none of the parameters are provided', () => { + const flipsNeeded = getImageFlips({}); + expect(flipsNeeded).toEqual({}); + }); + + test('should return empty object if ImageOrientationPatient and PatientOrientation is not provided', () => { + const ImageLaterality = 'L'; + const flipsNeeded = getImageFlips({ + ImageLaterality, + }); + expect(flipsNeeded).toEqual({}); + }); + + test('should return empty object if ImageLaterality is not privided', () => { + const ImageOrientationPatient = [0, 1, 0, 0, 0, 1], + PatientOrientation = ['P', 'H']; + const flipsNeeded = getImageFlips({ + ImageOrientationPatient, + PatientOrientation, + }); + expect(flipsNeeded).toEqual({}); + }); + + test('should return { hFlip: false, vFlip: false } if ImageOrientationPatient is [0, 1, 0, 0, 0, -1] and ImageLaterality is R', () => { + const ImageOrientationPatient = [0, 1, 0, 0, 0, -1], + PatientOrientation = ['P', 'F'], + ImageLaterality = 'R'; + const flipsNeeded = getImageFlips({ + ImageOrientationPatient, + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: false, vFlip: false }); + }); + + test('should return { hFlip: false, vFlip: true } if ImageOrientationPatient is [0, -1, 0, 0, 0, 1] and ImageLaterality is L', () => { + const ImageOrientationPatient = [0, -1, 0, 0, 0, 1], + ImageLaterality = 'L'; + const flipsNeeded = getImageFlips({ + ImageOrientationPatient, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: false, vFlip: true }); + }); + + test('should return { hFlip: true, vFlip: true } if ImageOrientationPatient is [0, -1, 0, -1, 0, 0] and ImageLaterality is R', () => { + const ImageOrientationPatient = [0, -1, 0, -1, 0, 0], + ImageLaterality = 'R'; + const flipsNeeded = getImageFlips({ + ImageOrientationPatient, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: true, vFlip: true }); + }); + + test("should return { hFlip: true, vFlip: true } if ImageOrientationPatient is not present, PatientOrientation is ['P', 'H'] and ImageLaterality is L", () => { + const PatientOrientation = ['P', 'H'], + ImageLaterality = 'L'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: true, vFlip: true }); + }); + + test("should return { hFlip: true, vFlip: false } if ImageOrientationPatient is not present, PatientOrientation is ['A', 'F'] and ImageLaterality is R", () => { + const PatientOrientation = ['A', 'F'], + ImageLaterality = 'R'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: true, vFlip: false }); + }); + + test("should return { hFlip: true, vFlip: false } if ImageOrientationPatient is not present, PatientOrientation is ['A', 'FL'] and ImageLaterality is R", () => { + const PatientOrientation = ['A', 'FL'], + ImageLaterality = 'R'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: true, vFlip: false }); + }); + + test("should return { hFlip: true, vFlip: false } if ImageOrientationPatient ans ImageLaterality is not present, PatientOrientation is ['P', 'FL'] and FrameLaterality is L", () => { + const PatientOrientation = ['P', 'FL'], + FrameLaterality = 'L'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + FrameLaterality, + }); + expect(flipsNeeded).toEqual({ hFlip: true, vFlip: false }); + }); + + test("should return empty object if ImageOrientationPatient is not present, PatientOrientation is ['H', 'R'] and ImageLaterality is R since the orientation is rotated, not flipped", () => { + const PatientOrientation = ['H', 'R'], + ImageLaterality = 'R'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({}); + }); + + test("should return empty object if ImageOrientationPatient is not present, PatientOrientation is ['F', 'L'] and ImageLaterality is L since the orientation is rotated, not flipped", () => { + const PatientOrientation = ['F', 'L'], + ImageLaterality = 'L'; + const flipsNeeded = getImageFlips({ + PatientOrientation, + ImageLaterality, + }); + expect(flipsNeeded).toEqual({}); + }); +}); diff --git a/extensions/cornerstone/src/utils/getImageFlips.ts b/extensions/cornerstone/src/utils/getImageFlips.ts new file mode 100644 index 00000000000..bbe95a5b0e2 --- /dev/null +++ b/extensions/cornerstone/src/utils/getImageFlips.ts @@ -0,0 +1,123 @@ +import { defaults, utils } from '@ohif/core'; +import { utilities } from '@cornerstonejs/tools'; +import { vec3 } from 'gl-matrix'; + +const { orientationDirectionVectorMap } = defaults; +const { getOrientationStringLPS } = utilities.orientation; + +type IOP = [number, number, number, number, number, number]; +type PO = [string, string] | string; +type IL = string; +type FL = string; +type Instance = { + ImageOrientationPatient?: IOP; + PatientOrientation?: PO; + ImageLaterality?: IL; + FrameLaterality?: FL; +}; + +/** + * A function to get required flipping to correct the image according to Orientation and Laterality. + * This function does not handle rotated images. + * @param instance Metadata instance of the image. + * @returns vertical and horizontal flipping needed to correct the image if possible. + */ +export function getImageFlips(instance: Instance): { vFlip?: boolean; hFlip?: boolean } { + const { ImageOrientationPatient, PatientOrientation, ImageLaterality, FrameLaterality } = + instance; + + if (!(ImageOrientationPatient || PatientOrientation) || !(ImageLaterality || FrameLaterality)) { + console.warn( + 'Skipping image orientation correction due to missing ImageOrientationPatient/ PatientOrientation or/and ImageLaterality/ FrameLaterality' + ); + return {}; + } + + let rowDirectionCurrent, columnDirectionCurrent, rowCosines, columnCosines; + if (ImageOrientationPatient) { + rowCosines = ImageOrientationPatient.slice(0, 3); + columnCosines = ImageOrientationPatient.slice(3, 6); + rowDirectionCurrent = getOrientationStringLPS(rowCosines); + columnDirectionCurrent = getOrientationStringLPS(columnCosines)[0]; + } else { + ({ rowDirection: rowDirectionCurrent, columnDirection: columnDirectionCurrent } = + utils.getDirectionsFromPatientOrientation(PatientOrientation)); + + rowCosines = orientationDirectionVectorMap[rowDirectionCurrent]; + columnCosines = orientationDirectionVectorMap[columnDirectionCurrent]; + } + + const scanAxisNormal = vec3.create(); + vec3.cross(scanAxisNormal, rowCosines, columnCosines); + + const scanAxisDirection = getOrientationStringLPS(scanAxisNormal as [number, number, number]); + + if (isImageRotated(rowDirectionCurrent, columnDirectionCurrent)) { + // TODO: Correcting images with rotation is not implemented. + console.warn('Correcting images by rotating is not implemented'); + return {}; + } + + let rowDirectionTarget, columnDirectionTarget; + switch (scanAxisDirection[0]) { + // Sagittal + case 'L': + case 'R': + if ((ImageLaterality || FrameLaterality) === 'L') { + rowDirectionTarget = 'A'; + } else { + rowDirectionTarget = 'P'; + } + columnDirectionTarget = 'F'; + break; + // Coronal + case 'A': + case 'P': + if ((ImageLaterality || FrameLaterality) === 'L') { + rowDirectionTarget = 'R'; + } else { + rowDirectionTarget = 'L'; + } + columnDirectionTarget = 'F'; + break; + // Axial + case 'H': + case 'F': + if ((ImageLaterality || FrameLaterality) === 'L') { + rowDirectionTarget = 'A'; + columnDirectionTarget = 'R'; + } else { + rowDirectionTarget = 'P'; + columnDirectionTarget = 'L'; + } + break; + } + + let hFlip = false, + vFlip = false; + if (rowDirectionCurrent !== rowDirectionTarget) { + hFlip = true; + } + if (columnDirectionCurrent !== columnDirectionTarget) { + vFlip = true; + } + + return { hFlip, vFlip }; +} + +function isImageRotated(rowDirection: string, columnDirection: string): boolean { + const possibleValues: { [key: string]: [string, string] } = { + xDirection: ['L', 'R'], + yDirection: ['P', 'A'], + zDirection: ['H', 'F'], + }; + + if ( + possibleValues.yDirection.includes(columnDirection) || + possibleValues.zDirection.includes(rowDirection) + ) { + return true; + } + + return false; +} diff --git a/extensions/cornerstone/src/utils/isOrientationCorrectionNeeded.ts b/extensions/cornerstone/src/utils/isOrientationCorrectionNeeded.ts new file mode 100644 index 00000000000..83e24545231 --- /dev/null +++ b/extensions/cornerstone/src/utils/isOrientationCorrectionNeeded.ts @@ -0,0 +1,10 @@ +const DEFAULT_AUTO_FLIP_MODALITIES: string[] = ['MG']; + +export default function isOrientationCorrectionNeeded(instance) { + const { Modality } = instance; + + // Check Modality + const isModalityIncluded = DEFAULT_AUTO_FLIP_MODALITIES.includes(Modality); + + return isModalityIncluded; +} diff --git a/extensions/default/src/Actions/createReportAsync.tsx b/extensions/default/src/Actions/createReportAsync.tsx index d0f34a48a23..4006951c7d6 100644 --- a/extensions/default/src/Actions/createReportAsync.tsx +++ b/extensions/default/src/Actions/createReportAsync.tsx @@ -1,46 +1,82 @@ import React from 'react'; +import dcmjs from 'dcmjs'; import { DicomMetadataStore } from '@ohif/core'; +const { datasetToBlob } = dcmjs.data; + /** * * @param {*} servicesManager */ -async function createReportAsync({ servicesManager, getReport, reportType = 'measurement' }) { - const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services; - const loadingDialogId = uiDialogService.create({ - showOverlay: true, - isDraggable: false, - centralize: true, - content: Loading, - }); +async function createReportAsync({ + servicesManager, + getReport, + reportType = 'measurement', + showLoadingModal = true, + throwErrors = false, +}) { + const { displaySetService, uiNotificationService, uiDialogService, CacheAPIService } = + servicesManager.services; + const loadingDialogId = + showLoadingModal && + uiDialogService.create({ + showOverlay: true, + isDraggable: false, + centralize: true, + content: Loading, + }); + + let displaySetInstanceUID; try { const naturalizedReport = await getReport(); + const { SeriesInstanceUID, SOPInstanceUID } = naturalizedReport; + let displaySet = displaySetService + .getDisplaySetsForSeries(SeriesInstanceUID) + ?.find(ds => ds.instances.some(instance => instance.SOPInstanceUID === SOPInstanceUID)); + + const shouldOverWrite = displaySet && displaySet.Modality === 'SEG'; + // The "Mode" route listens for DicomMetadataStore changes // When a new instance is added, it listens and // automatically calls makeDisplaySets DicomMetadataStore.addInstances([naturalizedReport], true); - const displaySet = displaySetService.getMostRecentDisplaySet(); + if (!displaySet) { + // If there is no displayset before adding instances, it is a new series. + displaySet = displaySetService.getMostRecentDisplaySet(); + } - const displaySetInstanceUID = displaySet.displaySetInstanceUID; + displaySetInstanceUID = displaySet.displaySetInstanceUID; - uiNotificationService.show({ - title: 'Create Report', - message: `${reportType} saved successfully`, - type: 'success', - }); + showLoadingModal && + uiNotificationService.show({ + title: 'Create Report', + message: `${reportType} saved successfully`, + type: 'success', + }); + + reportType === 'Segmentation' && + CacheAPIService?.updateCachedFile(datasetToBlob(naturalizedReport), displaySet); + if (shouldOverWrite) { + return; + } return [displaySetInstanceUID]; } catch (error) { - uiNotificationService.show({ - title: 'Create Report', - message: error.message || `Failed to store ${reportType}`, - type: 'error', - }); + showLoadingModal && + uiNotificationService.show({ + title: 'Create Report', + message: error.message || `Failed to store ${reportType}`, + type: 'error', + }); + + if (throwErrors) { + throw error; + } } finally { - uiDialogService.dismiss({ id: loadingDialogId }); + showLoadingModal && uiDialogService.dismiss({ id: loadingDialogId }); } } diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx index 8b24196f161..89d8162f703 100644 --- a/modes/segmentation/src/index.tsx +++ b/modes/segmentation/src/index.tsx @@ -22,6 +22,12 @@ const segmentation = { viewport: '@ohif/extension-cornerstone-dicom-seg.viewportModule.dicom-seg', }; +const gradienthealth = { + form: '@gradienthealth/ohif-gradienthealth-extension.panelModule.form', + thumbnailList: + '@gradienthealth/ohif-gradienthealth-extension.panelModule.seriesList-without-tracking', +}; + /** * Just two dependencies to be able to render a viewport with panels in order * to make sure that the mode is working. @@ -50,7 +56,14 @@ function modeFactory({ modeConfiguration }) { * Services and other resources. */ onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { - const { measurementService, toolbarService, toolGroupService } = servicesManager.services; + const { + measurementService, + toolbarService, + toolGroupService, + GoogleSheetsService, + CropDisplayAreaService, + CacheAPIService, + } = servicesManager.services; measurementService.clearMeasurements(); @@ -86,6 +99,9 @@ function modeFactory({ modeConfiguration }) { activateTool )); + GoogleSheetsService.init(); + CropDisplayAreaService.init(); + CacheAPIService.init(); toolbarService.init(extensionManager); toolbarService.addButtons(toolbarButtons); toolbarService.createButtonSection('primary', [ @@ -149,11 +165,16 @@ function modeFactory({ modeConfiguration }) { { path: 'template', layoutTemplate: ({ location, servicesManager }) => { + const params = new URLSearchParams(location.search); + const rightPanels = [ + segmentation.panelTool, + ...(params.get('sheetId') ? [gradienthealth.form] : []), + ]; return { id: ohif.layout, props: { - leftPanels: [ohif.leftPanel], - rightPanels: [segmentation.panelTool], + leftPanels: [gradienthealth.thumbnailList], + rightPanels: rightPanels, viewports: [ { namespace: cornerstone.viewport, diff --git a/platform/app/public/config/gradient.js b/platform/app/public/config/gradient.js index 0410534bf7b..a4881fc5923 100644 --- a/platform/app/public/config/gradient.js +++ b/platform/app/public/config/gradient.js @@ -13,7 +13,7 @@ window.config = { // below flag is for performance reasons, but it might not work for all servers omitQuotationForMultipartRequest: true, - showWarningMessageForCrossOrigin: true, + showWarningMessageForCrossOrigin: false, showCPUFallbackMessage: true, showLoadingIndicator: true, strictZSpacingForVolumeViewport: true, diff --git a/platform/core/src/classes/MetadataProvider.ts b/platform/core/src/classes/MetadataProvider.ts index de12ff4e33c..4c52d80198f 100644 --- a/platform/core/src/classes/MetadataProvider.ts +++ b/platform/core/src/classes/MetadataProvider.ts @@ -6,6 +6,7 @@ import DicomMetadataStore from '../services/DicomMetadataStore'; import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData'; import toNumber from '../utils/toNumber'; import combineFrameInstance from '../utils/combineFrameInstance'; +import { orientationDirectionVectorMap } from '../defaults'; class MetadataProvider { constructor() { @@ -494,7 +495,7 @@ export default metadataProvider; const WADO_IMAGE_LOADER = { imagePlaneModule: instance => { - const { ImageOrientationPatient } = instance; + const { ImageOrientationPatient, PatientOrientation } = instance; // Fallback for DX images. // TODO: We should use the rest of the results of this function @@ -515,6 +516,13 @@ const WADO_IMAGE_LOADER = { if (ImageOrientationPatient) { rowCosines = ImageOrientationPatient.slice(0, 3); columnCosines = ImageOrientationPatient.slice(3, 6); + } else if (PatientOrientation) { + let patientOrientation = PatientOrientation; + if (typeof patientOrientation === 'string') { + patientOrientation = patientOrientation.split('\\'); + } + rowCosines = orientationDirectionVectorMap[patientOrientation[0]]; + columnCosines = orientationDirectionVectorMap[patientOrientation[1][0]]; } return { diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index b4c6098936c..c4b572b7049 100644 --- a/platform/core/src/defaults/hotkeyBindings.js +++ b/platform/core/src/defaults/hotkeyBindings.js @@ -173,6 +173,20 @@ const bindings = [ label: 'W/L Preset 5', keys: ['5'], }, + { + commandName: 'recordSetToolActive', + commandOptions: { toolName: 'CircularBrush' }, + label: 'Segmentation Brush', + keys: ['b'], + isEditable: true, + }, + { + commandName: 'recordSetToolActive', + commandOptions: { toolName: 'CircularEraser' }, + label: 'Segmentation Eraser', + keys: ['e'], + isEditable: true, + }, // These don't exist, so don't try applying them.... // { // commandName: 'setWindowLevel', diff --git a/platform/core/src/defaults/index.js b/platform/core/src/defaults/index.js index f8c065d7b7f..ad4be530cfb 100644 --- a/platform/core/src/defaults/index.js +++ b/platform/core/src/defaults/index.js @@ -1,4 +1,5 @@ import hotkeyBindings from './hotkeyBindings'; import windowLevelPresets from './windowLevelPresets'; -export { hotkeyBindings, windowLevelPresets }; -export default { hotkeyBindings, windowLevelPresets }; +import orientationDirectionVectorMap from './orientationDirectionVectors'; +export { hotkeyBindings, windowLevelPresets, orientationDirectionVectorMap }; +export default { hotkeyBindings, windowLevelPresets, orientationDirectionVectorMap }; diff --git a/platform/core/src/defaults/orientationDirectionVectors.ts b/platform/core/src/defaults/orientationDirectionVectors.ts new file mode 100644 index 00000000000..2809673b26a --- /dev/null +++ b/platform/core/src/defaults/orientationDirectionVectors.ts @@ -0,0 +1,12 @@ +import { Types } from '@cornerstonejs/core'; + +const orientationDirectionVectorMap: { [key: string]: Types.Point3 } = { + L: [1, 0, 0], // Left + R: [-1, 0, 0], // Right + P: [0, 1, 0], // Posterior/ Back + A: [0, -1, 0], // Anterior/ Front + H: [0, 0, 1], // Head/ Superior + F: [0, 0, -1], // Feet/ Inferior +}; + +export default orientationDirectionVectorMap; diff --git a/platform/core/src/types/OrientationDirections.ts b/platform/core/src/types/OrientationDirections.ts new file mode 100644 index 00000000000..d44cec4d96d --- /dev/null +++ b/platform/core/src/types/OrientationDirections.ts @@ -0,0 +1 @@ +export type OrientationDirections = { rowDirection: string; columnDirection: string }; diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index efcc8ec3ccf..088a5935f60 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -18,6 +18,7 @@ export type * from './StudyMetadata'; export type * from './PanelModule'; export type * from './IPubSub'; export type * from './Color'; +export type * from './OrientationDirections'; // Enum exports export * from './TimingEnum'; diff --git a/platform/core/src/utils/getDirectionsFromPatientOrientation.ts b/platform/core/src/utils/getDirectionsFromPatientOrientation.ts new file mode 100644 index 00000000000..33058df6f14 --- /dev/null +++ b/platform/core/src/utils/getDirectionsFromPatientOrientation.ts @@ -0,0 +1,16 @@ +import { OrientationDirections } from '../types'; + +export default function getDirectionsFromPatientOrientation( + patientOrientation: string | [string, string] +): OrientationDirections { + if (typeof patientOrientation === 'string') { + patientOrientation = patientOrientation.split('\\') as [string, string]; + } + + // TODO: We are only considering first direction in column orientation. + // ex: in ['P', 'HL'], we are only taking 'H' instead of 'HL' as column orientation. + return { + rowDirection: patientOrientation[0], + columnDirection: patientOrientation[1][0], + }; +} diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index d7862618e0b..860ddbf6eb5 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -37,6 +37,7 @@ import { } from './sortStudy'; import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange'; import { splitComma, getSplitParam } from './splitComma'; +import getDirectionsFromPatientOrientation from './getDirectionsFromPatientOrientation'; // Commented out unused functionality. // Need to implement new mechanism for derived displaySets using the displaySetManager. @@ -80,6 +81,7 @@ const utils = { splitComma, getSplitParam, generateAcceptHeader, + getDirectionsFromPatientOrientation, }; export { @@ -112,6 +114,7 @@ export { splitComma, getSplitParam, generateAcceptHeader, + getDirectionsFromPatientOrientation, }; export default utils; diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index 30d8f70ef51..044df94a749 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -41,6 +41,7 @@ describe('Top level exports', () => { 'subscribeToNextViewportGridChange', 'uuidv4', 'addAccessors', + 'getDirectionsFromPatientOrientation', ].sort(); const exports = Object.keys(utils.default).sort(); diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx index 25a3f88857f..401a95b3505 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; function SegmentationDropDownRow({ segmentation, + savedStatusState, activeSegmentationId, disableEditing, showAddSegment, @@ -25,18 +26,6 @@ function SegmentationDropDownRow({ return null; } - const segmentationClickHandler = () => { - if (segmentation.id === activeSegmentationId) { - onToggleShowSegments(!showSegments); - } else { - onSegmentationClick(segmentation.id); - - if (!showSegments) { - onToggleShowSegments(true); - } - } - }; - return (
{ storeSegmentation(segmentation.id); }, @@ -95,29 +84,38 @@ function SegmentationDropDownRow({ onSegmentationDownload(segmentation.id); }, }, - { + /*{ title: t('Download DICOM RTSTRUCT'), onClick: () => { onSegmentationDownloadRTSS(segmentation.id); }, - }, + },*/ ], ]} > -
+
onSegmentationClick(segmentation.id)} > - {segmentation.label} +
+ {segmentation.label} +
+
onToggleSegmentationVisibility(segmentation.id)} > {segmentation.isVisible ? ( @@ -132,7 +130,10 @@ function SegmentationDropDownRow({ /> )}
-
+
onToggleShowSegments(showSegments => !showSegments)} + > {showSegments ? ( { const [showSegments, toggleShowSegments] = useState(true); @@ -35,6 +37,7 @@ const SegmentationGroup = ({
{showSegments && ( -
+
{segmentation?.segments?.map(segment => { if (!segment) { return null; @@ -82,6 +85,7 @@ const SegmentationGroup = ({ onColor={onSegmentColorClick} onToggleVisibility={onToggleSegmentVisibility} onToggleLocked={onToggleSegmentLock} + CropDisplayAreaService={CropDisplayAreaService} />
); @@ -107,6 +111,7 @@ SegmentationGroup.propTypes = { }) ), }), + savedStatusState: PropTypes.string, activeSegmentationId: PropTypes.string, disableEditing: PropTypes.bool, showAddSegment: PropTypes.bool, @@ -125,6 +130,7 @@ SegmentationGroup.propTypes = { onToggleSegmentVisibility: PropTypes.func.isRequired, onToggleSegmentLock: PropTypes.func.isRequired, onSegmentColorClick: PropTypes.func.isRequired, + CropDisplayAreaService: PropTypes.any, }; export default SegmentationGroup; diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx index 677f3b1246a..c404208cb63 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx @@ -20,9 +20,16 @@ const SegmentItem = ({ onColor, onToggleVisibility, onToggleLocked, + CropDisplayAreaService, }) => { const [isNumberBoxHovering, setIsNumberBoxHovering] = useState(false); + const onFocusClick = (segmentationId, segmentIndex) => { + CropDisplayAreaService.focusToSegment(segmentationId, segmentIndex).then(() => + onClick(segmentationId, segmentIndex) + ); + }; + const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`; return ( @@ -139,6 +146,7 @@ const SegmentItem = ({ onToggleVisibility={onToggleVisibility} segmentationId={segmentationId} segmentIndex={segmentIndex} + onFocusClick={onFocusClick} />
@@ -156,6 +164,7 @@ const HoveringIcons = ({ onToggleVisibility, segmentationId, segmentIndex, + onFocusClick, }) => { const iconClass = 'w-5 h-5 hover:cursor-pointer hover:opacity-60'; @@ -174,6 +183,7 @@ const HoveringIcons = ({ return (
+ {createIcon('tool-zoom', onFocusClick)} {!disableEditing && createIcon('row-edit', onEdit)} {!disableEditing && createIcon( @@ -205,6 +215,7 @@ SegmentItem.propTypes = { onDelete: PropTypes.func.isRequired, onToggleVisibility: PropTypes.func.isRequired, onToggleLocked: PropTypes.func, + CropDisplayAreaService: PropTypes.any, }; SegmentItem.defaultProps = { diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx index e3647081cb2..a7a22f23aeb 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx @@ -9,6 +9,7 @@ import SegmentationGroup from './SegmentationGroup'; const SegmentationGroupTable = ({ segmentations, + savedStatusStates, // segmentation initial config segmentationConfig, // UI show/hide @@ -41,6 +42,7 @@ const SegmentationGroupTable = ({ setRenderFill, setRenderInactiveSegmentations, setRenderOutline, + CropDisplayAreaService, }) => { const [isConfigOpen, setIsConfigOpen] = useState(false); const [activeSegmentationId, setActiveSegmentationId] = useState(null); @@ -73,10 +75,14 @@ const SegmentationGroupTable = ({ title={t('Segmentations')} actionIcons={ activeSegmentation && [ - { - name: 'row-add', - onClick: () => onSegmentationAdd(), - }, + ...(showAddSegmentation && !disableEditing + ? [ + { + name: 'row-add', + onClick: () => onSegmentationAdd(), + }, + ] + : []), { name: 'settings-bars', onClick: () => setIsConfigOpen(isOpen => !isOpen), @@ -109,6 +115,7 @@ const SegmentationGroupTable = ({ key={segmentation.id} activeSegmentationId={activeSegmentationId} segmentation={segmentation} + savedStatusState={savedStatusStates[segmentation.id]} disableEditing={disableEditing} showAddSegment={showAddSegment} onSegmentationClick={onSegmentationClick} @@ -126,6 +133,7 @@ const SegmentationGroupTable = ({ onToggleSegmentLock={onToggleSegmentLock} onSegmentColorClick={onSegmentColorClick} showDeleteSegment={showDeleteSegment} + CropDisplayAreaService={CropDisplayAreaService} /> )) )} @@ -151,6 +159,7 @@ SegmentationGroupTable.propTypes = { ), }) ), + savedStatusStates: PropTypes.object, segmentationConfig: PropTypes.object.isRequired, disableEditing: PropTypes.bool, showAddSegmentation: PropTypes.bool, @@ -178,6 +187,7 @@ SegmentationGroupTable.propTypes = { setRenderFill: PropTypes.func.isRequired, setRenderInactiveSegmentations: PropTypes.func.isRequired, setRenderOutline: PropTypes.func.isRequired, + CropDisplayAreaService: PropTypes.any, }; SegmentationGroupTable.defaultProps = {