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==