From 66863281c4e27f93e2f09373594b0e604f1300e3 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 26 Apr 2023 12:21:08 -0400 Subject: [PATCH] feat(RT): add dicom RT support via volume viewports (#3310) * feat: initial RT support * make the segmentation service work with representation data * feat: make segmentation service work with representations * fix rtss vis * fix: rt hydration * fix the rendering of rt names * fix imports * refactor: Modify status and click handling for hydration of RTStructures Modify status and click handling for hydration of RTStructures by renaming `onPillClick` to `onStatusClick` in `OHIFCornerstoneRTViewport.tsx` and `_getStatusComponent.tsx` files. Also, update initial segmentation configurations in `PanelSegmentation.tsx` and simplify configuration changes and values for segmentation service in `SegmentationService.ts`. Finally, remove console debug in `CornerstoneViewportService.ts`. * wip for highlighting contours * refactor rt displayset code * review code update * update cornerstone dependencies * refactor: Update license year, version number, and minor code cleanup This commit updates the license year in several files, updates the version number in package.json, and contains minor code cleanup in two files. * add bulkdataURI retrieve for RT * fix package version * apply review comments * apply review comments * apply review comments * feat(panels): refactor and streamline segmentation configuration and inputs Rewrote state hooks and streamlined the configuration input for `PanelSegmentation` to be more verbose and reusable. Included several new input types, including the `InputRange` component which now shows a fixed floating value based on the step provided. The `SegmentationConfig` component now works with dynamic values controlled by `initialConfig`. These changes should improve function usability and make the code more maintainable going forward. * fix various bugs * fix contour delete by upgrade cs3d version * feat(viewport, inputNumber, segmentationConfig, orthanc): Implement minimum and maximum values for input number components, and useBulkDataURI for Orthanc configuration. Compare measurement view planes with absolute viewport view planes in Cornerstone viewport. * update yarn lock --- .../.webpack/webpack.dev.js | 8 + .../.webpack/webpack.prod.js | 63 ++ extensions/cornerstone-dicom-rt/LICENSE | 20 + extensions/cornerstone-dicom-rt/README.md | 13 + .../cornerstone-dicom-rt/babel.config.js | 44 ++ extensions/cornerstone-dicom-rt/package.json | 71 ++ .../src/getSopClassHandlerModule.js | 207 ++++++ extensions/cornerstone-dicom-rt/src/id.js | 7 + extensions/cornerstone-dicom-rt/src/index.tsx | 61 ++ .../cornerstone-dicom-rt/src/loadRTStruct.js | 318 ++++++++ .../src/utils/_hydrateRT.ts | 70 ++ .../src/utils/initRTToolGroup.ts | 12 + .../src/utils/promptHydrateRT.ts | 70 ++ .../viewports/OHIFCornerstoneRTViewport.tsx | 415 +++++++++++ .../src/viewports/_getStatusComponent.tsx | 54 ++ extensions/cornerstone-dicom-seg/LICENSE | 2 +- .../src/getSopClassHandlerModule.js | 28 +- .../cornerstone-dicom-seg/src/index.tsx | 13 +- .../src/panels/PanelSegmentation.tsx | 37 +- .../src/panels/segmentationConfigReducer.tsx | 2 - .../src/utils/initSEGToolGroup.ts | 34 +- .../viewports/OHIFCornerstoneSEGViewport.tsx | 47 +- extensions/cornerstone-dicom-sr/package.json | 2 +- extensions/cornerstone/package.json | 4 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 12 +- .../cornerstone/src/getCustomizationModule.ts | 37 + extensions/cornerstone/src/index.tsx | 4 +- extensions/cornerstone/src/init.tsx | 2 +- .../RTSTRUCT/mapROIContoursToRTStructData.ts | 38 + .../SegmentationService.ts | 699 ++++++++++++------ .../SegmentationServiceTypes.ts | 48 +- .../CornerstoneViewportService.ts | 232 +++--- .../cornerstone/src/utils/transitions.ts | 14 + .../default/src/DicomWebDataSource/index.js | 11 + .../default/src/getSopClassHandlerModule.js | 10 +- extensions/measurement-tracking/package.json | 2 +- modes/longitudinal/package.json | 2 + modes/longitudinal/src/index.js | 14 +- .../src/classes/{ImageSet.js => ImageSet.ts} | 51 +- .../core/src/extensions/ExtensionManager.ts | 1 + platform/core/src/types/Color.ts | 4 + platform/core/src/types/index.ts | 1 + .../services/data/SegmentationService.md | 4 +- .../components/InputNumber/InputNumber.tsx | 37 +- .../src/components/InputRange/InputRange.tsx | 5 +- .../LoadingIndicatorTotalPercent.tsx | 54 ++ .../LoadingIndicatorTotalPercent/index.js | 2 + .../SegmentationConfig.tsx | 137 +--- .../SegmentationGroupTable.tsx | 31 +- .../segmentationConfigReducer.tsx | 22 - platform/ui/src/components/index.js | 2 + platform/ui/src/index.js | 1 + platform/viewer/netlify.toml | 1 - platform/viewer/package.json | 1 + platform/viewer/pluginConfig.json | 6 +- platform/viewer/public/config/default.js | 1 + platform/viewer/public/config/demo.js | 1 + .../viewer/public/config/local_dcm4chee.js | 7 +- .../viewer/public/config/local_orthanc.js | 1 + yarn.lock | 69 +- 60 files changed, 2488 insertions(+), 678 deletions(-) create mode 100644 extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js create mode 100644 extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js create mode 100644 extensions/cornerstone-dicom-rt/LICENSE create mode 100644 extensions/cornerstone-dicom-rt/README.md create mode 100644 extensions/cornerstone-dicom-rt/babel.config.js create mode 100644 extensions/cornerstone-dicom-rt/package.json create mode 100644 extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js create mode 100644 extensions/cornerstone-dicom-rt/src/id.js create mode 100644 extensions/cornerstone-dicom-rt/src/index.tsx create mode 100644 extensions/cornerstone-dicom-rt/src/loadRTStruct.js create mode 100644 extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts create mode 100644 extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts create mode 100644 extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts create mode 100644 extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx create mode 100644 extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx create mode 100644 extensions/cornerstone/src/getCustomizationModule.ts create mode 100644 extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts rename platform/core/src/classes/{ImageSet.js => ImageSet.ts} (72%) create mode 100644 platform/core/src/types/Color.ts create mode 100644 platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx create mode 100644 platform/ui/src/components/LoadingIndicatorTotalPercent/index.js delete mode 100644 platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx diff --git a/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js b/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js new file mode 100644 index 000000000..1ae308448 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js b/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js new file mode 100644 index 000000000..070a723e3 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/.webpack/webpack.prod.js @@ -0,0 +1,63 @@ +const path = require('path'); +const pkg = require('../package.json'); + +const outputFile = 'index.umd.js'; +const rootDir = path.resolve(__dirname, '../'); +const outputFolder = path.join(__dirname, `../dist/umd/${pkg.name}/`); + +// Todo: add ESM build for the extension in addition to umd build + +const config = { + mode: 'production', + entry: rootDir + '/' + pkg.module, + devtool: 'source-map', + output: { + path: outputFolder, + filename: outputFile, + library: pkg.name, + libraryTarget: 'umd', + chunkFilename: '[name].chunk.js', + umdNamedDefine: true, + globalObject: "typeof self !== 'undefined' ? self : this", + }, + externals: [ + { + react: { + root: 'React', + commonjs2: 'react', + commonjs: 'react', + amd: 'react', + }, + '@ohif/core': { + commonjs2: '@ohif/core', + commonjs: '@ohif/core', + amd: '@ohif/core', + root: '@ohif/core', + }, + '@ohif/ui': { + commonjs2: '@ohif/ui', + commonjs: '@ohif/ui', + amd: '@ohif/ui', + root: '@ohif/ui', + }, + }, + ], + module: { + rules: [ + { + test: /(\.jsx|\.js|\.tsx|\.ts)$/, + loader: 'babel-loader', + exclude: /(node_modules|bower_components)/, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + ], + }, + resolve: { + modules: [path.resolve('./node_modules'), path.resolve('./src')], + extensions: ['.json', '.js', '.jsx', '.tsx', '.ts'], + }, +}; + +module.exports = config; diff --git a/extensions/cornerstone-dicom-rt/LICENSE b/extensions/cornerstone-dicom-rt/LICENSE new file mode 100644 index 000000000..983c5ef34 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/cornerstone-dicom-rt/README.md b/extensions/cornerstone-dicom-rt/README.md new file mode 100644 index 000000000..a23ee416f --- /dev/null +++ b/extensions/cornerstone-dicom-rt/README.md @@ -0,0 +1,13 @@ +# dicom-rt +## Description + +DICOM RT read workflow. This extension will allow you to load a DICOM RTSS image +and display it in OHIF. + + +## Author + +OHIF + +## License +MIT diff --git a/extensions/cornerstone-dicom-rt/babel.config.js b/extensions/cornerstone-dicom-rt/babel.config.js new file mode 100644 index 000000000..92fbbdeaf --- /dev/null +++ b/extensions/cornerstone-dicom-rt/babel.config.js @@ -0,0 +1,44 @@ +module.exports = { + plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'], + env: { + test: { + presets: [ + [ + // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 + '@babel/preset-env', + { + modules: 'commonjs', + debug: false, + }, + "@babel/preset-typescript", + ], + '@babel/preset-react', + ], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-regenerator', + '@babel/plugin-transform-runtime', + ], + }, + production: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + "@babel/preset-typescript", + ], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + development: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + "@babel/preset-typescript", + ], + plugins: ['react-hot-loader/babel'], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + }, +}; diff --git a/extensions/cornerstone-dicom-rt/package.json b/extensions/cornerstone-dicom-rt/package.json new file mode 100644 index 000000000..cd4bcdb6c --- /dev/null +++ b/extensions/cornerstone-dicom-rt/package.json @@ -0,0 +1,71 @@ +{ + "name": "@ohif/extension-cornerstone-dicom-rt", + "version": "3.0.0", + "description": "DICOM RT read workflow", + "author": "OHIF", + "license": "MIT", + "main": "dist/umd/@ohif/dicom-rt/index.umd.js", + "module": "src/index.tsx", + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "repository": "OHIF/Viewers", + "keywords": [ + "ohif-extension" + ], + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.18.0" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:dicom-seg": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev" + }, + "peerDependencies": { + "@ohif/core": "^3.0.0", + "@ohif/extension-default": "^3.0.0", + "@ohif/extension-cornerstone": "^3.0.0", + "@ohif/i18n": "^1.0.0", + "prop-types": "^15.6.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-i18next": "^10.11.0", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", + "webpack": "^5.50.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "@babel/runtime": "7.7.6", + "react-color": "^2.19.3" + }, + "devDependencies": { + "@babel/core": "^7.5.0", + "@babel/plugin-proposal-class-properties": "^7.5.0", + "@babel/plugin-proposal-object-rest-spread": "^7.5.5", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.4.5", + "@babel/plugin-transform-runtime": "^7.5.0", + "babel-plugin-inline-react-svg": "^2.0.1", + "@babel/preset-env": "^7.5.0", + "@babel/preset-react": "^7.0.0", + "babel-eslint": "^8.0.3", + "babel-loader": "^8.0.0-beta.4", + "clean-webpack-plugin": "^4.0.0", + "copy-webpack-plugin": "^10.2.0", + "cross-env": "^7.0.3", + "dotenv": "^14.1.0", + "eslint": "^5.0.1", + "eslint-loader": "^2.0.0", + "webpack": "^5.50.0", + "webpack-merge": "^5.7.3", + "webpack-cli": "^4.7.2" + } +} diff --git a/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js new file mode 100644 index 000000000..81ba6aab9 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.js @@ -0,0 +1,207 @@ +import { utils } from '@ohif/core'; + +import { SOPClassHandlerId } from './id'; +import loadRTStruct from './loadRTStruct'; + +const sopClassUids = ['1.2.840.10008.5.1.4.1.1.481.3']; + +let loadPromises = {}; + +function _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager +) { + const instance = instances[0]; + + const { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + SeriesDescription, + SeriesNumber, + SeriesDate, + SOPClassUID, + wadoRoot, + wadoUri, + wadoUriRoot, + } = instance; + + const displaySet = { + Modality: 'RTSTRUCT', + loading: false, + isReconstructable: false, // by default for now since it is a volumetric SEG currently + displaySetInstanceUID: utils.guid(), + SeriesDescription, + SeriesNumber, + SeriesDate, + SOPInstanceUID, + SeriesInstanceUID, + StudyInstanceUID, + SOPClassHandlerId, + SOPClassUID, + referencedImages: null, + referencedSeriesInstanceUID: null, + referencedDisplaySetInstanceUID: null, + isDerivedDisplaySet: true, + isLoaded: false, + isHydrated: false, + structureSet: null, + sopClassUids, + instance, + wadoRoot, + wadoUriRoot, + wadoUri, + isOverlayDisplaySet: true, + }; + + let referencedSeriesSequence = instance.ReferencedSeriesSequence; + if ( + instance.ReferencedFrameOfReferenceSequence && + !instance.ReferencedSeriesSequence + ) { + instance.ReferencedSeriesSequence = _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence( + instance.ReferencedFrameOfReferenceSequence + ); + referencedSeriesSequence = instance.ReferencedSeriesSequence; + } + + if (!referencedSeriesSequence) { + throw new Error('ReferencedSeriesSequence is missing for the RTSTRUCT'); + } + + const referencedSeries = referencedSeriesSequence[0]; + + displaySet.referencedImages = + instance.ReferencedSeriesSequence.ReferencedInstanceSequence; + displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID; + + displaySet.getReferenceDisplaySet = () => { + const { DisplaySetService } = servicesManager.services; + const referencedDisplaySets = DisplaySetService.getDisplaySetsForSeries( + displaySet.referencedSeriesInstanceUID + ); + + if (!referencedDisplaySets || referencedDisplaySets.length === 0) { + throw new Error('Referenced DisplaySet is missing for the RT'); + } + + const referencedDisplaySet = referencedDisplaySets[0]; + + displaySet.referencedDisplaySetInstanceUID = + referencedDisplaySet.displaySetInstanceUID; + + return referencedDisplaySet; + }; + + displaySet.load = ({ headers }) => + _load(displaySet, servicesManager, extensionManager, headers); + + return [displaySet]; +} + +function _load(rtDisplaySet, servicesManager, extensionManager, headers) { + const { SOPInstanceUID } = rtDisplaySet; + const { segmentationService } = servicesManager.services; + if ( + (rtDisplaySet.loading || rtDisplaySet.isLoaded) && + loadPromises[SOPInstanceUID] && + _segmentationExistsInCache(rtDisplaySet, segmentationService) + ) { + return loadPromises[SOPInstanceUID]; + } + + rtDisplaySet.loading = true; + + // We don't want to fire multiple loads, so we'll wait for the first to finish + // and also return the same promise to any other callers. + loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => { + if (!rtDisplaySet.structureSet) { + const structureSet = await loadRTStruct( + extensionManager, + rtDisplaySet, + rtDisplaySet.getReferenceDisplaySet(), + headers + ); + + rtDisplaySet.structureSet = structureSet; + } + + const suppressEvents = true; + segmentationService + .createSegmentationForRTDisplaySet(rtDisplaySet, null, suppressEvents) + .then(() => { + rtDisplaySet.loading = false; + resolve(); + }) + .catch(error => { + rtDisplaySet.loading = false; + reject(error); + }); + }); + + return loadPromises[SOPInstanceUID]; +} + +function _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence( + ReferencedFrameOfReferenceSequence +) { + const ReferencedSeriesSequence = []; + + ReferencedFrameOfReferenceSequence.forEach(referencedFrameOfReference => { + const { RTReferencedStudySequence } = referencedFrameOfReference; + + RTReferencedStudySequence.forEach(rtReferencedStudy => { + const { RTReferencedSeriesSequence } = rtReferencedStudy; + + RTReferencedSeriesSequence.forEach(rtReferencedSeries => { + const ReferencedInstanceSequence = []; + const { ContourImageSequence, SeriesInstanceUID } = rtReferencedSeries; + + ContourImageSequence.forEach(contourImage => { + ReferencedInstanceSequence.push({ + ReferencedSOPInstanceUID: contourImage.ReferencedSOPInstanceUID, + ReferencedSOPClassUID: contourImage.ReferencedSOPClassUID, + }); + }); + + const referencedSeries = { + SeriesInstanceUID, + ReferencedInstanceSequence, + }; + + ReferencedSeriesSequence.push(referencedSeries); + }); + }); + }); + + return ReferencedSeriesSequence; +} + +function _segmentationExistsInCache(rtDisplaySet, segmentationService) { + // Todo: fix this + return false; + // This should be abstracted with the CornerstoneCacheService + const rtContourId = rtDisplaySet.displaySetInstanceUID; + const contour = segmentationService.getContour(rtContourId); + + return contour !== undefined; +} + +function getSopClassHandlerModule({ servicesManager, extensionManager }) { + return [ + { + name: 'dicom-rt', + sopClassUids, + getDisplaySetsFromSeries: instances => { + return _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager + ); + }, + }, + ]; +} + +export default getSopClassHandlerModule; diff --git a/extensions/cornerstone-dicom-rt/src/id.js b/extensions/cornerstone-dicom-rt/src/id.js new file mode 100644 index 000000000..2c8691f0b --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/id.js @@ -0,0 +1,7 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; +const SOPClassHandlerName = 'dicom-rt'; +const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`; + +export { id, SOPClassHandlerId, SOPClassHandlerName }; diff --git a/extensions/cornerstone-dicom-rt/src/index.tsx b/extensions/cornerstone-dicom-rt/src/index.tsx new file mode 100644 index 000000000..7d5529776 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/index.tsx @@ -0,0 +1,61 @@ +import { id } from './id'; +import React from 'react'; +import { Types } from '@ohif/core'; +import getSopClassHandlerModule from './getSopClassHandlerModule'; + +const Component = React.lazy(() => { + return import( + /* webpackPrefetch: true */ './viewports/OHIFCornerstoneRTViewport' + ); +}); + +const OHIFCornerstoneRTViewport = props => { + return ( + Loading...}> + + + ); +}; + +/** + * You can remove any of the following modules if you don't need them. + */ +const extension: Types.Extensions.Extension = { + /** + * Only required property. Should be a unique value across all extensions. + * You ID can be anything you want, but it should be unique. + */ + id, + + /** + * PanelModule should provide a list of panels that will be available in OHIF + * for Modes to consume and render. Each panel is defined by a {name, + * iconName, iconLabel, label, component} object. Example of a panel module + * is the StudyBrowserPanel that is provided by the default extension in OHIF. + */ + getViewportModule({ + servicesManager, + extensionManager, + }: Types.Extensions.ExtensionParams) { + const ExtendedOHIFCornerstoneRTViewport = props => { + return ( + + ); + }; + + return [{ name: 'dicom-rt', component: ExtendedOHIFCornerstoneRTViewport }]; + }, + /** + * SopClassHandlerModule should provide a list of sop class handlers that will be + * available in OHIF for Modes to consume and use to create displaySets from Series. + * Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}. + * Examples include the default sop class handler provided by the default extension + */ + getSopClassHandlerModule, +}; + +export default extension; diff --git a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js new file mode 100644 index 000000000..52e1bd7c7 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js @@ -0,0 +1,318 @@ +import dcmjs from 'dcmjs'; +const { DicomMessage, DicomMetaDictionary } = dcmjs.data; +const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB; + +async function checkAndLoadContourData(instance, datasource) { + if (!instance || !instance.ROIContourSequence) { + return Promise.reject('Invalid instance object or ROIContourSequence'); + } + + const promises = []; + let counter = 0; + + for (const ROIContour of instance.ROIContourSequence) { + if (!ROIContour || !ROIContour.ContourSequence) { + return Promise.reject('Invalid ROIContour or ContourSequence'); + } + + for (const Contour of ROIContour.ContourSequence) { + if (!Contour || !Contour.ContourData) { + return Promise.reject('Invalid Contour or ContourData'); + } + + const contourData = Contour.ContourData; + counter++; + if (Array.isArray(contourData)) { + promises.push(Promise.resolve(contourData)); + } else if (contourData && contourData.BulkDataURI) { + const bulkDataURI = contourData.BulkDataURI; + + if ( + !datasource || + !datasource.retrieve || + !datasource.retrieve.bulkDataURI + ) { + return Promise.reject( + 'Invalid datasource object or retrieve function' + ); + } + + const bulkDataPromise = datasource.retrieve.bulkDataURI({ + BulkDataURI: bulkDataURI, + StudyInstanceUID: instance.StudyInstanceUID, + SeriesInstanceUID: instance.SeriesInstanceUID, + SOPInstanceUID: instance.SOPInstanceUID, + }); + + promises.push(bulkDataPromise); + } else { + return Promise.reject(`Invalid ContourData: ${contourData}`); + } + } + } + const flattenedPromises = promises.flat(); + const resolvedPromises = await Promise.allSettled(flattenedPromises); + + // Modify contourData and replace it in its corresponding ROIContourSequence's Contour's contourData + let index = 0; + instance.ROIContourSequence.forEach((ROIContour, roiIndex) => { + ROIContour.ContourSequence.forEach((Contour, contourIndex) => { + const promise = resolvedPromises[index++]; + + if (promise.status === 'fulfilled') { + const uint8Array = new Uint8Array(promise.value); + const textDecoder = new TextDecoder(); + const dataUint8Array = textDecoder.decode(uint8Array); + if ( + typeof dataUint8Array === 'string' && + dataUint8Array.includes('\\') + ) { + const numSlashes = (dataUint8Array.match(/\\/g) || []).length; + let startIndex = 0; + let endIndex = dataUint8Array.indexOf('\\', startIndex); + let numbersParsed = 0; + const ContourData = []; + + while (numbersParsed !== numSlashes + 1) { + const str = dataUint8Array.substring(startIndex, endIndex); + let value = parseFloat(str); + + ContourData.push(value); + startIndex = endIndex + 1; + endIndex = dataUint8Array.indexOf('\\', startIndex); + endIndex === -1 ? (endIndex = dataUint8Array.length) : endIndex; + numbersParsed++; + } + Contour.ContourData = ContourData; + } else { + Contour.ContourData = []; + } + } else { + console.error(promise.reason); + } + }); + }); +} + +export default async function loadRTStruct( + extensionManager, + rtStructDisplaySet, + referencedDisplaySet, + headers +) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' + ); + const dataSource = extensionManager.getActiveDataSource()[0]; + const { useBulkDataURI } = dataSource.getConfig?.() || {}; + + const { dicomLoaderService } = utilityModule.exports; + const imageIdSopInstanceUidPairs = _getImageIdSopInstanceUidPairsForDisplaySet( + referencedDisplaySet + ); + + // Set here is loading is asynchronous. + // If this function throws its set back to false. + rtStructDisplaySet.isLoaded = true; + let instance = rtStructDisplaySet.instance; + + if (!useBulkDataURI) { + const segArrayBuffer = await dicomLoaderService.findDicomDataPromise( + rtStructDisplaySet, + null, + headers + ); + + const dicomData = DicomMessage.readFile(segArrayBuffer); + const rtStructDataset = DicomMetaDictionary.naturalizeDataset( + dicomData.dict + ); + rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta); + instance = rtStructDataset; + } else { + await checkAndLoadContourData(instance, dataSource); + } + + const { + StructureSetROISequence, + ROIContourSequence, + RTROIObservationsSequence, + } = instance; + + // Define our structure set entry and add it to the rtstruct module state. + const structureSet = { + StructureSetLabel: instance.StructureSetLabel, + SeriesInstanceUID: instance.SeriesInstanceUID, + ROIContours: [], + visible: true, + }; + + for (let i = 0; i < ROIContourSequence.length; i++) { + const ROIContour = ROIContourSequence[i]; + const { ContourSequence } = ROIContour; + + if (!ContourSequence) { + continue; + } + + const isSupported = false; + + const ContourSequenceArray = _toArray(ContourSequence); + + const contourPoints = []; + for (let c = 0; c < ContourSequenceArray.length; c++) { + const { + ContourImageSequence, + ContourData, + NumberOfContourPoints, + ContourGeometricType, + } = ContourSequenceArray[c]; + + const sopInstanceUID = ContourImageSequence.ReferencedSOPInstanceUID; + const imageId = _getImageId(imageIdSopInstanceUidPairs, sopInstanceUID); + + if (!imageId) { + continue; + } + let isSupported = false; + + const points = []; + for (let p = 0; p < NumberOfContourPoints * 3; p += 3) { + points.push({ + x: ContourData[p], + y: ContourData[p + 1], + z: ContourData[p + 2], + }); + } + + switch (ContourGeometricType) { + case 'CLOSED_PLANAR': + case 'OPEN_PLANAR': + case 'POINT': + isSupported = true; + + break; + default: + continue; + } + + contourPoints.push({ + numberOfPoints: NumberOfContourPoints, + points, + type: ContourGeometricType, + isSupported, + }); + } + + _setROIContourMetadata( + structureSet, + StructureSetROISequence, + RTROIObservationsSequence, + ROIContour, + contourPoints, + isSupported + ); + } + return structureSet; +} + +const _getImageId = (imageIdSopInstanceUidPairs, sopInstanceUID) => { + const imageIdSopInstanceUidPairsEntry = imageIdSopInstanceUidPairs.find( + imageIdSopInstanceUidPairsEntry => + imageIdSopInstanceUidPairsEntry.sopInstanceUID === sopInstanceUID + ); + + return imageIdSopInstanceUidPairsEntry + ? imageIdSopInstanceUidPairsEntry.imageId + : null; +}; + +function _getImageIdSopInstanceUidPairsForDisplaySet(referencedDisplaySet) { + return referencedDisplaySet.images.map(image => { + return { + imageId: image.imageId, + sopInstanceUID: image.SOPInstanceUID, + }; + }); +} + +function _setROIContourMetadata( + structureSet, + StructureSetROISequence, + RTROIObservationsSequence, + ROIContour, + contourPoints, + isSupported +) { + const StructureSetROI = StructureSetROISequence.find( + structureSetROI => + structureSetROI.ROINumber === ROIContour.ReferencedROINumber + ); + + const ROIContourData = { + ROINumber: StructureSetROI.ROINumber, + ROIName: StructureSetROI.ROIName, + ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm, + ROIDescription: StructureSetROI.ROIDescription, + isSupported, + contourPoints, + visible: true, + }; + + _setROIContourDataColor(ROIContour, ROIContourData); + + if (RTROIObservationsSequence) { + // If present, add additional RTROIObservations metadata. + _setROIContourRTROIObservations( + ROIContourData, + RTROIObservationsSequence, + ROIContour.ReferencedROINumber + ); + } + + structureSet.ROIContours.push(ROIContourData); +} + +function _setROIContourDataColor(ROIContour, ROIContourData) { + let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour; + + if (!ROIDisplayColor && RecommendedDisplayCIELabValue) { + // If ROIDisplayColor is absent, try using the RecommendedDisplayCIELabValue color. + ROIDisplayColor = dicomlab2RGB(RecommendedDisplayCIELabValue); + } + + if (ROIDisplayColor) { + ROIContourData.colorArray = [...ROIDisplayColor]; + } +} + +function _setROIContourRTROIObservations( + ROIContourData, + RTROIObservationsSequence, + ROINumber +) { + const RTROIObservations = RTROIObservationsSequence.find( + RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber + ); + + if (RTROIObservations) { + // Deep copy so we don't keep the reference to the dcmjs dataset entry. + const { + ObservationNumber, + ROIObservationDescription, + RTROIInterpretedType, + ROIInterpreter, + } = RTROIObservations; + + ROIContourData.RTROIObservations = { + ObservationNumber, + ROIObservationDescription, + RTROIInterpretedType, + ROIInterpreter, + }; + } +} + +function _toArray(objOrArray) { + return Array.isArray(objOrArray) ? objOrArray : [objOrArray]; +} diff --git a/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts new file mode 100644 index 000000000..0668c894b --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts @@ -0,0 +1,70 @@ +async function _hydrateRTDisplaySet({ + rtDisplaySet, + viewportIndex, + servicesManager, +}) { + const { + segmentationService, + hangingProtocolService, + viewportGridService, + } = servicesManager.services; + + const displaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID; + + let segmentationId = null; + + // We need the hydration to notify panels about the new segmentation added + const suppressEvents = false; + + segmentationId = await segmentationService.createSegmentationForRTDisplaySet( + rtDisplaySet, + segmentationId, + suppressEvents + ); + + segmentationService.hydrateSegmentation(rtDisplaySet.displaySetInstanceUID); + + const { viewports } = viewportGridService.getState(); + + const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( + viewportIndex, + displaySetInstanceUID + ); + + viewportGridService.setDisplaySetsForViewports(updatedViewports); + + // Todo: fix this after we have a better way for stack viewport segmentations + + // check every viewport in the viewports to see if the displaySetInstanceUID + // is being displayed, if so we need to update the viewport to use volume viewport + // (if already is not using it) since Cornerstone3D currently only supports + // volume viewport for segmentation + viewports.forEach((viewport, index) => { + if (index === viewportIndex) { + return; + } + + const shouldDisplaySeg = segmentationService.shouldRenderSegmentation( + viewport.displaySetInstanceUIDs, + rtDisplaySet.displaySetInstanceUID + ); + + if (shouldDisplaySeg) { + updatedViewports.push({ + viewportIndex: index, + displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, + viewportOptions: { + initialImageOptions: { + preset: 'middle', + }, + }, + }); + } + }); + + // Do the entire update at once + viewportGridService.setDisplaySetsForViewports(updatedViewports); + return true; +} + +export default _hydrateRTDisplaySet; diff --git a/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts new file mode 100644 index 000000000..826e3b9a6 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts @@ -0,0 +1,12 @@ +function createRTToolGroupAndAddTools( + ToolGroupService, + customizationService, + toolGroupId +) { + const { tools } = + customizationService.get('cornerstone.overlayViewportTools') ?? {}; + + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); +} + +export default createRTToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts new file mode 100644 index 000000000..4af78caee --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts @@ -0,0 +1,70 @@ +import hydrateRTDisplaySet from './_hydrateRT'; + +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + HYDRATE_SEG: 5, +}; + +function promptHydrateRT({ + servicesManager, + rtDisplaySet, + viewportIndex, + toolGroupId = 'default', +}) { + const { uiViewportDialogService } = servicesManager.services; + + return new Promise(async function(resolve, reject) { + const promptResult = await _askHydrate( + uiViewportDialogService, + viewportIndex + ); + + if (promptResult === RESPONSE.HYDRATE_SEG) { + const isHydrated = await hydrateRTDisplaySet({ + rtDisplaySet, + viewportIndex, + toolGroupId, + servicesManager, + }); + + resolve(isHydrated); + } + }); +} + +function _askHydrate(uiViewportDialogService, viewportIndex) { + return new Promise(function(resolve, reject) { + const message = 'Do you want to open this Segmentation?'; + const actions = [ + { + type: 'secondary', + text: 'No', + value: RESPONSE.CANCEL, + }, + { + type: 'primary', + text: 'Yes', + value: RESPONSE.HYDRATE_SEG, + }, + ]; + const onSubmit = result => { + uiViewportDialogService.hide(); + resolve(result); + }; + + uiViewportDialogService.show({ + viewportIndex, + type: 'info', + message, + actions, + onSubmit, + onOutsideClick: () => { + uiViewportDialogService.hide(); + resolve(RESPONSE.CANCEL); + }, + }); + }); +} + +export default promptHydrateRT; diff --git a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx new file mode 100644 index 000000000..29adb47d5 --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx @@ -0,0 +1,415 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import OHIF, { utils } from '@ohif/core'; +import { + Notification, + ViewportActionBar, + useViewportGrid, + useViewportDialog, + LoadingIndicatorTotalPercent, +} from '@ohif/ui'; + +import _hydrateRTdisplaySet from '../utils/_hydrateRT'; +import promptHydrateRT from '../utils/promptHydrateRT'; +import _getStatusComponent from './_getStatusComponent'; +import createRTToolGroupAndAddTools from '../utils/initRTToolGroup'; +import _hydrateRTDisplaySet from '../utils/_hydrateRT'; + +const { formatDate } = utils; +const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup'; + +function OHIFCornerstoneRTViewport(props) { + const { + children, + displaySets, + viewportOptions, + viewportIndex, + viewportLabel, + servicesManager, + extensionManager, + } = props; + + const { + displaySetService, + toolGroupService, + segmentationService, + uiNotificationService, + customizationService, + } = servicesManager.services; + + const toolGroupId = `${RT_TOOLGROUP_BASE_NAME}-${viewportIndex}`; + + // RT viewport will always have a single display set + if (displaySets.length > 1) { + throw new Error('RT viewport should only have a single display set'); + } + + const rtDisplaySet = displaySets[0]; + + const [viewportGrid, viewportGridService] = useViewportGrid(); + const [viewportDialogState, viewportDialogApi] = useViewportDialog(); + + // States + const [isToolGroupCreated, setToolGroupCreated] = useState(false); + const [selectedSegment, setSelectedSegment] = useState(1); + + // Hydration means that the RT is opened and segments are loaded into the + // segmentation panel, and RT is also rendered on any viewport that is in the + // same frameOfReferenceUID as the referencedSeriesUID of the RT. However, + // loading basically means RT loading over network and bit unpacking of the + // RT data. + const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated); + const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded); + const [element, setElement] = useState(null); + const [processingProgress, setProcessingProgress] = useState({ + percentComplete: null, + totalSegments: null, + }); + + // refs + const referencedDisplaySetRef = useRef(null); + + const { viewports, activeViewportIndex } = viewportGrid; + + const referencedDisplaySet = rtDisplaySet.getReferenceDisplaySet(); + const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata( + referencedDisplaySet + ); + + referencedDisplaySetRef.current = { + displaySet: referencedDisplaySet, + metadata: referencedDisplaySetMetadata, + }; + /** + * OnElementEnabled callback which is called after the cornerstoneExtension + * has enabled the element. Note: we delegate all the image rendering to + * cornerstoneExtension, so we don't need to do anything here regarding + * the image rendering, element enabling etc. + */ + const onElementEnabled = evt => { + setElement(evt.detail.element); + }; + + const onElementDisabled = () => { + setElement(null); + }; + + const getCornerstoneViewport = useCallback(() => { + const { component: Component } = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.viewportModule.cornerstone' + ); + + const { + displaySet: referencedDisplaySet, + } = referencedDisplaySetRef.current; + + // Todo: jump to the center of the first segment + return ( + + ); + }, [viewportIndex, rtDisplaySet, toolGroupId]); + + const onSegmentChange = useCallback( + direction => { + direction = direction === 'left' ? -1 : 1; + const segmentationId = rtDisplaySet.displaySetInstanceUID; + const segmentation = segmentationService.getSegmentation(segmentationId); + + const { segments } = segmentation; + + const numberOfSegments = Object.keys(segments).length; + + let newSelectedSegmentIndex = selectedSegment + direction; + + // Segment 0 is always background + if (newSelectedSegmentIndex >= numberOfSegments - 1) { + newSelectedSegmentIndex = 1; + } else if (newSelectedSegmentIndex === 0) { + newSelectedSegmentIndex = numberOfSegments - 1; + } + + segmentationService.jumpToSegmentCenter( + segmentationId, + newSelectedSegmentIndex, + toolGroupId + ); + setSelectedSegment(newSelectedSegmentIndex); + }, + [selectedSegment] + ); + + useEffect(() => { + if (rtIsLoading) { + return; + } + + promptHydrateRT({ + servicesManager, + viewportIndex, + rtDisplaySet, + }).then(isHydrated => { + if (isHydrated) { + setIsHydrated(true); + } + }); + }, [servicesManager, viewportIndex, rtDisplaySet, rtIsLoading]); + + useEffect(() => { + const { unsubscribe } = segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE, + evt => { + if ( + evt.rtDisplaySet.displaySetInstanceUID === + rtDisplaySet.displaySetInstanceUID + ) { + setRtIsLoading(false); + } + + if (evt.overlappingSegments) { + uiNotificationService.show({ + title: 'Overlapping Segments', + message: + 'Overlapping segments detected which is not currently supported', + type: 'warning', + }); + } + } + ); + + return () => { + unsubscribe(); + }; + }, [rtDisplaySet]); + + useEffect(() => { + const { unsubscribe } = segmentationService.subscribe( + segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, + ({ percentComplete, numSegments }) => { + setProcessingProgress({ + percentComplete, + totalSegments: numSegments, + }); + } + ); + + return () => { + unsubscribe(); + }; + }, [rtDisplaySet]); + + /** + Cleanup the SEG viewport when the viewport is destroyed + */ + useEffect(() => { + const onDisplaySetsRemovedSubscription = displaySetService.subscribe( + displaySetService.EVENTS.DISPLAY_SETS_REMOVED, + ({ displaySetInstanceUIDs }) => { + const activeViewport = viewports[activeViewportIndex]; + if ( + displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID) + ) { + viewportGridService.setDisplaySetsForViewport({ + viewportIndex: activeViewportIndex, + displaySetInstanceUIDs: [], + }); + } + } + ); + + return () => { + onDisplaySetsRemovedSubscription.unsubscribe(); + }; + }, []); + + useEffect(() => { + let toolGroup = toolGroupService.getToolGroup(toolGroupId); + + if (toolGroup) { + return; + } + + toolGroup = createRTToolGroupAndAddTools( + toolGroupService, + customizationService, + toolGroupId + ); + + setToolGroupCreated(true); + + return () => { + // remove the segmentation representations if seg displayset changed + segmentationService.removeSegmentationRepresentationFromToolGroup( + toolGroupId + ); + + toolGroupService.destroyToolGroup(toolGroupId); + }; + }, []); + + useEffect(() => { + setIsHydrated(rtDisplaySet.isHydrated); + + return () => { + // remove the segmentation representations if seg displayset changed + segmentationService.removeSegmentationRepresentationFromToolGroup( + toolGroupId + ); + referencedDisplaySetRef.current = null; + }; + }, [rtDisplaySet]); + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + let childrenWithProps = null; + + if ( + !referencedDisplaySetRef.current || + referencedDisplaySet.displaySetInstanceUID !== + referencedDisplaySetRef.current.displaySet.displaySetInstanceUID + ) { + return null; + } + + if (children && children.length) { + childrenWithProps = children.map((child, index) => { + return ( + child && + React.cloneElement(child, { + viewportIndex, + key: index, + }) + ); + }); + } + + const { + PatientID, + PatientName, + PatientSex, + PatientAge, + SliceThickness, + ManufacturerModelName, + StudyDate, + SeriesDescription, + SpacingBetweenSlices, + SeriesNumber, + } = referencedDisplaySetRef.current.metadata; + + const onStatusClick = async () => { + const isHydrated = await _hydrateRTDisplaySet({ + rtDisplaySet, + viewportIndex, + servicesManager, + }); + + setIsHydrated(isHydrated); + }; + + return ( + <> + { + evt.stopPropagation(); + evt.preventDefault(); + }} + onArrowsClick={onSegmentChange} + getStatusComponent={() => { + return _getStatusComponent({ + isHydrated, + onStatusClick, + }); + }} + studyData={{ + label: viewportLabel, + useAltStyling: true, + studyDate: formatDate(StudyDate), + currentSeries: SeriesNumber, + seriesDescription: `RT Viewport ${SeriesDescription}`, + patientInformation: { + patientName: PatientName + ? OHIF.utils.formatPN(PatientName.Alphabetic) + : '', + patientSex: PatientSex || '', + patientAge: PatientAge || '', + MRN: PatientID || '', + thickness: SliceThickness ? `${SliceThickness.toFixed(2)}mm` : '', + spacing: + SpacingBetweenSlices !== undefined + ? `${SpacingBetweenSlices.toFixed(2)}mm` + : '', + scanner: ManufacturerModelName || '', + }, + }} + /> + +
+ {rtIsLoading && ( + + )} + {getCornerstoneViewport()} +
+ {viewportDialogState.viewportIndex === viewportIndex && ( + + )} +
+ {childrenWithProps} +
+ + ); +} + +OHIFCornerstoneRTViewport.propTypes = { + displaySets: PropTypes.arrayOf(PropTypes.object), + viewportIndex: PropTypes.number.isRequired, + dataSource: PropTypes.object, + children: PropTypes.node, + customProps: PropTypes.object, +}; + +OHIFCornerstoneRTViewport.defaultProps = { + customProps: {}, +}; + +function _getReferencedDisplaySetMetadata(referencedDisplaySet) { + const image0 = referencedDisplaySet.images[0]; + const referencedDisplaySetMetadata = { + PatientID: image0.PatientID, + PatientName: image0.PatientName, + PatientSex: image0.PatientSex, + PatientAge: image0.PatientAge, + SliceThickness: image0.SliceThickness, + StudyDate: image0.StudyDate, + SeriesDescription: image0.SeriesDescription, + SeriesInstanceUID: image0.SeriesInstanceUID, + SeriesNumber: image0.SeriesNumber, + ManufacturerModelName: image0.ManufacturerModelName, + SpacingBetweenSlices: image0.SpacingBetweenSlices, + }; + + return referencedDisplaySetMetadata; +} + +export default OHIFCornerstoneRTViewport; diff --git a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx new file mode 100644 index 000000000..fee3d619a --- /dev/null +++ b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon, Tooltip } from '@ohif/ui'; + +export default function _getStatusComponent({ isHydrated, onStatusClick }) { + let ToolTipMessage = null; + let StatusIcon = null; + + const { t } = useTranslation('Common'); + const loadStr = t('LOAD'); + + switch (isHydrated) { + case true: + StatusIcon = () => ; + + ToolTipMessage = () => ( +
This Segmentation is loaded in the segmentation panel
+ ); + break; + case false: + StatusIcon = () => ; + + ToolTipMessage = () =>
Click LOAD to load RTSTRUCT.
; + } + + const StatusArea = () => ( +
+
+ + RTSTRUCT +
+ {!isHydrated && ( +
+ {loadStr} +
+ )} +
+ ); + + return ( + <> + {ToolTipMessage && ( + } position="bottom-left"> + + + )} + {!ToolTipMessage && } + + ); +} diff --git a/extensions/cornerstone-dicom-seg/LICENSE b/extensions/cornerstone-dicom-seg/LICENSE index 898d93d0b..983c5ef34 100644 --- a/extensions/cornerstone-dicom-seg/LICENSE +++ b/extensions/cornerstone-dicom-seg/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Open Health Imaging Foundation +Copyright (c) 2023 Open Health Imaging Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js index de9212d84..b92da5382 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -57,6 +57,7 @@ function _getDisplaySetsFromSeries( wadoRoot, wadoUriRoot, wadoUri, + isOverlayDisplaySet: true, }; const referencedSeriesSequence = instance.ReferencedSeriesSequence; @@ -102,9 +103,12 @@ function _getDisplaySetsFromSeries( function _load(segDisplaySet, servicesManager, extensionManager, headers) { const { SOPInstanceUID } = segDisplaySet; + const { segmentationService } = servicesManager.services; + if ( (segDisplaySet.loading || segDisplaySet.isLoaded) && - loadPromises[SOPInstanceUID] + loadPromises[SOPInstanceUID] && + _segmentationExists(segDisplaySet, segmentationService) ) { return loadPromises[SOPInstanceUID]; } @@ -114,12 +118,6 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) { // We don't want to fire multiple loads, so we'll wait for the first to finish // and also return the same promise to any other callers. loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => { - const { segmentationService } = servicesManager.services; - - if (_segmentationExistsInCache(segDisplaySet, segmentationService)) { - return; - } - if ( !segDisplaySet.segments || Object.keys(segDisplaySet.segments).length === 0 @@ -134,11 +132,8 @@ function _load(segDisplaySet, servicesManager, extensionManager, headers) { } const suppressEvents = true; - segmentationService.createSegmentationForSEGDisplaySet( - segDisplaySet, - null, - suppressEvents - ) + segmentationService + .createSegmentationForSEGDisplaySet(segDisplaySet, null, suppressEvents) .then(() => { segDisplaySet.loading = false; resolve(); @@ -176,12 +171,11 @@ async function _loadSegments(extensionManager, segDisplaySet, headers) { return segments; } -function _segmentationExistsInCache(segDisplaySet, segmentationService) { +function _segmentationExists(segDisplaySet, segmentationService) { // This should be abstracted with the CornerstoneCacheService - const labelmapVolumeId = segDisplaySet.displaySetInstanceUID; - const segVolume = segmentationService.getLabelmapVolume(labelmapVolumeId); - - return segVolume !== undefined; + return segmentationService.getSegmentation( + segDisplaySet.displaySetInstanceUID + ); } function _getPixelData(dataset, segments) { diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index 22a6ec491..54dff2255 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -3,7 +3,9 @@ import React from 'react'; import { Types } from '@ohif/core'; -import getSopClassHandlerModule, { protocols } from './getSopClassHandlerModule'; +import getSopClassHandlerModule, { + protocols, +} from './getSopClassHandlerModule'; import PanelSegmentation from './panels/PanelSegmentation'; import getHangingProtocolModule from './getHangingProtocolModule'; @@ -31,13 +33,17 @@ const extension = { */ id, - /** + /** * PanelModule should provide a list of panels that will be available in OHIF * for Modes to consume and render. Each panel is defined by a {name, * iconName, iconLabel, label, component} object. Example of a panel module * is the StudyBrowserPanel that is provided by the default extension in OHIF. */ - getPanelModule: ({ servicesManager, commandsManager, extensionManager }: Types.Extensions.ExtensionParams): Types.Panel[] => { + getPanelModule: ({ + servicesManager, + commandsManager, + extensionManager, + }): Types.Panel[] => { const wrappedPanelSegmentation = () => { return ( segmentationService.getSegmentations() @@ -62,6 +55,7 @@ export default function PanelSegmentation({ const { unsubscribe } = segmentationService.subscribe(evt, () => { const segmentations = segmentationService.getSegmentations(); setSegmentations(segmentations); + setSegmentationConfiguration(segmentationService.getConfiguration()); }); subscriptions.push(unsubscribe); }); @@ -184,7 +178,7 @@ export default function PanelSegmentation({ segmentationService.toggleSegmentationVisibility(segmentationId); }; - const setSegmentationConfiguration = useCallback( + const _setSegmentationConfiguration = useCallback( (segmentationId, key, value) => { segmentationService.setConfiguration({ segmentationId, @@ -214,54 +208,51 @@ export default function PanelSegmentation({ onToggleSegmentVisibility={onToggleSegmentVisibility} onToggleSegmentationVisibility={onToggleSegmentationVisibility} onToggleMinimizeSegmentation={onToggleMinimizeSegmentation} - segmentationConfig={{ - initialConfig: initialSegmentationConfigurations, - usePercentage: true, - }} + segmentationConfig={{ initialConfig: segmentationConfiguration }} setRenderOutline={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'renderOutline', value ) } setOutlineOpacityActive={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'outlineOpacity', value ) } setRenderFill={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'renderFill', value ) } setRenderInactiveSegmentations={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'renderInactiveSegmentations', value ) } setOutlineWidthActive={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'outlineWidthActive', value ) } setFillAlpha={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'fillAlpha', value ) } setFillAlphaInactive={value => - setSegmentationConfiguration( + _setSegmentationConfiguration( selectedSegmentationId, 'fillAlphaInactive', value diff --git a/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx index 0fbf58b2a..02857e86b 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx @@ -1,5 +1,3 @@ -import React, { useReducer } from 'react'; - // Todo: use defaults in cs3d const initialState = { renderOutline: true, diff --git a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts index fe114dbcd..e032542f1 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts @@ -1,34 +1,12 @@ function createSEGToolGroupAndAddTools( - toolGroupService, - toolGroupId, - extensionManager + ToolGroupService, + customizationService, + toolGroupId ) { - const utilityModule = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.utilityModule.tools' - ); + const { tools } = + customizationService.get('cornerstone.overlayViewportTools') ?? {}; - const { toolNames, Enums } = utilityModule.exports; - - 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.StackScrollMouseWheel, bindings: [] }, - ], - enabled: [{ toolName: toolNames.SegmentationDisplay }], - }; - - return toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); } export default createSEGToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx index f16df5f6b..e901ac0be 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -3,7 +3,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import OHIF, { utils } from '@ohif/core'; import { - LoadingIndicatorProgress, Notification, useViewportDialog, useViewportGrid, ViewportActionBar + LoadingIndicatorTotalPercent, + Notification, + useViewportDialog, + useViewportGrid, + ViewportActionBar, } from '@ohif/ui'; import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup'; import promptHydrateSEG from '../utils/promptHydrateSEG'; @@ -31,6 +35,7 @@ function OHIFCornerstoneSEGViewport(props) { toolGroupService, segmentationService, uiNotificationService, + customizationService, } = servicesManager.services; const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportIndex}`; @@ -58,7 +63,7 @@ function OHIFCornerstoneSEGViewport(props) { const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded); const [element, setElement] = useState(null); const [processingProgress, setProcessingProgress] = useState({ - segmentIndex: 1, + percentComplete: null, totalSegments: null, }); @@ -129,6 +134,8 @@ function OHIFCornerstoneSEGViewport(props) { let newSelectedSegmentIndex = selectedSegment + direction; + // Segment 0 is always background + if (newSelectedSegmentIndex > numberOfSegments - 1) { newSelectedSegmentIndex = 1; } else if (newSelectedSegmentIndex === 0) { @@ -163,7 +170,7 @@ function OHIFCornerstoneSEGViewport(props) { useEffect(() => { const { unsubscribe } = segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, + segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE, evt => { if ( evt.segDisplaySet.displaySetInstanceUID === @@ -190,10 +197,10 @@ function OHIFCornerstoneSEGViewport(props) { useEffect(() => { const { unsubscribe } = segmentationService.subscribe( - segmentationService.EVENTS.SEGMENT_PIXEL_DATA_CREATED, - ({ segmentIndex, numSegments }) => { + segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, + ({ percentComplete, numSegments }) => { setProcessingProgress({ - segmentIndex, + percentComplete, totalSegments: numSegments, }); } @@ -239,8 +246,8 @@ function OHIFCornerstoneSEGViewport(props) { // only, and does NOT interfere with currently displayed segmentations. toolGroup = createSEGToolGroupAndAddTools( toolGroupService, - toolGroupId, - extensionManager + customizationService, + toolGroupId ); setToolGroupCreated(true); @@ -351,27 +358,11 @@ function OHIFCornerstoneSEGViewport(props) {
{segIsLoading && ( - Loading SEG ... - ) : ( - -
Loading Segment
-
{`${processingProgress.segmentIndex}`}
-
/
-
{`${processingProgress.totalSegments}`}
-
- ) - } + totalNumbers={processingProgress.totalSegments} + percentComplete={processingProgress.percentComplete} + loadingText="Loading SEG..." /> )} {getCornerstoneViewport()} diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 2dc6b9edc..34aa3e523 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -47,6 +47,6 @@ "classnames": "^2.3.2", "@cornerstonejs/adapters": "^0.6.0", "@cornerstonejs/core": "^0.42.2", - "@cornerstonejs/tools": "^0.61.11" + "@cornerstonejs/tools": "^0.63.2" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 526074be1..d6a6a3ee1 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -50,8 +50,8 @@ "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^0.6.0", "@cornerstonejs/core": "^0.42.2", - "@cornerstonejs/streaming-image-volume-loader": "^0.16.0", - "@cornerstonejs/tools": "^0.61.11", + "@cornerstonejs/streaming-image-volume-loader": "^0.16.2", + "@cornerstonejs/tools": "^0.63.2", "@kitware/vtk.js": "26.5.6", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index f3e061900..d74a3b99b 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -630,19 +630,19 @@ function _jumpToMeasurement( } else { // for volume viewport we can't rely on the imageIdIndex since it can be // a reconstructed view that doesn't match the original slice numbers etc. - const { viewPlaneNormal } = measurement.metadata; + const { viewPlaneNormal: measurementViewPlane } = measurement.metadata; imageIdIndex = referencedDisplaySet.images.findIndex( i => i.SOPInstanceUID === SOPInstanceUID ); - const { orientation } = viewportInfo.getViewportOptions(); + const { viewPlaneNormal: viewportViewPlane } = viewport.getCamera(); + // should compare abs for both planes since the direction can be flipped if ( - orientation && - viewPlaneNormal && + measurementViewPlane && !csUtils.isEqual( - CONSTANTS.MPR_CAMERA_VALUES[orientation]?.viewPlaneNormal, - viewPlaneNormal + measurementViewPlane.map(Math.abs), + viewportViewPlane.map(Math.abs) ) ) { viewportCameraDirectionMatch = false; diff --git a/extensions/cornerstone/src/getCustomizationModule.ts b/extensions/cornerstone/src/getCustomizationModule.ts new file mode 100644 index 000000000..fa548b74a --- /dev/null +++ b/extensions/cornerstone/src/getCustomizationModule.ts @@ -0,0 +1,37 @@ +import { Enums } from '@cornerstonejs/tools'; +import { toolNames } from './initCornerstoneTools'; + +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.StackScrollMouseWheel, bindings: [] }, + ], + enabled: [{ toolName: toolNames.SegmentationDisplay }], +}; + +function getCustomizationModule() { + return [ + { + name: 'default', + value: [ + { + id: 'cornerstone.overlayViewportTools', + tools, + }, + ], + }, + ]; +} + +export default getCustomizationModule; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 29387df19..643022e15 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -10,6 +10,7 @@ import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; import { ServicesManager, Types } from '@ohif/core'; import init from './init'; +import getCustomizationModule from './getCustomizationModule'; import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import ToolGroupService from './services/ToolGroupService'; @@ -108,6 +109,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { ]; }, getCommandsModule, + getCustomizationModule, getUtilityModule({ servicesManager }) { return [ { @@ -139,5 +141,5 @@ const cornerstoneExtension: Types.Extensions.Extension = { }; export type { PublicViewportOptions }; -export { measurementMappingUtils, CornerstoneExtensionTypes }; +export { measurementMappingUtils, CornerstoneExtensionTypes, toolNames }; export default cornerstoneExtension; diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 27ca02ee7..377cbbe82 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -1,4 +1,4 @@ -import OHIF from '@ohif/core'; +import OHIF, { Types } from '@ohif/core'; import React from 'react'; import * as cornerstone from '@cornerstonejs/core'; diff --git a/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts b/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts new file mode 100644 index 000000000..f17ec5c70 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/RTSTRUCT/mapROIContoursToRTStructData.ts @@ -0,0 +1,38 @@ +/** + * 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}`, + }; + } + ); +} diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index d4c25bee4..b9882e3ed 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -3,10 +3,11 @@ import cloneDeep from 'lodash.clonedeep'; import { Types as OhifTypes, ServicesManager, PubSubService } from '@ohif/core'; import { cache, + Enums as csEnums, + geometryLoader, eventTarget, getEnabledElementByIds, metaData, - Types, utilities as csUtils, volumeLoader, } from '@cornerstonejs/core'; @@ -18,15 +19,18 @@ import { utilities as cstUtils, } from '@cornerstonejs/tools'; import isEqual from 'lodash.isequal'; -import { easeInOutBell } from '../../utils/transitions'; +import { Types as ohifTypes } from '@ohif/core'; +import { easeInOutBell, reverseEaseInOutBell } from '../../utils/transitions'; import { + Segment, Segmentation, SegmentationConfig, - SegmentationSchema, } from './SegmentationServiceTypes'; +import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStructData'; const { COLOR_LUT } = cstConstants; const LABELMAP = csToolsEnums.SegmentationRepresentations.Labelmap; +const CONTOUR = csToolsEnums.SegmentationRepresentations.Contour; const EVENTS = { // fired when the segmentation is updated (e.g. when a segment is added, removed, or modified, locked, visibility changed etc.) @@ -40,13 +44,22 @@ const EVENTS = { // fired when the configuration for the segmentation is changed (e.g., brush size, render fill, outline thickness, etc.) SEGMENTATION_CONFIGURATION_CHANGED: 'event::segmentation_configuration_changed', - SEGMENT_PIXEL_DATA_CREATED: 'event::segment_pixel_data_created', + // fired when the active segment is loaded in SEG or RTSTRUCT + SEGMENT_LOADING_COMPLETE: 'event::segment_loading_complete', // for all segments - SEGMENTATION_PIXEL_DATA_CREATED: 'event::segmentation_pixel_data_created', + SEGMENTATION_LOADING_COMPLETE: 'event::segmentation_loading_complete', }; const VALUE_TYPES = {}; +const SEGMENT_CONSTANT = { + opacity: 255, + isVisible: true, + isLocked: false, +}; + +const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume'; + class SegmentationService extends PubSubService { static REGISTRATION = { name: 'segmentationService', @@ -75,7 +88,7 @@ class SegmentationService extends PubSubService { public destroy = () => { eventTarget.removeEventListener( csToolsEnums.Events.SEGMENTATION_MODIFIED, - this._onSegmentationModified + this._onSegmentationModifiedFromSource ); eventTarget.removeEventListener( @@ -112,7 +125,7 @@ class SegmentationService extends PubSubService { toolGroupId?: string, properties?: { label?: string; - color?: Types.Point3; + color?: ohifTypes.RGB; opacity?: number; visibility?: boolean; isLocked?: boolean; @@ -239,7 +252,8 @@ class SegmentationService extends PubSubService { // Todo: handle other segmentations other than labelmap const labelmapVolume = this.getLabelmapVolume(segmentationId); - const { scalarData, dimensions } = labelmapVolume; + const { dimensions } = labelmapVolume; + const scalarData = labelmapVolume.getScalarData(); // Set all values of this segment to zero and get which frames have been edited. const frameLength = dimensions[0] * dimensions[1]; @@ -324,7 +338,7 @@ class SegmentationService extends PubSubService { public setSegmentColor( segmentationId: string, segmentIndex: number, - color: Types.Point3, + color: ohifTypes.RGB, toolGroupId?: string ): void { this._setSegmentColor(segmentationId, segmentIndex, color, toolGroupId); @@ -438,15 +452,15 @@ class SegmentationService extends PubSubService { } public addOrUpdateSegmentation( - segmentationSchema: SegmentationSchema, + segmentation: Segmentation, suppressEvents = false, notYetUpdatedAtSource = false ): string { - const { id: segmentationId } = segmentationSchema; - let segmentation = this.segmentations[segmentationId]; - if (segmentation) { + const { id: segmentationId } = segmentation; + let cachedSegmentation = this.segmentations[segmentationId]; + if (cachedSegmentation) { // Update the segmentation (mostly for assigning metadata/labels) - Object.assign(segmentation, segmentationSchema); + Object.assign(cachedSegmentation, segmentation); this._updateCornerstoneSegmentations({ segmentationId, @@ -455,53 +469,47 @@ class SegmentationService extends PubSubService { if (!suppressEvents) { this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { - segmentation, + segmentation: cachedSegmentation, }); } return segmentationId; } - // Add the segmentation otherwise + const representationType = segmentation.type; + const representationData = + segmentation.representationData[representationType]; cstSegmentation.addSegmentations([ { segmentationId, representation: { - type: LABELMAP, - // Todo: need to be generalized + type: representationType, data: { - volumeId: segmentationId, + ...representationData, }, }, }, ]); // Define a new color LUT and associate it with this segmentation. - // Todo: need to be generalized to accept custom color LUTs const newColorLUT = this.generateNewColorLUT(); const newColorLUTIndex = this.getNextColorLUTIndex(); cstSegmentation.config.color.addColorLUT(newColorLUT, newColorLUTIndex); - if ( - segmentationSchema.label === undefined || - segmentationSchema.label === '' - ) { - segmentationSchema.label = 'Segmentation'; - } - this.segmentations[segmentationId] = { - ...segmentationSchema, - segments: segmentationSchema.segments || [null], - activeSegmentIndex: segmentationSchema.activeSegmentIndex ?? null, - segmentCount: segmentationSchema.segmentCount ?? 0, + ...segmentation, + label: segmentation.label || '', + segments: segmentation.segments || [null], + activeSegmentIndex: segmentation.activeSegmentIndex ?? null, + segmentCount: segmentation.segmentCount ?? 0, isActive: false, colorLUTIndex: newColorLUTIndex, isVisible: true, }; - segmentation = this.segmentations[segmentationId]; + cachedSegmentation = this.segmentations[segmentationId]; this._updateCornerstoneSegmentations({ segmentationId, @@ -510,11 +518,11 @@ class SegmentationService extends PubSubService { if (!suppressEvents) { this._broadcastEvent(this.EVENTS.SEGMENTATION_ADDED, { - segmentation, + segmentation: cachedSegmentation, }); } - return segmentation.id; + return cachedSegmentation.id; } public async createSegmentationForSEGDisplaySet( @@ -522,7 +530,38 @@ class SegmentationService extends PubSubService { segmentationId?: string, suppressEvents = false ): Promise { + // Todo: we only support creating labelmap for SEG displaySets for now + const representationType = LABELMAP; + segmentationId = segmentationId ?? segDisplaySet.displaySetInstanceUID; + + const defaultScheme = this._getDefaultSegmentationScheme(); + + const segmentation: Segmentation = { + ...defaultScheme, + id: segmentationId, + displaySetInstanceUID: segDisplaySet.displaySetInstanceUID, + type: representationType, + representationData: { + [LABELMAP]: { + volumeId: segmentationId, + referencedVolumeId: segDisplaySet.referencedVolumeId, + }, + }, + }; + + const labelmap = this.getLabelmapVolume(segmentationId); + const cachedSegmentation = this.getSegmentation(segmentationId); + if (labelmap && cachedSegmentation) { + // if the labelmap with the same segmentationId already exists, we can + // just assume that the segmentation is already created and move on with + // updating the state + return this.addOrUpdateSegmentation( + Object.assign(segmentation, cachedSegmentation), + suppressEvents + ); + } + const { segments, referencedVolumeId } = segDisplaySet; if (!segments || !referencedVolumeId) { @@ -531,38 +570,8 @@ class SegmentationService extends PubSubService { ); } - const segmentationSchema: SegmentationSchema = { - id: segmentationId, - volumeId: segmentationId, - displaySetInstanceUID: segDisplaySet.displaySetInstanceUID, - referencedVolumeURI: segDisplaySet.referencedVolumeURI, - activeSegmentIndex: 1, - cachedStats: {}, - label: '', - segmentsLocked: [], - type: LABELMAP, - displayText: [], - hydrated: false, // by default we don't hydrate the segmentation for SEG displaySets - segmentCount: 0, - segments: [], - }; - - const labelmap = this.getLabelmapVolume(segmentationId); - const segmentation = this.getSegmentation(segmentationId); - - if (labelmap && segmentation) { - // if the labalemap with the same segmentationId already exists, we can - // just assume that the segmentation is already created and move on with - // updating the state - return this.addOrUpdateSegmentation( - Object.assign(segmentationSchema, segmentation), - suppressEvents - ); - } - // if the labelmap doesn't exist, we need to create it first from the // DICOM SEG displaySet data - const referencedVolume = cache.getVolume(referencedVolumeId); if (!referencedVolume) { @@ -584,7 +593,7 @@ class SegmentationService extends PubSubService { } ); const [rows, columns] = derivedVolume.dimensions; - const derivedVolumeScalarData = derivedVolume.scalarData; + const derivedVolumeScalarData = derivedVolume.getScalarData(); const { imageIds } = referencedVolume; const sopUIDImageIdIndexMap = imageIds.reduce((acc, imageId, index) => { @@ -667,26 +676,34 @@ class SegmentationService extends PubSubService { const centerWorld = derivedVolume.imageData.indexToWorld([x, y, z]); - segmentationSchema.cachedStats = { - ...segmentationSchema.cachedStats, + segmentation.cachedStats = { + ...segmentation.cachedStats, segmentCenter: { - ...segmentationSchema.cachedStats.segmentCenter, + ...segmentation.cachedStats.segmentCenter, [segmentIndex]: { center: { image: [x, y, z], world: centerWorld, }, - modifiedTime: Date.now(), + modifiedTime: segDisplaySet.SeriesDate, }, }, }; - this._broadcastEvent(EVENTS.SEGMENT_PIXEL_DATA_CREATED, { - segmentIndex: Number(segmentIndex), - numSegments, + const numInitialized = Object.keys(segmentation.cachedStats.segmentCenter) + .length; + + // Calculate percentage completed + const percentComplete = Math.round((numInitialized / numSegments) * 100); + + this._broadcastEvent(EVENTS.SEGMENT_LOADING_COMPLETE, { + percentComplete, + numSegments: numSegments, }); }; + const promiseArray = []; + for (const segmentIndex in segments) { const segmentInfo = segments[segmentIndex]; @@ -700,17 +717,19 @@ class SegmentationService extends PubSubService { }, 0); }); - await promise; + promiseArray.push(promise); } - segmentationSchema.segmentCount = Object.keys(segments).length; - segmentationSchema.segments = [null]; // segment 0 + await Promise.all(promiseArray); + + segmentation.segmentCount = Object.keys(segments).length; + segmentation.segments = [null]; // segment 0 Object.keys(segments).forEach(segmentIndex => { const segmentInfo = segments[segmentIndex]; const segIndex = Number(segmentIndex); - segmentationSchema.segments[segIndex] = { + segmentation.segments[segIndex] = { label: segmentInfo.label || `Segment ${segIndex}`, segmentIndex: Number(segmentIndex), color: [ @@ -726,13 +745,151 @@ class SegmentationService extends PubSubService { segDisplaySet.isLoaded = true; - this._broadcastEvent(EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, { + this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, { segmentationId, segDisplaySet, overlappingSegments, }); - return this.addOrUpdateSegmentation(segmentationSchema, suppressEvents); + return this.addOrUpdateSegmentation(segmentation, suppressEvents); + } + + public async createSegmentationForRTDisplaySet( + rtDisplaySet, + segmentationId?: string, + suppressEvents = false + ): Promise { + // Todo: we currently only have support for contour representation for initial + // RT display + const representationType = CONTOUR; + segmentationId = segmentationId ?? rtDisplaySet.displaySetInstanceUID; + const { structureSet } = rtDisplaySet; + + if (!structureSet) { + throw new Error( + 'To create the contours from RT displaySet, the displaySet should be loaded first, you can perform rtDisplaySet.load() before calling this method.' + ); + } + + const defaultScheme = this._getDefaultSegmentationScheme(); + const rtDisplaySetUID = rtDisplaySet.displaySetInstanceUID; + + const allRTStructData = mapROIContoursToRTStructData( + structureSet, + rtDisplaySetUID + ); + + // sort by segmentIndex + allRTStructData.sort((a, b) => a.segmentIndex - b.segmentIndex); + + const geometryIds = allRTStructData.map(({ geometryId }) => geometryId); + + const segmentation: Segmentation = { + ...defaultScheme, + id: segmentationId, + displaySetInstanceUID: rtDisplaySetUID, + type: representationType, + representationData: { + [CONTOUR]: { + geometryIds, + }, + }, + }; + + const cachedSegmentation = this.getSegmentation(segmentationId); + + if (cachedSegmentation) { + // if the labelmap with the same segmentationId already exists, we can + // just assume that the segmentation is already created and move on with + // updating the state + return this.addOrUpdateSegmentation( + Object.assign(segmentation, cachedSegmentation), + suppressEvents + ); + } + + if (!structureSet.ROIContours?.length) { + throw new Error( + 'The structureSet does not contain any ROIContours. Please ensure the structureSet is loaded first.' + ); + } + const segmentsCachedStats = {}; + const initializeContour = async rtStructData => { + const { data, id, color, segmentIndex, geometryId } = rtStructData; + const geometry = await geometryLoader.createAndCacheGeometry(geometryId, { + geometryData: { + data, + id, + color, + frameOfReferenceUID: structureSet.frameOfReferenceUID, + segmentIndex, + }, + type: csEnums.GeometryType.CONTOUR, + }); + + const contourSet = geometry.data; + const centroid = contourSet.getCentroid(); + + segmentsCachedStats[segmentIndex] = { + center: { world: centroid }, + modifiedTime: rtDisplaySet.SeriesDate, // we use the SeriesDate as the modifiedTime since this is the first time we are creating the segmentation + }; + + segmentation.segments[segmentIndex] = { + label: id, + segmentIndex, + color, + ...SEGMENT_CONSTANT, + }; + + const numInitialized = Object.keys(segmentsCachedStats).length; + + // Calculate percentage completed + const percentComplete = Math.round( + (numInitialized / allRTStructData.length) * 100 + ); + + this._broadcastEvent(EVENTS.SEGMENT_LOADING_COMPLETE, { + percentComplete, + // Note: this is not the geometryIds length since there might be + // some missing ROINumbers + numSegments: allRTStructData.length, + }); + }; + + const promiseArray = []; + + for (let i = 0; i < allRTStructData.length; i++) { + const promise = new Promise((resolve, reject) => { + setTimeout(() => { + initializeContour(allRTStructData[i]).then(() => { + resolve(); + }); + }, 0); + }); + + promiseArray.push(promise); + } + + await Promise.all(promiseArray); + + segmentation.segmentCount = allRTStructData.length; + rtDisplaySet.isLoaded = true; + + segmentation.cachedStats = { + ...segmentation.cachedStats, + segmentCenter: { + ...segmentation.cachedStats.segmentCenter, + ...segmentsCachedStats, + }, + }; + + this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, { + segmentationId, + rtDisplaySet, + }); + + return this.addOrUpdateSegmentation(segmentation, suppressEvents); } public jumpToSegmentCenter( @@ -743,7 +900,7 @@ class SegmentationService extends PubSubService { highlightSegment = true, animationLength = 750, highlightHideOthers = false, - highlightFunctionType: 'ease-in-out' // todo: make animation functions configurable from outside + highlightFunctionType = 'ease-in-out' // todo: make animation functions configurable from outside ): void { const { toolGroupService } = this.servicesManager.services; const center = this._getSegmentCenter(segmentationId, segmentIndex); @@ -797,7 +954,7 @@ class SegmentationService extends PubSubService { alpha = 0.9, animationLength = 750, hideOthers = true, - highlightFunctionType: 'ease-in-out' + highlightFunctionType = 'ease-in-out' ): void { if (this.highlightIntervalId) { clearInterval(this.highlightIntervalId); @@ -811,61 +968,25 @@ class SegmentationService extends PubSubService { toolGroupId ); + const { type } = segmentationRepresentation; const { segments } = segmentation; - const newSegmentSpecificConfig = { - [segmentIndex]: { - LABELMAP: { - fillAlpha: alpha, - }, - }, - }; + const highlightFn = + type === LABELMAP + ? this._highlightLabelmap.bind(this) + : this._highlightContour.bind(this); - if (hideOthers) { - for (let i = 0; i < segments.length; i++) { - if (i !== segmentIndex) { - newSegmentSpecificConfig[i] = { - LABELMAP: { - fillAlpha: 0, - }, - }; - } - } - } + const adjustedAlpha = type === LABELMAP ? alpha : 1 - alpha; - const { fillAlpha } = this.getConfiguration(toolGroupId); - - let count = 0; - const intervalTime = 16; - const numberOfFrames = Math.ceil(animationLength / intervalTime); - - this.highlightIntervalId = setInterval(() => { - const x = (count * intervalTime) / animationLength; - cstSegmentation.config.setSegmentSpecificConfig( - toolGroupId, - segmentationRepresentation.segmentationRepresentationUID, - { - [segmentIndex]: { - LABELMAP: { - fillAlpha: easeInOutBell(x, fillAlpha), - }, - }, - } - ); - - count++; - - if (count === numberOfFrames) { - clearInterval(this.highlightIntervalId); - cstSegmentation.config.setSegmentSpecificConfig( - toolGroupId, - segmentationRepresentation.segmentationRepresentationUID, - {} - ); - - this.highlightIntervalId = null; - } - }, intervalTime); + highlightFn( + segmentIndex, + adjustedAlpha, + hideOthers, + segments, + toolGroupId, + animationLength, + segmentationRepresentation + ); } public createSegmentationForDisplaySet = async ( @@ -875,8 +996,10 @@ class SegmentationService extends PubSubService { label: string; } ): Promise => { - const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use - const volumeId = `${volumeLoaderScheme}:${displaySetInstanceUID}`; // VolumeId with loader id + volume id + // Todo: we currently only support labelmap for segmentation for a displaySet + const representationType = LABELMAP; + + const volumeId = this._getVolumeIdForDisplaySet(displaySetInstanceUID); const segmentationId = options?.segmentationId ?? `${csUtils.uuidv4()}`; @@ -890,23 +1013,25 @@ class SegmentationService extends PubSubService { }, }); - const segmentationSchema: SegmentationSchema = { + const defaultScheme = this._getDefaultSegmentationScheme(); + + const segmentation: Segmentation = { + ...defaultScheme, id: segmentationId, - volumeId: segmentationId, displaySetInstanceUID, - referencedVolumeURI: volumeId.split(':')[0], // Todo: this is so ugly - activeSegmentIndex: 1, - cachedStats: {}, label: options?.label, - segmentsLocked: [], - type: LABELMAP, - displayText: [], - hydrated: false, - segmentCount: 0, - segments: [], + // We should set it as active by default, as it created for display + isActive: true, + type: representationType, + representationData: { + LABELMAP: { + volumeId: segmentationId, + referencedVolumeId: volumeId, // Todo: this is so ugly + }, + }, }; - this.addOrUpdateSegmentation(segmentationSchema); + this.addOrUpdateSegmentation(segmentation); return segmentationId; }; @@ -1085,21 +1210,131 @@ class SegmentationService extends PubSubService { } }; + private _highlightLabelmap( + segmentIndex: number, + alpha: number, + hideOthers: boolean, + segments: Segment[], + toolGroupId: string, + animationLength: number, + segmentationRepresentation: cstTypes.ToolGroupSpecificRepresentation + ) { + const newSegmentSpecificConfig = { + [segmentIndex]: { + LABELMAP: { + fillAlpha: alpha, + }, + }, + }; + + if (hideOthers) { + for (let i = 0; i < segments.length; i++) { + if (i !== segmentIndex) { + newSegmentSpecificConfig[i] = { + LABELMAP: { + fillAlpha: 0, + }, + }; + } + } + } + + const { fillAlpha } = this.getConfiguration(toolGroupId); + + let count = 0; + const intervalTime = 16; + const numberOfFrames = Math.ceil(animationLength / intervalTime); + + this.highlightIntervalId = setInterval(() => { + const x = (count * intervalTime) / animationLength; + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + { + [segmentIndex]: { + LABELMAP: { + fillAlpha: easeInOutBell(x, fillAlpha), + }, + }, + } + ); + + count++; + + if (count === numberOfFrames) { + clearInterval(this.highlightIntervalId); + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + {} + ); + + this.highlightIntervalId = null; + } + }, intervalTime); + } + + private _highlightContour( + segmentIndex: number, + alpha: number, + hideOthers: boolean, + segments: Segment[], + toolGroupId: string, + animationLength: number, + segmentationRepresentation: cstTypes.ToolGroupSpecificRepresentation + ) { + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const progress = (currentTime - startTime) / animationLength; + if (progress >= 1) { + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + {} + ); + return; + } + + const reversedProgress = reverseEaseInOutBell(progress, 0.1); + cstSegmentation.config.setSegmentSpecificConfig( + toolGroupId, + segmentationRepresentation.segmentationRepresentationUID, + { + [segmentIndex]: { + CONTOUR: { + fillAlpha: reversedProgress, + }, + }, + } + ); + + requestAnimationFrame(animate); + }; + + requestAnimationFrame(animate); + } + public removeSegmentationRepresentationFromToolGroup( toolGroupId: string, - segmentationIds?: string[] + segmentationRepresentationUIDsIds?: string[] ): void { - segmentationIds = - segmentationIds ?? - cstSegmentation.state - .getSegmentationRepresentations(toolGroupId) - .map(rep => rep.segmentationRepresentationUID); + const uids = segmentationRepresentationUIDsIds || []; + if (!uids.length) { + const representations = cstSegmentation.state.getSegmentationRepresentations( + toolGroupId + ); - cstSegmentation.removeSegmentationsFromToolGroup( - toolGroupId, - segmentationIds, - true // immediate render - ); + if (!representations || !representations.length) { + return; + } + + uids.push( + ...representations.map(rep => rep.segmentationRepresentationUID) + ); + } + + cstSegmentation.removeSegmentationsFromToolGroup(toolGroupId, uids); } /** @@ -1162,10 +1397,16 @@ class SegmentationService extends PubSubService { // toolGroupId // ); + const segmentationRepresentations = this.getSegmentationRepresentationsForToolGroup( + toolGroupId + ); + + const typeToUse = segmentationRepresentations?.[0]?.type || LABELMAP; + const config = cstSegmentation.config.getGlobalConfig(); const { renderInactiveSegmentations } = config; - const labelmapRepresentationConfig = config.representations.LABELMAP; + const representation = config.representations[typeToUse]; const { renderOutline, @@ -1175,7 +1416,7 @@ class SegmentationService extends PubSubService { fillAlphaInactive, outlineOpacity, outlineOpacityInactive, - } = labelmapRepresentationConfig; + } = representation; return { brushSize, @@ -1204,48 +1445,33 @@ class SegmentationService extends PubSubService { renderOutline, } = configuration; - if (renderOutline !== undefined) { - this._setLabelmapConfigValue('renderOutline', renderOutline); - } + const setConfigValueIfDefined = (key, value, transformFn = null) => { + if (value !== undefined) { + const transformedValue = transformFn ? transformFn(value) : value; + this._setSegmentationConfig(key, transformedValue); + } + }; - if (outlineWidthActive !== undefined) { - this._setLabelmapConfigValue('outlineWidthActive', outlineWidthActive); - // this._setLabelmapConfigValue('outlineWidthInactive', outlineWidthActive); - } - - if (outlineOpacity !== undefined) { - this._setLabelmapConfigValue('outlineOpacity', outlineOpacity / 100); - } - - if (fillAlpha !== undefined) { - this._setLabelmapConfigValue('fillAlpha', fillAlpha / 100); - } - - if (renderFill !== undefined) { - this._setLabelmapConfigValue('renderFill', renderFill); - } + setConfigValueIfDefined('renderOutline', renderOutline); + setConfigValueIfDefined('outlineWidthActive', outlineWidthActive); + setConfigValueIfDefined('outlineOpacity', outlineOpacity, v => v / 100); + setConfigValueIfDefined('fillAlpha', fillAlpha, v => v / 100); + setConfigValueIfDefined('renderFill', renderFill); + setConfigValueIfDefined( + 'fillAlphaInactive', + fillAlphaInactive, + v => v / 100 + ); + setConfigValueIfDefined('outlineOpacityInactive', fillAlphaInactive, v => + Math.max(0.75, v / 100) + ); if (renderInactiveSegmentations !== undefined) { const config = cstSegmentation.config.getGlobalConfig(); - config.renderInactiveSegmentations = renderInactiveSegmentations; cstSegmentation.config.setGlobalConfig(config); } - if (fillAlphaInactive !== undefined) { - this._setLabelmapConfigValue( - 'fillAlphaInactive', - fillAlphaInactive / 100 - ); - - // we assume that if the user changes the inactive fill alpha, they - // want the inactive outline to be also changed - this._setLabelmapConfigValue( - 'outlineOpacityInactive', - Math.max(0.75, fillAlphaInactive / 100) // don't go below 0.7 for outline - ); - } - // if (brushSize !== undefined) { // const { toolGroupService } = this.servicesManager.services; @@ -1320,6 +1546,7 @@ class SegmentationService extends PubSubService { }); } } + public shouldRenderSegmentation( viewportDisplaySetInstanceUIDs, segDisplaySetInstanceUID @@ -1339,11 +1566,13 @@ class SegmentationService extends PubSubService { segDisplaySetInstanceUID ); - const segFrameOfReferenceUID = segDisplaySet.instance?.FrameOfReferenceUID; + const segFrameOfReferenceUID = this._getFrameOfReferenceUIDForSeg( + segDisplaySet + ); - viewportDisplaySetInstanceUIDs.forEach(displaySetInstanceUID => { - // check if the displaySet is sharing the same frameOfReferenceUID - // with the new segmentation + // check if the displaySet is sharing the same frameOfReferenceUID + // with the new segmentation + for (const displaySetInstanceUID of viewportDisplaySetInstanceUIDs) { const displaySet = displaySetService.getDisplaySetByUID( displaySetInstanceUID ); @@ -1355,12 +1584,29 @@ class SegmentationService extends PubSubService { displaySet?.images?.[0]?.FrameOfReferenceUID === segFrameOfReferenceUID ) { shouldDisplaySeg = true; + break; } - }); + } return shouldDisplaySeg; } + private _getDefaultSegmentationScheme() { + return { + activeSegmentIndex: 1, + cachedStats: {}, + label: '', + segmentsLocked: [], + displayText: [], + hydrated: false, // by default we don't hydrate the segmentation for SEG displaySets + segmentCount: 0, + segments: [], + isVisible: true, + isActive: false, + colorLUTIndex: 0, + }; + } + private _setActiveSegmentationForToolGroup( segmentationId: string, toolGroupId: string, @@ -1454,10 +1700,17 @@ class SegmentationService extends PubSubService { } } + private _getVolumeIdForDisplaySet(displaySet) { + const volumeLoaderSchema = + displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME; + + return `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`; + } + private _setSegmentColor = ( segmentationId: string, segmentIndex: number, - color: Types.Point3, + color: ohifTypes.RGB, toolGroupId?: string, suppressEvents = false ) => { @@ -1723,12 +1976,16 @@ class SegmentationService extends PubSubService { return representation; } - private _setLabelmapConfigValue = (property, value) => { + private _setSegmentationConfig = (property, value) => { + // Todo: currently we only support global config, and we get the type + // from the first segmentation + const typeToUse = this.getSegmentations()[0].type; + const { cornerstoneViewportService } = this.servicesManager.services; const config = cstSegmentation.config.getGlobalConfig(); - config.representations.LABELMAP[property] = value; + config.representations[typeToUse][property] = value; // Todo: add non global (representation specific config as well) cstSegmentation.config.setGlobalConfig(config); @@ -1743,7 +2000,7 @@ class SegmentationService extends PubSubService { // Connect Segmentation Service to Cornerstone3D. eventTarget.addEventListener( csToolsEnums.Events.SEGMENTATION_MODIFIED, - this._onSegmentationModified + this._onSegmentationModifiedFromSource ); eventTarget.addEventListener( @@ -1767,7 +2024,7 @@ class SegmentationService extends PubSubService { }); }; - private _onSegmentationModified = evt => { + private _onSegmentationModifiedFromSource = evt => { const { segmentationId } = evt.detail; const segmentation = this.segmentations[segmentationId]; @@ -1785,23 +2042,25 @@ class SegmentationService extends PubSubService { return; } - if (!Object.keys(segmentationState.representationData).includes(LABELMAP)) { - throw new Error('Non-labelmap representations are not supported yet'); - } - const { activeSegmentIndex, cachedStats, segmentsLocked, - representationData, label, type, } = segmentationState; - const labelmapRepresentationData = representationData[LABELMAP]; + if (![LABELMAP, CONTOUR].includes(type)) { + throw new Error( + `Unsupported segmentation type: ${type}. Only ${LABELMAP} and ${CONTOUR} are supported.` + ); + } + + const representationData = segmentationState.representationData[type]; // TODO: handle other representations when available in cornerstone3D const segmentationSchema = { + ...segmentation, activeSegmentIndex, cachedStats, displayText: [], @@ -1809,7 +2068,11 @@ class SegmentationService extends PubSubService { label, segmentsLocked, type, - volumeId: labelmapRepresentationData.volumeId, + representationData: { + [type]: { + ...representationData, + }, + }, }; try { @@ -1881,7 +2144,7 @@ class SegmentationService extends PubSubService { // cleanup the segmentation state too segmentationState.removeSegmentation(segmentationId); - if (removeFromCache) { + if (removeFromCache && cache.getVolumeLoadObject(segmentationId)) { cache.removeVolumeLoadObject(segmentationId); } } @@ -1965,6 +2228,22 @@ class SegmentationService extends PubSubService { return toolGroupIds; } + private _getFrameOfReferenceUIDForSeg(displaySet) { + const frameOfReferenceUID = displaySet.instance?.FrameOfReferenceUID; + + if (frameOfReferenceUID) { + return frameOfReferenceUID; + } + + // if not found we should try the ReferencedFrameOfReferenceSequence + const referencedFrameOfReferenceSequence = + displaySet.instance?.ReferencedFrameOfReferenceSequence; + + if (referencedFrameOfReferenceSequence) { + return referencedFrameOfReferenceSequence.FrameOfReferenceUID; + } + } + private _getFirstToolGroupId = () => { const { toolGroupService } = this.servicesManager.services; const toolGroupIds = toolGroupService.getToolGroupIds(); diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts index 3d4066d09..e170197c3 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts @@ -29,6 +29,8 @@ type Segmentation = { colorLUTIndex: number; // if segmentation contains any data (often calculated from labelmap) cachedStats: Record; + // displaySetInstanceUID + displaySetInstanceUID: string; // displayText is the text that is displayed on the segmentation panel (often derived from the data) displayText?: string[]; // the id of the segmentation @@ -45,44 +47,22 @@ type Segmentation = { segments: Array; // the set of segments that are locked segmentsLocked: Array; - // the segmentation representation type - type: csToolsEnums.SegmentationRepresentations; - // if labelmap, the id of the volume that the labelmap is associated with - volumeId?: string; // whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport // but hydrated SEG -> segmentation that is persisted in the store) hydrated: boolean; -}; - -// Schema to generate a segmentation -type SegmentationSchema = { - // active segment index for the segmentation - activeSegmentIndex: number; - // statistics that are derived from the segmentation - cachedStats: Record; - // the displayText for the segmentation in the panels - displayText?: string[]; - // segmentation id - id: string; - // displaySetInstanceUID - displaySetInstanceUID: string; - // segmentation label - label: string; - // segment indices that are locked for the segmentation - segmentsLocked: Array; // the type of the segmentation (e.g., Labelmap etc.) type: csToolsEnums.SegmentationRepresentations; - // the volume id of the volume that the labelmap is associated with, this only exists for the labelmap representation - volumeId: string; - // the referenced volumeURI for the segmentation - referencedVolumeURI: string; - // whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport - // but hydrated SEG -> segmentation that is persisted in the store) - hydrated: boolean; - // the number of segments in the segmentation - segmentCount: number; - // the array of segments with their details - segments: Array; + // the segmentation representation data + representationData: SegmentationRepresentationData; }; -export { SegmentationConfig, Segment, Segmentation, SegmentationSchema }; +type LabelmapSegmentationData = { + volumeId: string; + referencedVolumeId?: string; +}; + +type SegmentationRepresentationData = { + LABELMAP?: LabelmapSegmentationData; +}; + +export { SegmentationConfig, Segment, Segmentation }; diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index a99083d8b..cf47cce78 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -11,9 +11,13 @@ import { cache, utilities, CONSTANTS, + Enums as csEnums, } from '@cornerstonejs/core'; -import { utilities as csToolsUtils } from '@cornerstonejs/tools'; +import { + utilities as csToolsUtils, + Enums as csToolsEnums, +} from '@cornerstonejs/tools'; import { IViewportService } from './IViewportService'; import { RENDERING_ENGINE_ID } from './constants'; import ViewportInfo, { @@ -386,7 +390,7 @@ class CornerstoneViewportService extends PubSubService initialImageIndexToUse === null ) { initialImageIndexToUse = - this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0; + this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0; } const properties = { ...presentations.lutPresentation?.properties }; @@ -412,7 +416,7 @@ class CornerstoneViewportService extends PubSubService }); } - private _getInitialImageIndexForStackViewport( + private _getInitialImageIndexForViewport( viewportInfo: ViewportInfo, imageIds?: string[] ): number { @@ -423,7 +427,29 @@ class CornerstoneViewportService extends PubSubService } const { index, preset } = initialImageOptions; - return this._getInitialImageIndex(imageIds.length, index, preset); + const viewportType = viewportInfo.getViewportType(); + + let numberOfSlices; + if (viewportType === csEnums.ViewportType.STACK) { + numberOfSlices = imageIds.length; + } else if (viewportType === csEnums.ViewportType.ORTHOGRAPHIC) { + const viewport = this.getCornerstoneViewport( + viewportInfo.getViewportId() + ); + const imageSliceData = csUtils.getImageSliceDataForVolumeViewport( + viewport + ); + + if (!imageSliceData) { + return; + } + + ({ numberOfSlices } = imageSliceData); + } else { + return; + } + + return this._getInitialImageIndex(numberOfSlices, index, preset); } _getInitialImageIndex( @@ -548,7 +574,6 @@ class CornerstoneViewportService extends PubSubService ) { const { displaySetService, - segmentationService, toolGroupService, } = this.servicesManager.services; @@ -558,110 +583,32 @@ class CornerstoneViewportService extends PubSubService // load any secondary displaySets const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id); - const segDisplaySet = displaySetInstanceUIDs + // can be SEG or RTSTRUCT for now + const overlayDisplaySet = displaySetInstanceUIDs .map(displaySetService.getDisplaySetByUID) - .find(displaySet => displaySet && displaySet.Modality === 'SEG'); + .find(displaySet => displaySet?.isOverlayDisplaySet); - if (segDisplaySet) { - const { referencedVolumeId } = segDisplaySet; - const referencedVolume = cache.getVolume(referencedVolumeId); - const segmentationId = segDisplaySet.displaySetInstanceUID; - - const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); - - if (referencedVolume) { - segmentationService.addSegmentationRepresentationToToolGroup( - toolGroup.id, - segmentationId - ); - } + if (overlayDisplaySet) { + this.addOverlayRepresentationForDisplaySet(overlayDisplaySet, viewport); } else { - const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); - const toolGroupSegmentationRepresentations = - segmentationService.getSegmentationRepresentationsForToolGroup( - toolGroup.id - ) || []; - - // csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id); // If the displaySet is not a SEG displaySet we assume it is a primary displaySet // and we can look into hydrated segmentations to check if any of them are // associated with the primary displaySet + // get segmentations only returns the hydrated segmentations - const segmentations = segmentationService.getSegmentations(); - - for (const segmentation of segmentations) { - // if there is already a segmentation representation for this segmentation - // for this toolGroup, don't bother at all - if ( - toolGroupSegmentationRepresentations.find( - representation => representation.segmentationId === segmentation.id - ) - ) { - continue; - } - - // otherwise, check if the hydrated segmentations are in the same FOR - // as the primary displaySet, if so add the representation (since it was not there) - const { id: segDisplaySetInstanceUID } = segmentation; - - const segFrameOfReferenceUID = this._getFrameOfReferenceUID( - segDisplaySetInstanceUID - ); - - let shouldDisplaySeg = false; - - for (const displaySetInstanceUID of displaySetInstanceUIDs) { - const primaryFrameOfReferenceUID = this._getFrameOfReferenceUID( - displaySetInstanceUID - ); - - if (segFrameOfReferenceUID === primaryFrameOfReferenceUID) { - shouldDisplaySeg = true; - break; - } - } - - if (shouldDisplaySeg) { - const toolGroup = toolGroupService.getToolGroupForViewport( - viewport.id - ); - - segmentationService.addSegmentationRepresentationToToolGroup( - toolGroup.id, - segmentation.id - ); - } - } + this._addSegmentationRepresentationToToolGroupIfNecessary( + displaySetInstanceUIDs, + viewport + ); } const viewportInfo = this.getViewportInfo(viewport.id); - - if (!viewportInfo) { - console.warn('Viewport info not defined for', viewport.id); - } - const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id); - const initialImageOptions = viewportInfo.getInitialImageOptions(); - - if ( - initialImageOptions && - (initialImageOptions.preset !== undefined || - initialImageOptions.index !== undefined) - ) { - const { index, preset } = initialImageOptions; - - const { numberOfSlices } = csUtils.getImageSliceDataForVolumeViewport( - viewport - ); - - const imageIndex = this._getInitialImageIndex( - numberOfSlices, - index, - preset - ); + const imageIndex = this._getInitialImageIndexForViewport(viewportInfo); + if (imageIndex !== undefined) { csToolsUtils.jumpToSlice(viewport.element, { imageIndex, }); @@ -670,6 +617,96 @@ class CornerstoneViewportService extends PubSubService viewport.render(); } + private _addSegmentationRepresentationToToolGroupIfNecessary( + displaySetInstanceUIDs: string[], + viewport: any + ) { + const { + segmentationService, + toolGroupService, + } = this.servicesManager.services; + + const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); + + // this only returns hydrated segmentations + const segmentations = segmentationService.getSegmentations(); + + for (const segmentation of segmentations) { + const toolGroupSegmentationRepresentations = + segmentationService.getSegmentationRepresentationsForToolGroup( + toolGroup.id + ) || []; + + // if there is already a segmentation representation for this segmentation + // for this toolGroup, don't bother at all + const isSegmentationInToolGroup = toolGroupSegmentationRepresentations.find( + representation => representation.segmentationId === segmentation.id + ); + + if (isSegmentationInToolGroup) { + continue; + } + + // otherwise, check if the hydrated segmentations are in the same FOR + // as the primary displaySet, if so add the representation (since it was not there) + const { id: segDisplaySetInstanceUID, type } = segmentation; + const segFrameOfReferenceUID = this._getFrameOfReferenceUID( + segDisplaySetInstanceUID + ); + + let shouldDisplaySeg = false; + + for (const displaySetInstanceUID of displaySetInstanceUIDs) { + const primaryFrameOfReferenceUID = this._getFrameOfReferenceUID( + displaySetInstanceUID + ); + + if (segFrameOfReferenceUID === primaryFrameOfReferenceUID) { + shouldDisplaySeg = true; + break; + } + } + + if (!shouldDisplaySeg) { + return; + } + + segmentationService.addSegmentationRepresentationToToolGroup( + toolGroup.id, + segmentation.id, + false, // already hydrated, + segmentation.type + ); + } + } + + private addOverlayRepresentationForDisplaySet( + displaySet: any, + viewport: any + ) { + const { + segmentationService, + toolGroupService, + } = this.servicesManager.services; + + const { referencedVolumeId } = displaySet; + const segmentationId = displaySet.displaySetInstanceUID; + + const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); + + const representationType = + referencedVolumeId && cache.getVolume(referencedVolumeId) !== undefined + ? csToolsEnums.SegmentationRepresentations.Labelmap + : csToolsEnums.SegmentationRepresentations.Contour; + + segmentationService.addSegmentationRepresentationToToolGroup( + toolGroup.id, + segmentationId, + false, + representationType + ); + } + // Todo: keepCamera is an interim solution until we have a better solution for // keeping the camera position when the viewport data is changed public updateViewport( @@ -859,6 +896,11 @@ class CornerstoneViewportService extends PubSubService return instance.FrameOfReferenceUID; } + if (displaySet.Modality === 'RTSTRUCT') { + const { instance } = displaySet; + return instance.ReferencedFrameOfReferenceSequence.FrameOfReferenceUID; + } + const { images } = displaySet; if (images && images.length) { return images[0].FrameOfReferenceUID; diff --git a/extensions/cornerstone/src/utils/transitions.ts b/extensions/cornerstone/src/utils/transitions.ts index 2fa96794d..28d2dacd0 100644 --- a/extensions/cornerstone/src/utils/transitions.ts +++ b/extensions/cornerstone/src/utils/transitions.ts @@ -20,3 +20,17 @@ export function easeInOutBell(x: number, baseline: number): number { return (- 4 * Math.pow(2 * x - 2, 3)) * alpha + baseline; } } + +/** + * A reversed bell curved function that starts from 1 and goes to baseline and + * come back to 1 again. It uses ease in out quadratic for css transition + * timing function for each side of the curve. + * + * @param {number} x - The current time, in the range [0, 1]. + * @param {number} baseline - The baseline value to start from and return to. + * @returns the value of the transition at time x. + */ +export function reverseEaseInOutBell(x: number, baseline: number): number { + const y = easeInOutBell(x, baseline); + return -y + 1 + baseline; +} diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 3534c727d..792ee44c7 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -178,6 +178,17 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { directURL: params => { return getDirectURL(wadoRoot, params); }, + bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => { + const options = { + multipart: false, + BulkDataURI, + StudyInstanceUID, + }; + return qidoDicomWebClient.retrieveBulkData(options).then(val => { + const ret = (val && val[0]) || undefined; + return ret; + }); + }, series: { metadata: async ({ StudyInstanceUID, diff --git a/extensions/default/src/getSopClassHandlerModule.js b/extensions/default/src/getSopClassHandlerModule.js index f00018187..df1900051 100644 --- a/extensions/default/src/getSopClassHandlerModule.js +++ b/extensions/default/src/getSopClassHandlerModule.js @@ -14,7 +14,10 @@ const makeDisplaySet = instances => { const instance = instances[0]; const imageSet = new ImageSet(instances); - const displayReconstructableInfo = isDisplaySetReconstructable(instances); + const { + value: isReconstructable, + averageSpacingBetweenFrames, + } = isDisplaySetReconstructable(instances); // set appropriate attributes to image set... imageSet.setAttributes({ @@ -29,10 +32,11 @@ const makeDisplaySet = instances => { SeriesDescription: instance.SeriesDescription || '', Modality: instance.Modality, isMultiFrame: isMultiFrame(instance), - countIcon: displayReconstructableInfo.value ? 'icon-mpr' : undefined, + countIcon: isReconstructable ? 'icon-mpr' : undefined, numImageFrames: instances.length, SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`, - isReconstructable: displayReconstructableInfo.value, + isReconstructable, + averageSpacingBetweenFrames: averageSpacingBetweenFrames || null, }); // Sort the images in this series if needed diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index b07b17d2e..a695b0268 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -33,7 +33,7 @@ "@ohif/core": "^3.0.0", "classnames": "^2.3.2", "@cornerstonejs/core": "^0.42.2", - "@cornerstonejs/tools": "^0.61.11", + "@cornerstonejs/tools": "^0.63.2", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.5", "lodash.debounce": "^4.17.21", diff --git a/modes/longitudinal/package.json b/modes/longitudinal/package.json index 7a35b2b9a..32cd0ed37 100644 --- a/modes/longitudinal/package.json +++ b/modes/longitudinal/package.json @@ -36,6 +36,8 @@ "@ohif/extension-default": "^3.0.0", "@ohif/extension-cornerstone": "^3.0.0", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", + "@ohif/extension-cornerstone-dicom-seg": "^3.0.0", + "@ohif/extension-cornerstone-dicom-rt": "^3.0.0", "@ohif/extension-dicom-pdf": "^3.0.1", "@ohif/extension-dicom-video": "^3.0.1", "@ohif/extension-measurement-tracking": "^3.0.0" diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 6e2d8a2da..4f882c457 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -5,7 +5,7 @@ import initToolGroups from './initToolGroups.js'; // Allow this mode by excluding non-imaging modalities such as SR, SEG // Also, SM is not a simple imaging modalities, so exclude it. -const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG']; +const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG', 'RTSTRUCT']; const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', @@ -45,6 +45,12 @@ const dicomSeg = { panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', }; +const dicomRt = { + viewport: '@ohif/extension-cornerstone-dicom-rt.viewportModule.dicom-rt', + sopClassHandler: + '@ohif/extension-cornerstone-dicom-rt.sopClassHandlerModule.dicom-rt', +}; + const extensionDependencies = { // Can derive the versions at least process.env.from npm_package_version '@ohif/extension-default': '^3.0.0', @@ -52,6 +58,7 @@ const extensionDependencies = { '@ohif/extension-measurement-tracking': '^3.0.0', '@ohif/extension-cornerstone-dicom-sr': '^3.0.0', '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', + '@ohif/extension-cornerstone-dicom-rt': '^3.0.0', '@ohif/extension-dicom-pdf': '^3.0.1', '@ohif/extension-dicom-video': '^3.0.1', }; @@ -211,6 +218,10 @@ function modeFactory() { namespace: dicomSeg.viewport, displaySetsToDisplay: [dicomSeg.sopClassHandler], }, + { + namespace: dicomRt.viewport, + displaySetsToDisplay: [dicomRt.sopClassHandler], + }, ], }, }; @@ -230,6 +241,7 @@ function modeFactory() { ohif.sopClassHandler, dicompdf.sopClassHandler, dicomsr.sopClassHandler, + dicomRt.sopClassHandler, ], hotkeys: [...hotkeys.defaults.hotkeyBindings], }; diff --git a/platform/core/src/classes/ImageSet.js b/platform/core/src/classes/ImageSet.ts similarity index 72% rename from platform/core/src/classes/ImageSet.js rename to platform/core/src/classes/ImageSet.ts index 80838c14f..d7ac82bce 100644 --- a/platform/core/src/classes/ImageSet.js +++ b/platform/core/src/classes/ImageSet.ts @@ -1,7 +1,16 @@ import guid from '../utils/guid.js'; import { Vector3 } from 'cornerstone-math'; -const OBJECT = 'object'; +type Attributes = Record; +type Image = { + StudyInstanceUID?: string; + getData(): { + metadata: { + ImagePositionPatient: number[]; + ImageOrientationPatient: number[]; + }; + }; +}; /** * This class defines an ImageSet object which will be used across the viewer. This object represents @@ -10,8 +19,14 @@ const OBJECT = 'object'; * indiscriminately, but this should be changed). */ class ImageSet { - constructor(images) { - if (Array.isArray(images) !== true) { + images: Image[]; + uid: string; + instances: Image[]; + instance?: Image; + StudyInstanceUID?: string; + + constructor(images: Image[]) { + if (!Array.isArray(images)) { throw new Error('ImageSet expects an array of images'); } @@ -36,41 +51,39 @@ class ImageSet { this.StudyInstanceUID = this.instance?.StudyInstanceUID; } - getUID() { + load: () => Promise; + + getUID(): string { return this.uid; } - setAttribute(attribute, value) { + setAttribute(attribute: string, value: unknown): void { this[attribute] = value; } - getAttribute(attribute) { + getAttribute(attribute: string): unknown { return this[attribute]; } - setAttributes(attributes) { - if (typeof attributes === OBJECT && attributes !== null) { - const imageSet = this, - hasOwn = Object.prototype.hasOwnProperty; - for (let attribute in attributes) { - if (hasOwn.call(attributes, attribute)) { - imageSet[attribute] = attributes[attribute]; - } + setAttributes(attributes: Attributes): void { + if (typeof attributes === 'object' && attributes !== null) { + for (const [attribute, value] of Object.entries(attributes)) { + this[attribute] = value; } } } - getNumImages = () => this.images.length; + getNumImages = (): number => this.images.length; - getImage(index) { + getImage(index: number): Image { return this.images[index]; } - sortBy(sortingCallback) { + sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] { return this.images.sort(sortingCallback); } - sortByImagePositionPatient() { + sortByImagePositionPatient(): void { const images = this.images; const referenceImagePositionPatient = _getImagePositionPatient(images[0]); @@ -94,7 +107,7 @@ class ImageSet { ) ); - const distanceImagePairs = images.map(function(image) { + const distanceImagePairs = images.map(function(image: Image) { const ippVec = new Vector3(..._getImagePositionPatient(image)); const positionVector = refIppVec.clone().sub(ippVec); const distance = positionVector.dot(scanAxisNormal); diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 9c26d7370..c028fbab3 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -41,6 +41,7 @@ export interface Extension { getCommandsModule?: (p: ExtensionParams) => CommandsModule; getViewportModule?: (p: ExtensionParams) => unknown; getUtilityModule?: (p: ExtensionParams) => unknown; + getCustomizationModule?: (p: ExtensionParams) => unknown; onModeEnter?: () => void; onModeExit?: () => void; } diff --git a/platform/core/src/types/Color.ts b/platform/core/src/types/Color.ts new file mode 100644 index 000000000..7dfacefb6 --- /dev/null +++ b/platform/core/src/types/Color.ts @@ -0,0 +1,4 @@ +/** + * RGB color type + */ +export type RGB = [number, number, number]; diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts index 089a994f5..3c68b60c4 100644 --- a/platform/core/src/types/index.ts +++ b/platform/core/src/types/index.ts @@ -12,6 +12,7 @@ export * from './Command'; export * from './StudyMetadata'; export * from './PanelModule'; export * from './IPubSub'; +export * from './Color'; /** * Export the types used within the various services and managers, but diff --git a/platform/docs/docs/platform/services/data/SegmentationService.md b/platform/docs/docs/platform/services/data/SegmentationService.md index fcde850bc..54e7f82a7 100644 --- a/platform/docs/docs/platform/services/data/SegmentationService.md +++ b/platform/docs/docs/platform/services/data/SegmentationService.md @@ -25,8 +25,8 @@ There are seven events that get publish in `MeasurementService`: | SEGMENTATION_ADDED | Fires when a new segmentation is added to OHIF | | SEGMENTATION_REMOVED | Fires when a segmentation is removed from OHIF | | SEGMENTATION_CONFIGURATION_CHANGED | Fires when a segmentation configuration is changed | -| SEGMENT_PIXEL_DATA_CREATED | Fires when a segment group adds its pixel data to the volume | -| SEGMENTATION_PIXEL_DATA_CREATED | Fires when the full segmentation volume is filled with its segments | +| SEGMENT_LOADING_COMPLETE | Fires when a segment group adds its pixel data to the volume | +| SEGMENTATION_LOADING_COMPLETE | Fires when the full segmentation volume is filled with its segments | ## API diff --git a/platform/ui/src/components/InputNumber/InputNumber.tsx b/platform/ui/src/components/InputNumber/InputNumber.tsx index 185420eab..a96446293 100644 --- a/platform/ui/src/components/InputNumber/InputNumber.tsx +++ b/platform/ui/src/components/InputNumber/InputNumber.tsx @@ -19,37 +19,60 @@ const sizesClasses = { const InputNumber: React.FC<{ value: number; onChange: (value) => void; + minValue?: number; + maxValue?: number; step: number; size?: string; className?: string; -}> = ({ value, onChange, step = 1, className, size = 'sm' }) => { +}> = ({ + value, + onChange, + step = 1, + className, + size = 'sm', + minValue = 0, + maxValue = 100, +}) => { const [numberValue, setNumberValue] = useState(value); + const handleMinMax = useCallback( + (value: number) => { + if (value > maxValue) { + return maxValue; + } else if (value < minValue) { + return minValue; + } else { + return value; + } + }, + [maxValue, minValue] + ); + const handleChange = useCallback( e => { - const numberValue = e.target.value; + const numberValue = handleMinMax(Number(e.target.value)); setNumberValue(numberValue); onChange(numberValue); }, - [onChange, setNumberValue] + [onChange, setNumberValue, handleMinMax] ); const handleIncrement = useCallback( e => { - const newNum = Number(numberValue) + step; + const newNum = handleMinMax(Number(numberValue) + step); setNumberValue(newNum); onChange(newNum); }, - [onChange, setNumberValue, step, numberValue] + [onChange, setNumberValue, step, numberValue, handleMinMax] ); const handleDecrement = useCallback( e => { - const newNum = Number(numberValue) - step; + const newNum = handleMinMax(Number(numberValue) - step); setNumberValue(newNum); onChange(newNum); }, - [onChange, setNumberValue, step, numberValue] + [onChange, setNumberValue, step, numberValue, handleMinMax] ); return ( diff --git a/platform/ui/src/components/InputRange/InputRange.tsx b/platform/ui/src/components/InputRange/InputRange.tsx index 28e98b06f..9bcf40b14 100644 --- a/platform/ui/src/components/InputRange/InputRange.tsx +++ b/platform/ui/src/components/InputRange/InputRange.tsx @@ -54,6 +54,9 @@ const InputRange: React.FC<{ const rangeValuePercentage = ((rangeValue - minValue) / (maxValue - minValue)) * 100; + const rangeValueForStr = + step >= 1 ? rangeValue.toFixed(0) : rangeValue.toFixed(1); + return (
- {rangeValue} + {rangeValueForStr} {unit} )} diff --git a/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx b/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx new file mode 100644 index 000000000..4a649e453 --- /dev/null +++ b/platform/ui/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import LoadingIndicatorProgress from '../LoadingIndicatorProgress'; + +interface Props { + className?: string; + totalNumbers: number | null; + percentComplete: number | null; + loadingText?: string; + targetText?: string; +} + +/** + * A React component that renders a loading indicator but accepts a totalNumbers + * and percentComplete to display a more detailed message. + */ +function LoadingIndicatorTotalPercent({ + className, + totalNumbers, + percentComplete, + loadingText = 'Loading...', + targetText = 'segments', +}: Props): JSX.Element { + percentComplete = percentComplete !== null ? percentComplete : null; + + const progress = percentComplete !== null ? percentComplete : null; + const totalNumbersText = totalNumbers !== null ? `${totalNumbers}` : ''; + const numTargetsLoadedText = + percentComplete !== null + ? Math.floor((percentComplete * totalNumbers) / 100) + : ''; + + const textBlock = !totalNumbers ? ( +
{loadingText}
+ ) : ( +
+
Loaded
+
{numTargetsLoadedText}
+
of
+
{totalNumbersText}
+
{targetText}
+
+ ); + + return ( + + ); +} + +export default LoadingIndicatorTotalPercent; diff --git a/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js b/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js new file mode 100644 index 000000000..fc4e1fa10 --- /dev/null +++ b/platform/ui/src/components/LoadingIndicatorTotalPercent/index.js @@ -0,0 +1,2 @@ +import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent'; +export default LoadingIndicatorTotalPercent; diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx index 6167ec55d..8a83e567e 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx @@ -1,27 +1,15 @@ import React, { useState } from 'react'; import { Icon, InputRange, CheckBox, InputNumber } from '../'; import classNames from 'classnames'; -import { reducer } from './segmentationConfigReducer'; const ActiveSegmentationConfig = ({ config, - dispatch, setRenderOutline, setOutlineOpacityActive, setOutlineWidthActive, setRenderFill, setFillAlpha, - usePercentage, }) => { - const [ - useOutlineOpacityPercentage, - setUseOutlineOpacityPercentage, - ] = useState(usePercentage); - - const [useFillAlphaPercentage, setUseFillAlphaPercentage] = useState( - usePercentage - ); - return (
@@ -31,32 +19,14 @@ const ActiveSegmentationConfig = ({ checked={config.renderOutline} labelClassName="text-[12px] pl-1 pt-1" className="mb-[9px]" - onChange={value => { - dispatch({ - type: 'RENDER_OUTLINE', - payload: { - value, - }, - }); - - setRenderOutline(value); - }} + onChange={setRenderOutline} /> { - dispatch({ - type: 'RENDER_FILL', - payload: { - value, - }, - }); - - setRenderFill(value); - }} + onChange={setRenderFill} />
@@ -64,23 +34,9 @@ const ActiveSegmentationConfig = ({
Opacity
{ - setUseOutlineOpacityPercentage(false); - dispatch({ - type: 'SET_OUTLINE_OPACITY', - payload: { - value: value, - }, - }); - - setOutlineOpacityActive(value); - }} + maxValue={100} + value={config.outlineOpacity * 100} + onChange={setOutlineOpacityActive} step={1} containerClassName="mt-[4px] mb-[9px]" inputClassName="w-[64px]" @@ -89,21 +45,9 @@ const ActiveSegmentationConfig = ({ /> { - setUseFillAlphaPercentage(false); - dispatch({ - type: 'SET_FILL_ALPHA', - payload: { - value, - }, - }); - - setFillAlpha(value); - }} + maxValue={100} + value={config.fillAlpha * 100} + onChange={setFillAlpha} step={1} containerClassName="mt-[4px] mb-[9px]" inputClassName="w-[64px]" @@ -116,16 +60,9 @@ const ActiveSegmentationConfig = ({
Size
{ - dispatch({ - type: 'SET_OUTLINE_WIDTH', - payload: { - value, - }, - }); - - setOutlineWidthActive(value); - }} + onChange={setOutlineWidthActive} + minValue={0} + maxValue={10} className="-mt-1" />
@@ -135,16 +72,9 @@ const ActiveSegmentationConfig = ({ const InactiveSegmentationConfig = ({ config, - dispatch, setRenderInactiveSegmentations, setFillAlphaInactive, - usePercentage, }) => { - const [ - useFillAlphaInactivePercentage, - setUseFillInactivePercentage, - ] = useState(usePercentage); - return (
{ - dispatch({ - type: 'RENDER_INACTIVE_SEGMENTATIONS', - payload: { - value, - }, - }); - - setRenderInactiveSegmentations(value); - }} + onChange={setRenderInactiveSegmentations} />
Opacity { - setUseFillInactivePercentage(false); - dispatch({ - type: 'SET_FILL_ALPHA_INACTIVE', - payload: { - value: value, - }, - }); - - setFillAlphaInactive(value); - }} + maxValue={100} + value={config.fillAlphaInactive * 100} + onChange={setFillAlphaInactive} step={1} containerClassName="mt-[4px]" inputClassName="w-[64px]" @@ -206,11 +113,7 @@ const SegmentationConfig = ({ setRenderInactiveSegmentations, setRenderOutline, }) => { - const [config, dispatch] = React.useReducer( - reducer, - segmentationConfig.initialConfig - ); - + const { initialConfig } = segmentationConfig; const [isMinimized, setIsMinimized] = useState(true); return (
@@ -238,14 +141,12 @@ const SegmentationConfig = ({ {!isMinimized && (
{/* A small line */}
@@ -259,11 +160,9 @@ const SegmentationConfig = ({
)} diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx index d6269cde2..42bb3f2d8 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx @@ -4,30 +4,6 @@ import Icon from '../Icon'; import SegmentationGroup from './SegmentationGroup'; import SegmentationConfig from './SegmentationConfig'; -const GetSegmentationConfig = ({ - setFillAlpha, - setFillAlphaInactive, - setOutlineWidthActive, - setRenderFill, - setRenderInactiveSegmentations, - setRenderOutline, - setOutlineOpacityActive, - segmentationConfig, -}) => { - return ( - - ); -}; - const SegmentationGroupTable = ({ segmentations, onSegmentationAdd, @@ -56,10 +32,7 @@ const SegmentationGroupTable = ({ }) => { return (
-
{!!segmentations.length && @@ -155,7 +129,6 @@ SegmentationGroupTable.defaultProps = { renderInactiveSegmentations: true, renderOutline: true, }, - usePercentage: true, }, setFillAlpha: () => {}, setFillAlphaInactive: () => {}, diff --git a/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx b/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx deleted file mode 100644 index 1063b0558..000000000 --- a/platform/ui/src/components/SegmentationGroupTable/segmentationConfigReducer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -const reducer = (state, action) => { - switch (action.type) { - case 'RENDER_OUTLINE': - return { ...state, renderOutline: action.payload.value }; - case 'RENDER_FILL': - return { ...state, renderFill: action.payload.value }; - case 'SET_OUTLINE_OPACITY': - return { ...state, outlineOpacity: action.payload.value }; - case 'SET_OUTLINE_WIDTH': - return { ...state, outlineWidth: action.payload.value }; - case 'SET_FILL_ALPHA': - return { ...state, fillAlpha: action.payload.value }; - case 'SET_FILL_ALPHA_INACTIVE': - return { ...state, fillAlphaInactive: action.payload.value }; - case 'RENDER_INACTIVE_SEGMENTATIONS': - return { ...state, renderInactiveSegmentations: action.payload.value }; - default: - return state; - } -}; - -export { reducer }; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 4f70ef69c..d01247542 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -68,6 +68,7 @@ import InputRange from './InputRange'; import InputNumber from './InputNumber'; import CheckBox from './CheckBox'; import LoadingIndicatorProgress from './LoadingIndicatorProgress'; +import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent'; import ViewportActionBar from './ViewportActionBar'; export { @@ -104,6 +105,7 @@ export { LegacyCinePlayer, LegacyViewportActionBar, LoadingIndicatorProgress, + LoadingIndicatorTotalPercent, MeasurementTable, Modal, NavBar, diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index 50842160d..fad709a2c 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -65,6 +65,7 @@ export { LegacyCinePlayer, LegacyViewportActionBar, LoadingIndicatorProgress, + LoadingIndicatorTotalPercent, MeasurementTable, Modal, NavBar, diff --git a/platform/viewer/netlify.toml b/platform/viewer/netlify.toml index 468eca951..4af3dd597 100644 --- a/platform/viewer/netlify.toml +++ b/platform/viewer/netlify.toml @@ -12,7 +12,6 @@ base = "" build = "yarn run build:viewer:ci" publish = "dist" - ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../ui/ ../core/ ../i18n" # NODE_VERSION in root `.nvmrc` takes priority diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 2c1c7325b..fca0b294c 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -51,6 +51,7 @@ "@ohif/extension-cornerstone": "^3.0.0", "@ohif/extension-cornerstone-dicom-seg": "^3.0.0", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", + "@ohif/extension-cornerstone-dicom-rt": "^3.0.0", "@ohif/extension-default": "^3.0.0", "@ohif/extension-dicom-pdf": "^3.0.1", "@ohif/extension-dicom-video": "^3.0.1", diff --git a/platform/viewer/pluginConfig.json b/platform/viewer/pluginConfig.json index f69de2770..8c5d24197 100644 --- a/platform/viewer/pluginConfig.json +++ b/platform/viewer/pluginConfig.json @@ -31,6 +31,10 @@ { "packageName": "@ohif/extension-cornerstone-dicom-seg", "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-cornerstone-dicom-rt", + "version": "3.0.0" } ], "modes": [ @@ -45,4 +49,4 @@ ], "modesFactory": [], "umd": [] -} \ No newline at end of file +} diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index 7b432830b..558f473c2 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -50,6 +50,7 @@ window.config = { supportsWildcard: true, staticWado: true, singlepart: 'bulkdata,video,pdf', + useBulkDataURI: false, }, }, { diff --git a/platform/viewer/public/config/demo.js b/platform/viewer/public/config/demo.js index 5c37f5b48..433d56845 100644 --- a/platform/viewer/public/config/demo.js +++ b/platform/viewer/public/config/demo.js @@ -18,6 +18,7 @@ window.config = { imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, + useBulkDataURI: false, }, ], }, diff --git a/platform/viewer/public/config/local_dcm4chee.js b/platform/viewer/public/config/local_dcm4chee.js index fca1f2a71..28ae28fd9 100644 --- a/platform/viewer/public/config/local_dcm4chee.js +++ b/platform/viewer/public/config/local_dcm4chee.js @@ -16,13 +16,14 @@ window.config = { sourceName: 'dicomweb', configuration: { name: 'DCM4CHEE', - wadoUriRoot: 'http://localhost:8080/dcm4chee-arc/aets/DCM4CHEE/wado', - qidoRoot: 'http://localhost:8080/dcm4chee-arc/aets/DCM4CHEE/rs', - wadoRoot: 'http://localhost:8080/dcm4chee-arc/aets/DCM4CHEE/rs', + wadoUriRoot: 'http://localhost/dcm4chee-arc/aets/DCM4CHEE/wado', + qidoRoot: 'http://localhost/dcm4chee-arc/aets/DCM4CHEE/rs', + wadoRoot: 'http://localhost/dcm4chee-arc/aets/DCM4CHEE/rs', qidoSupportsIncludeField: true, imageRendering: 'wadors', enableStudyLazyLoad: true, thumbnailRendering: 'wadors', + useBulkDataURI: false, requestOptions: { auth: 'admin:admin', }, diff --git a/platform/viewer/public/config/local_orthanc.js b/platform/viewer/public/config/local_orthanc.js index 5165cb7e5..71bacacbe 100644 --- a/platform/viewer/public/config/local_orthanc.js +++ b/platform/viewer/public/config/local_orthanc.js @@ -25,6 +25,7 @@ window.config = { imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, + useBulkDataURI: false, supportsFuzzyMatching: true, supportsWildcard: true, }, diff --git a/yarn.lock b/yarn.lock index 113a1eb55..032805b51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1308,7 +1308,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" -"@babel/runtime@7.17.9", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@7.17.9", "@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.20.13" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA== @@ -1443,10 +1443,10 @@ resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.2.tgz#e96721d56f6ec96f7f95c16321d88cc8467d8d81" integrity sha512-lgdvBvvNezleY+4pIe2ceUsJzlZe/0PipdeubQ3vZZOz3xxtHHMR1XFCl4fgd8gosR8COHuD7h6q+MwgrwBsng== -"@cornerstonejs/core@^0.40.0": - version "0.40.0" - resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.40.0.tgz#5b6409277362b26c6ddb55b54025ecf26b304f84" - integrity sha512-tjUGFyXuRNRSybpKpd/mP4tKMshc48n/TIt9x5mXU+zqywBPGmojkXOj/v+pG02XopKLg5XPSI4LPysZNVscsg== +"@cornerstonejs/core@^0.41.0": + version "0.41.0" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.41.0.tgz#1268bc9cb70e101b52e5eee9055a84541d76ef5d" + integrity sha512-Wk4BpDaKYz9KRr6eNiYatxg/ZNsluNN2p5QsK0qNYyquR5ABo/fuBexI160plP3jI4pDKlMH+OpbXuLx3T3ngw== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" @@ -1459,20 +1459,28 @@ detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/streaming-image-volume-loader@^0.16.0": - version "0.16.0" - resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.16.0.tgz#513f868c285963fd2f2dbf54ed7840a2dc05e33a" - integrity sha512-+bbQ6/FN7ryCDdsuyheIPeRa5MO8mTDUDZusNIU97Q8fdmw1hB+eiy9fNThdGfCENd/GPANNsXe6K5olUX7QhQ== +"@cornerstonejs/core@^0.43.1": + version "0.43.1" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.43.1.tgz#b51e3310156136e4407d56b1be9fc981022ae4dd" + integrity sha512-TT2EZHWFblkwYnMqiUWYCwtwzWEseXy819YnquWU8OzzsF14k/2JAQPvw8FB0ephv/LoVyXZPC/zARA6U30weg== dependencies: - "@cornerstonejs/core" "^0.40.0" + detect-gpu "^4.0.45" + lodash.clonedeep "4.5.0" + +"@cornerstonejs/streaming-image-volume-loader@^0.16.2": + version "0.16.2" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.16.2.tgz#7ac513a21d4d8045047a7aa2fb57b5a85682c036" + integrity sha512-2FT0uyuj6+sarAFAdLjd3mGEeW4vD3OPMd/bJltJNyf0eJn0YRC6ke0PwEJj1uyJhEjbhquxsMqhGQIUN8vzpg== + dependencies: + "@cornerstonejs/core" "^0.41.0" cornerstone-wado-image-loader "^4.10.2" -"@cornerstonejs/tools@^0.61.11": - version "0.61.11" - resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-0.61.11.tgz#dacd85967cd6ab22c27dd44c10b14664863631f9" - integrity sha512-TCUde2gmuyiyd0EXhoT4DhDZBHYy82MOiekGQe+IG24um3a7GNGJ7jOKBk7fVFxJCTAI2Cb6XI8K79h6BhuWdg== +"@cornerstonejs/tools@^0.63.2": + version "0.63.3" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.63.3.tgz#a66df8e8a3dc0c1bd5224d85db5d2231ab946f12" + integrity sha512-cgs/6OYcp3p+RSEOb77gR61eziTfb6Y0uK/GrdpgEet3InhFohJCst1TI8rbrHJd4wUX2VDzXHIm5gf6TlwRsA== dependencies: - "@cornerstonejs/core" "^0.42.2" + "@cornerstonejs/core" "^0.43.1" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -6446,6 +6454,11 @@ array-union@^2.1.0: resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +array-union@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz#da52630d327f8b88cfbfb57728e2af5cd9b6b975" + integrity sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw== + array-uniq@^1.0.1: version "1.0.3" resolved "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" @@ -8458,6 +8471,18 @@ copy-text-to-clipboard@^3.0.1: resolved "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz#8cbf8f90e0a47f12e4a24743736265d157bce69c" integrity sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q== +copy-webpack-plugin@^10.2.0: + version "10.2.4" + resolved "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz#6c854be3fdaae22025da34b9112ccf81c63308fe" + integrity sha512-xFVltahqlsRcyyJqQbDY6EYTtyQZF9rf+JPjwHObLdPFMEISqkFkr7mFoVOC6BfYS/dNThyoQKvziugm+OnwBg== + dependencies: + fast-glob "^3.2.7" + glob-parent "^6.0.1" + globby "^12.0.2" + normalize-path "^3.0.0" + schema-utils "^4.0.0" + serialize-javascript "^6.0.0" + copy-webpack-plugin@^11.0.0: version "11.0.0" resolved "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a" @@ -11996,6 +12021,18 @@ globby@^11.0.1, globby@^11.0.2, globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +globby@^12.0.2: + version "12.2.0" + resolved "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz#2ab8046b4fba4ff6eede835b29f678f90e3d3c22" + integrity sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA== + dependencies: + array-union "^3.0.1" + dir-glob "^3.0.1" + fast-glob "^3.2.7" + ignore "^5.1.9" + merge2 "^1.4.1" + slash "^4.0.0" + globby@^13.1.1: version "13.1.3" resolved "https://registry.npmjs.org/globby/-/globby-13.1.3.tgz#f62baf5720bcb2c1330c8d4ef222ee12318563ff" @@ -12728,7 +12765,7 @@ ignore@^4.0.3, ignore@^4.0.6: resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.0.0, ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0: +ignore@^5.0.0, ignore@^5.1.1, ignore@^5.1.8, ignore@^5.1.9, ignore@^5.2.0: version "5.2.4" resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==