diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 00000000000..ca8d4fb1185 --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,43 @@ +# Docker compose files + +This folder contains docker-compose files used to spin up OHIF-Viewer with +different options such as locally or with any PAS you desire to + +## Public Server + +## Local Orthanc + +### Build + +`$ docker-compose -f docker-compose-orthanc.yml build` + +### Run + +Starts containers and leaves them running in the background. + +`$ docker-compose -f docker-compose-orthanc.yml up -d` + +then, access the application at [http://localhost](http://localhost) + +**remember that you have to access orthanc application and include your studies +there** + +## Local Dcm4chee + +#### build + +`$ docker-compose -f docker-compose-dcm4chee.yml build` + +#### run + +`$ docker-compose -f docker-compose-dcm4chee.yml up -d` + +then, access the application at [http://localhost](http://localhost) + +**remember that you have to access dcm4chee application and include your studies +there** You can use the following command to import your studies into dcm4che + +`$ docker run -v {YOUR_STUDY_FOLDER}:/tmp --rm --network=docker_dcm4che_default dcm4che/dcm4che-tools:5.14.0 storescu -cDCM4CHEE@arc:11112 /tmp` + +**make sure that your Docker network name is docker_dcm4chee_default or change +it to the right one** diff --git a/.docker/Viewer-v2.x/default.conf b/.docker/Viewer-v3.x/default.conf.template similarity index 87% rename from .docker/Viewer-v2.x/default.conf rename to .docker/Viewer-v3.x/default.conf.template index 93cba3df8ab..354d5df1658 100644 --- a/.docker/Viewer-v2.x/default.conf +++ b/.docker/Viewer-v3.x/default.conf.template @@ -1,5 +1,6 @@ server { - listen 80; + listen ${PORT}; + # listen 3000; location / { root /usr/share/nginx/html; index index.html index.htm; diff --git a/.docker/Viewer-v2.x/entrypoint.sh b/.docker/Viewer-v3.x/entrypoint.sh similarity index 92% rename from .docker/Viewer-v2.x/entrypoint.sh rename to .docker/Viewer-v3.x/entrypoint.sh index 45ad2b2cb0a..4791c613a01 100644 --- a/.docker/Viewer-v2.x/entrypoint.sh +++ b/.docker/Viewer-v3.x/entrypoint.sh @@ -1,4 +1,6 @@ -#!/bin/bash +#!/bin/sh + +envsubst `${PORT}` < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ] then diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..d877674e5e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Set the default behavior, +# in case people don't have core.autocrlf set. +* text=auto +# Declares that files will always have CRLF line ends +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 4a9d0827898..96350ce2d4f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ screenshots/ # autogenerated files platform/viewer/src/pluginImports.js /Viewers.iml +platform/viewer/.recipes/Nginx-Dcm4Che/dcm4che/dcm4che-arc/* +platform/viewer/.recipes/OpenResty-Orthanc/logs/* diff --git a/Dockerfile b/Dockerfile index 2b42601dfc8..efcf1ea2810 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,23 @@ +# This dockerfile is used to publish the `ohif/viewer` image on dockerhub. +# +# It's a good example of how to build our static application and package it +# with a web server capable of hosting it as static content. +# +# docker build +# -------------- +# If you would like to use this dockerfile to build and tag an image, make sure +# you set the context to the project's root directory: +# https://docs.docker.com/engine/reference/commandline/build/ +# +# +# SUMMARY +# -------------- +# This dockerfile has two stages: +# +# 1. Building the React application for production +# 2. Setting up our Nginx (Alpine Linux) image w/ step one's output +# + # Stage 1: Build the application # docker build -t ohif/viewer:latest . @@ -34,10 +54,21 @@ RUN yarn install --frozen-lockfile --verbose ENV PATH /usr/src/app/node_modules/.bin:$PATH ENV QUICK_BUILD true +# ENV GENERATE_SOURCEMAP=false +# ENV REACT_APP_CONFIG=config/default.js + +RUN yarn run build # Stage 3: Bundle the built application into a Docker container # which runs Nginx using Alpine Linux -FROM nginx:1.15.5-alpine as final -RUN apk add --no-cache bash -RUN rm -rf /etc/nginx/conf.d -COPY .docker/Viewer-v2.x /etc/nginx/conf.d +FROM nginxinc/nginx-unprivileged:1.23.1-alpine as final +#RUN apk add --no-cache bash +ENV PORT=3000 +RUN rm /etc/nginx/conf.d/default.conf +USER nginx +COPY --chown=nginx:nginx .docker/Viewer-v3.x /usr/src +RUN envsubst `${PORT}` < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf +RUN chmod 777 /usr/src/entrypoint.sh +COPY --from=builder /usr/src/app/platform/viewer/dist /usr/share/nginx/html +ENTRYPOINT ["/usr/src/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/extensions/cornerstone-dicom-seg/.webpack/webpack.dev.js b/extensions/cornerstone-dicom-seg/.webpack/webpack.dev.js new file mode 100644 index 00000000000..1ae30844802 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/.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-seg/.webpack/webpack.prod.js b/extensions/cornerstone-dicom-seg/.webpack/webpack.prod.js new file mode 100644 index 00000000000..070a723e38d --- /dev/null +++ b/extensions/cornerstone-dicom-seg/.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-seg/LICENSE b/extensions/cornerstone-dicom-seg/LICENSE new file mode 100644 index 00000000000..898d93d0bc1 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2022 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-seg/README.md b/extensions/cornerstone-dicom-seg/README.md new file mode 100644 index 00000000000..9057fbc3218 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/README.md @@ -0,0 +1,18 @@ +# dicom-seg +## Description + +DICOM SEG read workflow. This extension will allow you to load a DICOM SEG image +and display it on OHIF. Currently Segmentations are loaded as a volumetric labelmap +and displayed as a 3D volume. + +This extension provides a SEG viewport, which enables rendering and reviewing +of the DICOM SEG images. However, in order to fully load all the segments +you will need to click on the SEG Pill button on the viewport action bar +to fully load the segments. + +## Author + +OHIF + +## License +MIT diff --git a/extensions/cornerstone-dicom-seg/babel.config.js b/extensions/cornerstone-dicom-seg/babel.config.js new file mode 100644 index 00000000000..92fbbdeaf95 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/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-seg/package.json b/extensions/cornerstone-dicom-seg/package.json new file mode 100644 index 00000000000..a401b312f37 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/package.json @@ -0,0 +1,71 @@ +{ + "name": "@ohif/extension-cornerstone-dicom-seg", + "version": "3.0.0", + "description": "DICOM SEG read workflow", + "author": "OHIF", + "license": "MIT", + "main": "dist/umd/@ohif/dicom-seg/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-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js new file mode 100644 index 00000000000..a0303f0765e --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -0,0 +1,312 @@ +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; + +import { utils } from '@ohif/core'; + +import { SOPClassHandlerId } from './id'; +import dcmjs from 'dcmjs'; + +const { DicomMessage, DicomMetaDictionary } = dcmjs.data; + +const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4']; + +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: 'SEG', + loading: false, + isReconstructable: true, // 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, + segments: {}, + sopClassUids, + instance, + wadoRoot, + wadoUriRoot, + wadoUri, + }; + + const referencedSeriesSequence = instance.ReferencedSeriesSequence; + + if (!referencedSeriesSequence) { + throw new Error('ReferencedSeriesSequence is missing for the SEG'); + } + + 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 SEG'); + } + + const referencedDisplaySet = referencedDisplaySets[0]; + + displaySet.referencedDisplaySetInstanceUID = + referencedDisplaySet.displaySetInstanceUID; + + // Todo: this needs to be able to work with other reference volumes (other than streaming) such as nifti, etc. + displaySet.referencedVolumeURI = referencedDisplaySet.displaySetInstanceUID; + const referencedVolumeId = `cornerstoneStreamingImageVolume:${displaySet.referencedVolumeURI}`; + displaySet.referencedVolumeId = referencedVolumeId; + + return referencedDisplaySet; + }; + + displaySet.load = async ({ headers }) => + await _load(displaySet, servicesManager, extensionManager, headers); + + return [displaySet]; +} + +function _load(segDisplaySet, servicesManager, extensionManager, headers) { + const { SOPInstanceUID } = segDisplaySet; + if ( + (segDisplaySet.loading || segDisplaySet.isLoaded) && + loadPromises[SOPInstanceUID] + ) { + return loadPromises[SOPInstanceUID]; + } + + segDisplaySet.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) => { + const { SegmentationService } = servicesManager.services; + + if (_segmentationExistsInCache(segDisplaySet, SegmentationService)) { + return; + } + + if ( + !segDisplaySet.segments || + Object.keys(segDisplaySet.segments).length === 0 + ) { + const segments = await _loadSegments( + extensionManager, + segDisplaySet, + headers + ); + + segDisplaySet.segments = segments; + } + + const suppressEvents = true; + SegmentationService.createSegmentationForSEGDisplaySet( + segDisplaySet, + null, + suppressEvents + ) + .then(() => { + segDisplaySet.loading = false; + resolve(); + }) + .catch(error => { + segDisplaySet.loading = false; + reject(error); + }); + }); + + return loadPromises[SOPInstanceUID]; +} + +async function _loadSegments(extensionManager, segDisplaySet, headers) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.common' + ); + + const { dicomLoaderService } = utilityModule.exports; + const segArrayBuffer = await dicomLoaderService.findDicomDataPromise( + segDisplaySet, + null, + headers + ); + + const dicomData = DicomMessage.readFile(segArrayBuffer); + const dataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict); + dataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta); + + if (!Array.isArray(dataset.SegmentSequence)) { + dataset.SegmentSequence = [dataset.SegmentSequence]; + } + + const segments = _getSegments(dataset); + return segments; +} + +function _segmentationExistsInCache(segDisplaySet, SegmentationService) { + // This should be abstracted with the CornerstoneCacheService + const labelmapVolumeId = segDisplaySet.displaySetInstanceUID; + const segVolume = SegmentationService.getLabelmapVolume(labelmapVolumeId); + + return segVolume !== undefined; +} + +function _getPixelData(dataset, segments) { + let frameSize = Math.ceil((dataset.Rows * dataset.Columns) / 8); + let nextOffset = 0; + + Object.keys(segments).forEach(segmentKey => { + const segment = segments[segmentKey]; + segment.numberOfFrames = segment.functionalGroups.length; + segment.size = segment.numberOfFrames * frameSize; + segment.offset = nextOffset; + nextOffset = segment.offset + segment.size; + const packedSegment = dataset.PixelData[0].slice( + segment.offset, + nextOffset + ); + + segment.pixelData = dcmjs.data.BitArray.unpack(packedSegment); + segment.geometry = geometryFromFunctionalGroups( + dataset, + segment.functionalGroups + ); + }); + + return segments; +} + +function geometryFromFunctionalGroups(dataset, perFrame) { + let pixelMeasures = + dataset.SharedFunctionalGroupsSequence.PixelMeasuresSequence; + let planeOrientation = + dataset.SharedFunctionalGroupsSequence.PlaneOrientationSequence; + let planePosition = perFrame[0].PlanePositionSequence; // TODO: assume sorted frames! + + const geometry = {}; + + // NB: DICOM PixelSpacing is defined as Row then Column, + // unlike ImageOrientationPatient + let spacingBetweenSlices = pixelMeasures.SpacingBetweenSlices; + if (!spacingBetweenSlices) { + if (pixelMeasures.SliceThickness) { + console.log('Using SliceThickness as SpacingBetweenSlices'); + spacingBetweenSlices = pixelMeasures.SliceThickness; + } + } + geometry.spacing = [ + pixelMeasures.PixelSpacing[1], + pixelMeasures.PixelSpacing[0], + spacingBetweenSlices, + ].map(Number); + + geometry.dimensions = [dataset.Columns, dataset.Rows, perFrame.length].map( + Number + ); + + let orientation = planeOrientation.ImageOrientationPatient.map(Number); + const columnStepToPatient = orientation.slice(0, 3); + const rowStepToPatient = orientation.slice(3, 6); + geometry.planeNormal = []; + vtkMath.cross(columnStepToPatient, rowStepToPatient, geometry.planeNormal); + + let firstPosition = perFrame[0].PlanePositionSequence.ImagePositionPatient.map( + Number + ); + let lastPosition = perFrame[ + perFrame.length - 1 + ].PlanePositionSequence.ImagePositionPatient.map(Number); + geometry.sliceStep = []; + vtkMath.subtract(lastPosition, firstPosition, geometry.sliceStep); + vtkMath.normalize(geometry.sliceStep); + geometry.direction = columnStepToPatient + .concat(rowStepToPatient) + .concat(geometry.sliceStep); + geometry.origin = planePosition.ImagePositionPatient.map(Number); + + return geometry; +} + +function _getSegments(dataset) { + const segments = {}; + + dataset.SegmentSequence.forEach(segment => { + const cielab = segment.RecommendedDisplayCIELabValue; + const rgba = dcmjs.data.Colors.dicomlab2RGB(cielab).map(x => + Math.round(x * 255) + ); + + rgba.push(255); + const segmentNumber = segment.SegmentNumber; + + segments[segmentNumber] = { + color: rgba, + functionalGroups: [], + offset: null, + size: null, + pixelData: null, + label: segment.SegmentLabel, + }; + }); + + // make a list of functional groups per segment + dataset.PerFrameFunctionalGroupsSequence.forEach(functionalGroup => { + const segmentNumber = + functionalGroup.SegmentIdentificationSequence.ReferencedSegmentNumber; + segments[segmentNumber].functionalGroups.push(functionalGroup); + }); + + return _getPixelData(dataset, segments); +} + +function getSopClassHandlerModule({ servicesManager, extensionManager }) { + const getDisplaySetsFromSeries = instances => { + return _getDisplaySetsFromSeries( + instances, + servicesManager, + extensionManager + ); + }; + + return [ + { + name: 'dicom-seg', + sopClassUids, + getDisplaySetsFromSeries, + }, + ]; +} + +export default getSopClassHandlerModule; diff --git a/extensions/cornerstone-dicom-seg/src/id.js b/extensions/cornerstone-dicom-seg/src/id.js new file mode 100644 index 00000000000..7b5d940f231 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/id.js @@ -0,0 +1,7 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; +const SOPClassHandlerName = 'dicom-seg'; +const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`; + +export { id, SOPClassHandlerId, SOPClassHandlerName }; diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx new file mode 100644 index 00000000000..da3d909bba5 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -0,0 +1,93 @@ +import { id } from './id'; +import React from 'react'; + +import getSopClassHandlerModule from './getSopClassHandlerModule'; +import PanelSegmentation from './panels/PanelSegmentation'; + +const Component = React.lazy(() => { + return import( + /* webpackPrefetch: true */ './viewports/OHIFCornerstoneSEGViewport' + ); +}); + +const OHIFCornerstoneSEGViewport = props => { + return ( + Loading...}> + + + ); +}; + +/** + * You can remove any of the following modules if you don't need them. + */ +const 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, + + /** + * Perform any pre-registration tasks here. This is called before the extension + * is registered. Usually we run tasks such as: configuring the libraries + * (e.g. cornerstone, cornerstoneTools, ...) or registering any services that + * this extension is providing. + */ + preRegistration: ({ + servicesManager, + commandsManager, + configuration = {}, + }) => {}, + /** + * 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 }) => { + const wrappedPanelSegmentation = () => { + return ( + + ); + }; + + return [ + { + name: 'panelSegmentation', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation', + component: wrappedPanelSegmentation, + }, + ]; + }, + getViewportModule({ servicesManager, extensionManager }) { + const ExtendedOHIFCornerstoneSEGViewport = props => { + return ( + + ); + }; + + return [ + { name: 'dicom-seg', component: ExtendedOHIFCornerstoneSEGViewport }, + ]; + }, + /** + * 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-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx new file mode 100644 index 00000000000..94bc8448a5d --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { SegmentationGroupTable } from '@ohif/ui'; +import callInputDialog from './callInputDialog'; + +import { useTranslation } from 'react-i18next'; +import callColorPickerDialog from './callColorPickerDialog'; + +export default function PanelSegmentation({ + servicesManager, + commandsManager, +}) { + const { + SegmentationService, + UIDialogService, + ViewportGridService, + ToolGroupService, + CornerstoneViewportService, + } = servicesManager.services; + + const { t } = useTranslation('PanelSegmentation'); + const [selectedSegmentationId, setSelectedSegmentationId] = useState(null); + const [ + initialSegmentationConfigurations, + setInitialSegmentationConfigurations, + ] = useState(SegmentationService.getConfiguration()); + + const [segmentations, setSegmentations] = useState(() => + SegmentationService.getSegmentations() + ); + + const [isMinimized, setIsMinimized] = useState({}); + + const onToggleMinimizeSegmentation = useCallback( + id => { + setIsMinimized(prevState => ({ + ...prevState, + [id]: !prevState[id], + })); + }, + [setIsMinimized] + ); + + // Only expand the last segmentation added to the list and collapse the rest + useEffect(() => { + const lastSegmentationId = segmentations[segmentations.length - 1]?.id; + if (lastSegmentationId) { + setIsMinimized(prevState => ({ + ...prevState, + [lastSegmentationId]: false, + })); + } + }, [segmentations, setIsMinimized]); + + useEffect(() => { + // ~~ Subscription + const added = SegmentationService.EVENTS.SEGMENTATION_ADDED; + const updated = SegmentationService.EVENTS.SEGMENTATION_UPDATED; + const removed = SegmentationService.EVENTS.SEGMENTATION_REMOVED; + const subscriptions = []; + + [added, updated, removed].forEach(evt => { + const { unsubscribe } = SegmentationService.subscribe(evt, () => { + const segmentations = SegmentationService.getSegmentations(); + setSegmentations(segmentations); + }); + subscriptions.push(unsubscribe); + }); + + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; + }, []); + + const onSegmentationClick = (segmentationId: string) => { + SegmentationService.setActiveSegmentationForToolGroup(segmentationId); + }; + + const onSegmentationDelete = (segmentationId: string) => { + SegmentationService.remove(segmentationId); + }; + + const getToolGroupIds = segmentationId => { + const toolGroupIds = SegmentationService.getToolGroupIdsWithSegmentation( + segmentationId + ); + + return toolGroupIds; + }; + + const onSegmentClick = (segmentationId, segmentIndex) => { + SegmentationService.setActiveSegmentForSegmentation( + segmentationId, + segmentIndex + ); + + const toolGroupIds = getToolGroupIds(segmentationId); + + toolGroupIds.forEach(toolGroupId => { + // const toolGroupId = + SegmentationService.setActiveSegmentationForToolGroup( + segmentationId, + toolGroupId + ); + SegmentationService.jumpToSegmentCenter( + segmentationId, + segmentIndex, + toolGroupId + ); + }); + }; + + const onSegmentEdit = (segmentationId, segmentIndex) => { + const segmentation = SegmentationService.getSegmentation(segmentationId); + + const segment = segmentation.segments[segmentIndex]; + const { label } = segment; + + callInputDialog(UIDialogService, label, (label, actionId) => { + if (label === '') { + return; + } + + SegmentationService.setSegmentLabelForSegmentation( + segmentationId, + segmentIndex, + label + ); + }); + }; + + const onSegmentationEdit = segmentationId => { + const segmentation = SegmentationService.getSegmentation(segmentationId); + const { label } = segmentation; + + callInputDialog(UIDialogService, label, (label, actionId) => { + if (label === '') { + return; + } + + SegmentationService.addOrUpdateSegmentation( + { + id: segmentationId, + label, + }, + false, // suppress event + true // notYetUpdatedAtSource + ); + }); + }; + + const onSegmentColorClick = (segmentationId, segmentIndex) => { + // Todo: Implement color picker later + return; + }; + + const onSegmentDelete = (segmentationId, segmentIndex) => { + // SegmentationService.removeSegmentFromSegmentation( + // segmentationId, + // segmentIndex + // ); + console.warn('not implemented yet'); + }; + + const onToggleSegmentVisibility = (segmentationId, segmentIndex) => { + const segmentation = SegmentationService.getSegmentation(segmentationId); + const segmentInfo = segmentation.segments[segmentIndex]; + const isVisible = !segmentInfo.isVisible; + const toolGroupIds = getToolGroupIds(segmentationId); + + // Todo: right now we apply the visibility to all tool groups + toolGroupIds.forEach(toolGroupId => { + SegmentationService.setSegmentVisibility( + segmentationId, + segmentIndex, + isVisible, + toolGroupId + ); + }); + }; + + const onToggleSegmentationVisibility = segmentationId => { + SegmentationService.toggleSegmentationVisibility(segmentationId); + }; + + const setSegmentationConfiguration = useCallback( + (segmentationId, key, value) => { + SegmentationService.setConfiguration({ + segmentationId, + [key]: value, + }); + }, + [SegmentationService] + ); + + return ( +
+ {/* show segmentation table */} + {segmentations?.length ? ( + + setSegmentationConfiguration( + selectedSegmentationId, + 'renderOutline', + value + ) + } + setOutlineOpacityActive={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'outlineOpacity', + value + ) + } + setRenderFill={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'renderFill', + value + ) + } + setRenderInactiveSegmentations={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'renderInactiveSegmentations', + value + ) + } + setOutlineWidthActive={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'outlineWidthActive', + value + ) + } + setFillAlpha={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'fillAlpha', + value + ) + } + setFillAlphaInactive={value => + setSegmentationConfiguration( + selectedSegmentationId, + 'fillAlphaInactive', + value + ) + } + /> + ) : null} +
+ ); +} + +PanelSegmentation.propTypes = { + commandsManager: PropTypes.shape({ + runCommand: PropTypes.func.isRequired, + }), + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + SegmentationService: PropTypes.shape({ + getSegmentation: PropTypes.func.isRequired, + getSegmentations: PropTypes.func.isRequired, + toggleSegmentationVisibility: PropTypes.func.isRequired, + subscribe: PropTypes.func.isRequired, + EVENTS: PropTypes.object.isRequired, + }).isRequired, + }).isRequired, + }).isRequired, +}; diff --git a/extensions/cornerstone-dicom-seg/src/panels/callInputDialog.tsx b/extensions/cornerstone-dicom-seg/src/panels/callInputDialog.tsx new file mode 100644 index 00000000000..9ae8e5eb802 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/callInputDialog.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Input, Dialog } from '@ohif/ui'; + +function callInputDialog(UIDialogService, label, callback) { + const dialogId = 'enter-segment-label'; + + const onSubmitHandler = ({ action, value }) => { + switch (action.id) { + case 'save': + callback(value.label, action.id); + break; + case 'cancel': + callback('', action.id); + break; + } + UIDialogService.dismiss({ id: dialogId }); + }; + + if (UIDialogService) { + UIDialogService.create({ + id: dialogId, + centralize: true, + isDraggable: false, + showOverlay: true, + content: Dialog, + contentProps: { + title: 'Enter Segment Label', + value: { label }, + noCloseButton: true, + onClose: () => UIDialogService.dismiss({ id: dialogId }), + actions: [ + { id: 'cancel', text: 'Cancel', type: 'primary' }, + { id: 'save', text: 'Confirm', type: 'secondary' }, + ], + onSubmit: onSubmitHandler, + body: ({ value, setValue }) => { + return ( +
+ { + event.persist(); + setValue(value => ({ ...value, label: event.target.value })); + }} + onKeyPress={event => { + if (event.key === 'Enter') { + onSubmitHandler({ value, action: { id: 'save' } }); + } + }} + /> +
+ ); + }, + }, + }); + } +} + +export default callInputDialog; diff --git a/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx new file mode 100644 index 00000000000..0fbf58b2a4f --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/segmentationConfigReducer.tsx @@ -0,0 +1,35 @@ +import React, { useReducer } from 'react'; + +// Todo: use defaults in cs3d +const initialState = { + renderOutline: true, + renderFill: true, + outlineOpacity: 0.9, + outlineWidth: 3, + fillOpacity: 0.9, + fillOpacityInactive: 0.8, + renderInactiveSegmentations: true, +}; + +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_OPACITY': + return { ...state, fillOpacity: action.payload.value }; + case 'SET_FILL_OPACITY_INACTIVE': + return { ...state, fillOpacityInactive: action.payload.value }; + case 'RENDER_INACTIVE_SEGMENTATIONS': + return { ...state, renderInactiveSegmentations: action.payload.value }; + default: + return state; + } +}; + +export { initialState, reducer }; diff --git a/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts new file mode 100644 index 00000000000..c27439fcd6e --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts @@ -0,0 +1,71 @@ +async function _hydrateSEGDisplaySet({ + segDisplaySet, + viewportIndex, + toolGroupId, + servicesManager, +}) { + const { + SegmentationService, + HangingProtocolService, + ViewportGridService, + } = servicesManager.services; + + const displaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID; + + let segmentationId = null; + + // We need the hydration to notify panels about the new segmentation added + const suppressEvents = false; + + segmentationId = await SegmentationService.createSegmentationForSEGDisplaySet( + segDisplaySet, + segmentationId, + suppressEvents + ); + + SegmentationService.hydrateSegmentation(segDisplaySet.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, + segDisplaySet.displaySetInstanceUID + ); + + if (shouldDisplaySeg) { + ViewportGridService.setDisplaySetsForViewport({ + viewportIndex: index, + displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, + viewportOptions: { + viewportType: 'volume', + toolGroupId, + initialImageOptions: { + preset: 'middle', + }, + }, + }); + } + }); + + return true; +} + +export default _hydrateSEGDisplaySet; diff --git a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts new file mode 100644 index 00000000000..e4c15df9b2e --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts @@ -0,0 +1,34 @@ +function createSEGToolGroupAndAddTools( + ToolGroupService, + toolGroupId, + extensionManager +) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.tools' + ); + + 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, {}); +} + +export default createSEGToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts new file mode 100644 index 00000000000..c60e9ea05a8 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts @@ -0,0 +1,70 @@ +import hydrateSEGDisplaySet from './_hydrateSEG'; + +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + HYDRATE_SEG: 5, +}; + +function promptHydrateSEG({ + servicesManager, + segDisplaySet, + 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 hydrateSEGDisplaySet({ + segDisplaySet, + 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 promptHydrateSEG; diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx new file mode 100644 index 00000000000..e6bd1d62457 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -0,0 +1,424 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import OHIF, { utils } from '@ohif/core'; +import { + Notification, + ViewportActionBar, + useViewportGrid, + useViewportDialog, + LoadingIndicatorProgress, +} from '@ohif/ui'; + +import { useTranslation } from 'react-i18next'; + +import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup'; +import _hydrateSEGDisplaySet from '../utils/_hydrateSEG'; +import promptHydrateSEG from '../utils/promptHydrateSEG'; +import _getStatusComponent from './_getStatusComponent'; + +const { formatDate } = utils; +const SEG_TOOLGROUP_BASE_NAME = 'SEGToolGroup'; + +function OHIFCornerstoneSEGViewport(props) { + const { + children, + displaySets, + viewportOptions, + viewportIndex, + viewportLabel, + servicesManager, + extensionManager, + } = props; + + const { t } = useTranslation('SEGViewport'); + + const { + DisplaySetService, + ToolGroupService, + SegmentationService, + } = servicesManager.services; + + const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportIndex}`; + + // SEG viewport will always have a single display set + if (displaySets.length > 1) { + throw new Error('SEG viewport should only have a single display set'); + } + + const segDisplaySet = 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 SEG is opened and segments are loaded into the + // segmentation panel, and SEG is also rendered on any viewport that is in the + // same frameOfReferenceUID as the referencedSeriesUID of the SEG. However, + // loading basically means SEG loading over network and bit unpacking of the + // SEG data. + const [isHydrated, setIsHydrated] = useState(segDisplaySet.isHydrated); + const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded); + const [element, setElement] = useState(null); + const [processingProgress, setProcessingProgress] = useState({ + segmentIndex: 1, + totalSegments: null, + }); + + // refs + const referencedDisplaySetRef = useRef(null); + + const { viewports, activeViewportIndex } = viewportGrid; + + const referencedDisplaySet = segDisplaySet.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, segDisplaySet, toolGroupId]); + + const onSegmentChange = useCallback( + direction => { + direction = direction === 'left' ? -1 : 1; + const segmentationId = segDisplaySet.displaySetInstanceUID; + const segmentation = SegmentationService.getSegmentation(segmentationId); + + const { segments } = segmentation; + + const numberOfSegments = Object.keys(segments).length; + + let newSelectedSegmentIndex = selectedSegment + direction; + + if (newSelectedSegmentIndex > numberOfSegments - 1) { + newSelectedSegmentIndex = 1; + } else if (newSelectedSegmentIndex === 0) { + newSelectedSegmentIndex = numberOfSegments - 1; + } + + SegmentationService.jumpToSegmentCenter( + segmentationId, + newSelectedSegmentIndex, + toolGroupId + ); + setSelectedSegment(newSelectedSegmentIndex); + }, + [selectedSegment] + ); + + useEffect(() => { + if (segIsLoading) { + return; + } + + promptHydrateSEG({ + servicesManager, + viewportIndex, + segDisplaySet, + }).then(isHydrated => { + if (isHydrated) { + setIsHydrated(true); + } + }); + }, [servicesManager, viewportIndex, segDisplaySet, segIsLoading]); + + useEffect(() => { + const { unsubscribe } = SegmentationService.subscribe( + SegmentationService.EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, + evt => { + if ( + evt.segDisplaySet.displaySetInstanceUID === + segDisplaySet.displaySetInstanceUID + ) { + setSegIsLoading(false); + } + } + ); + + return () => { + unsubscribe(); + }; + }, [segDisplaySet]); + + useEffect(() => { + const { unsubscribe } = SegmentationService.subscribe( + SegmentationService.EVENTS.SEGMENT_PIXEL_DATA_CREATED, + ({ segmentIndex, numSegments }) => { + setProcessingProgress({ + segmentIndex, + totalSegments: numSegments, + }); + } + ); + + return () => { + unsubscribe(); + }; + }, [segDisplaySet]); + + /** + 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 = createSEGToolGroupAndAddTools( + ToolGroupService, + toolGroupId, + extensionManager + ); + + setToolGroupCreated(true); + + return () => { + // remove the segmentation representations if seg displayset changed + SegmentationService.removeSegmentationRepresentationFromToolGroup( + toolGroupId + ); + + ToolGroupService.destroyToolGroup(toolGroupId); + }; + }, []); + + useEffect(() => { + setIsHydrated(segDisplaySet.isHydrated); + + return () => { + // remove the segmentation representations if seg displayset changed + SegmentationService.removeSegmentationRepresentationFromToolGroup( + toolGroupId + ); + referencedDisplaySetRef.current = null; + }; + }, [segDisplaySet]); + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 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 onPillClick = () => { + promptHydrateSEG({ + servicesManager, + viewportIndex, + segDisplaySet, + }).then(isHydrated => { + if (isHydrated) { + setIsHydrated(true); + } + }); + }; + + return ( + <> + { + evt.stopPropagation(); + evt.preventDefault(); + }} + onArrowsClick={onSegmentChange} + getStatusComponent={() => { + return _getStatusComponent({ + isHydrated, + onPillClick, + }); + }} + studyData={{ + label: viewportLabel, + useAltStyling: true, + studyDate: formatDate(StudyDate), + currentSeries: SeriesNumber, + seriesDescription: `SEG 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 || '', + }, + }} + /> + +
+ {segIsLoading && ( + Loading SEG ... + ) : ( + +
Loading Segment
+
{`${processingProgress.segmentIndex}`}
+
/
+
{`${processingProgress.totalSegments}`}
+
+ ) + } + /> + )} + {getCornerstoneViewport()} +
+ {viewportDialogState.viewportIndex === viewportIndex && ( + + )} +
+ {childrenWithProps} +
+ + ); +} + +OHIFCornerstoneSEGViewport.propTypes = { + displaySets: PropTypes.arrayOf(PropTypes.object), + viewportIndex: PropTypes.number.isRequired, + dataSource: PropTypes.object, + children: PropTypes.node, + customProps: PropTypes.object, +}; + +OHIFCornerstoneSEGViewport.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 OHIFCornerstoneSEGViewport; diff --git a/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx new file mode 100644 index 00000000000..3ef7d3ce11b --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Icon, Tooltip } from '@ohif/ui'; + +import _hydrateSEGDisplaySet from '../utils/_hydrateSEG'; + +export default function _getStatusComponent({ isHydrated, onPillClick }) { + let ToolTipMessage = null; + let StatusIcon = null; + + switch (isHydrated) { + case true: + StatusIcon = () => ( +
+ +
+ ); + + ToolTipMessage = () => ( +
This Segmentation is loaded in the segmentation panel
+ ); + break; + case false: + StatusIcon = () => ( +
+ +
+ ); + + ToolTipMessage = () =>
Click to load segmentation.
; + } + + const StatusPill = () => ( +
{ + if (!isHydrated) { + if (onPillClick) { + onPillClick(); + } + } + }} + > +
+ SEG +
+ +
+ ); + + return ( + <> + {ToolTipMessage && ( + } position="bottom-left"> + + + )} + {!ToolTipMessage && } + + ); +} diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 90ef39e1a89..35558a8e1b7 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -33,19 +33,19 @@ }, "peerDependencies": { "@ohif/core": "^3.0.0", + "@ohif/extension-cornerstone": "^3.0.0", + "@ohif/extension-measurement-tracking": "^3.0.0", "@ohif/ui": "^2.0.0", "dcmjs": "^0.28.3", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", "prop-types": "^15.6.2", - "react": "^17.0.2", - "@ohif/extension-cornerstone": "^3.0.0", - "@ohif/extension-measurement-tracking": "^3.0.0" + "react": "^17.0.2" }, "dependencies": { "@babel/runtime": "7.16.3", "classnames": "^2.2.6", - "@cornerstonejs/core": "^0.16.8", - "@cornerstonejs/tools": "^0.24.1" + "@cornerstonejs/core": "^0.21.0", + "@cornerstonejs/tools": "^0.29.2" } } diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.js index 0cfebaeda01..96d6e5de2da 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.js @@ -79,6 +79,7 @@ function _getDisplaySetsFromSeries( SeriesNumber, SeriesDate, ConceptNameCodeSequence, + SOPClassUID, } = instance; if ( @@ -103,6 +104,7 @@ function _getDisplaySetsFromSeries( SeriesInstanceUID, StudyInstanceUID, SOPClassHandlerId, + SOPClassUID, referencedImages: null, measurements: null, isDerivedDisplaySet: true, diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index 680c51cdeaa..64ed151ef1a 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -1,13 +1,18 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import OHIF, { utils } from '@ohif/core'; +import { useTranslation } from 'react-i18next'; import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule'; + import { Notification, ViewportActionBar, useViewportGrid, useViewportDialog, + Tooltip, + Icon, } from '@ohif/ui'; +import classNames from 'classnames'; const { formatDate } = utils; @@ -27,6 +32,8 @@ function OHIFCornerstoneSRViewport(props) { extensionManager, } = props; + const { t } = useTranslation('SRViewport'); + const { DisplaySetService, CornerstoneViewportService, @@ -318,8 +325,6 @@ function OHIFCornerstoneSRViewport(props) { }); } - const { Modality } = srDisplaySet; - const { PatientID, PatientName, @@ -341,24 +346,23 @@ function OHIFCornerstoneSRViewport(props) { evt.stopPropagation(); evt.preventDefault(); }} - onPillClick={() => { - sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', { - displaySetInstanceUID: srDisplaySet.displaySetInstanceUID, + onArrowsClick={onMeasurementChange} + getStatusComponent={() => + _getStatusComponent({ + srDisplaySet, viewportIndex, - }); - }} - onSeriesChange={onMeasurementChange} + isTracked: false, + isRehydratable: srDisplaySet.isRehydratable, + isLocked, + sendTrackedMeasurementsEvent, + }) + } studyData={{ label: viewportLabel, useAltStyling: true, - isTracked: false, - isLocked, - isRehydratable: srDisplaySet.isRehydratable, - isHydrated, studyDate: formatDate(StudyDate), currentSeries: SeriesNumber, seriesDescription: SeriesDescription, - modality: Modality, patientInformation: { patientName: PatientName ? OHIF.utils.formatPN(PatientName.Alphabetic) @@ -439,6 +443,139 @@ async function _getViewportReferencedDisplaySetData( return { referencedDisplaySetMetadata, referencedDisplaySet }; } +function _getStatusComponent({ + srDisplaySet, + viewportIndex, + isRehydratable, + isLocked, + sendTrackedMeasurementsEvent, +}) { + const onPillClick = () => { + sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', { + displaySetInstanceUID: srDisplaySet.displaySetInstanceUID, + viewportIndex, + }); + }; + + // 1 - Incompatible + // 2 - Locked + // 3 - Rehydratable / Open + const state = + isRehydratable && !isLocked ? 3 : isRehydratable && isLocked ? 2 : 1; + let ToolTipMessage = null; + let StatusIcon = null; + + switch (state) { + case 1: + StatusIcon = () => ( +
+ +
+ ); + + ToolTipMessage = () => ( +
+ This structured report is not compatible +
+ with this application. +
+ ); + break; + case 2: + StatusIcon = () => ( +
+ +
+ ); + + ToolTipMessage = () => ( +
+ This structured report is currently read-only +
+ because you are tracking measurements in +
+ another viewport. +
+ ); + break; + case 3: + StatusIcon = () => ( +
+ +
+ ); + + ToolTipMessage = () =>
Click to restore measurements.
; + } + + const StatusPill = () => ( +
{ + if (state === 3) { + if (onPillClick) { + onPillClick(); + } + } + }} + > + SR + +
+ ); + + return ( + <> + {ToolTipMessage && ( + } position="bottom-left"> + + + )} + {!ToolTipMessage && } + + ); +} + // function _onDoubleClick() { // const cancelActiveManipulatorsForElement = cornerstoneTools.getModule( // 'manipulatorState' diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 3e6cc1602d4..b56dd42a682 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -43,11 +43,11 @@ }, "dependencies": { "@babel/runtime": "7.17.9", - "@cornerstonejs/core": "^0.16.8", - "@cornerstonejs/streaming-image-volume-loader": "^0.4.23", - "@cornerstonejs/tools": "^0.24.1", - "@kitware/vtk.js": "^24.18.7", - "dom-to-image": "^2.6.0", + "@cornerstonejs/core": "^0.21.0", + "@cornerstonejs/streaming-image-volume-loader": "^0.6.1", + "@cornerstonejs/tools": "^0.29.2", + "@kitware/vtk.js": "25.9.0", + "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", "lodash.merge": "^4.6.2", "shader-loader": "^1.3.1", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 751105c4c39..de388528ba3 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -8,10 +8,11 @@ import { eventTarget, getEnabledElement, StackViewport, + utilities as csUtils, + CONSTANTS, } from '@cornerstonejs/core'; import { setEnabledElement } from '../state'; -import CornerstoneCacheService from '../services/ViewportService/CornerstoneCacheService'; import './OHIFCornerstoneViewport.css'; import CornerstoneOverlays from './Overlays/CornerstoneOverlays'; @@ -46,24 +47,44 @@ function areEqual(prevProps, nextProps) { return false; } - const prevDisplaySets = prevProps.displaySets[0]; - const nextDisplaySets = nextProps.displaySets[0]; - - if (prevDisplaySets && nextDisplaySets) { - const areSameDisplaySetInstanceUIDs = - prevDisplaySets.displaySetInstanceUID === - nextDisplaySets.displaySetInstanceUID; - const areSameImageLength = - prevDisplaySets.images.length === nextDisplaySets.images.length; - const areSameImageIds = prevDisplaySets.images.every( - (prevImage, index) => - prevImage.imageId === nextDisplaySets.images[index].imageId - ); - return ( - areSameDisplaySetInstanceUIDs && areSameImageLength && areSameImageIds + const prevDisplaySets = prevProps.displaySets; + const nextDisplaySets = nextProps.displaySets; + + if (prevDisplaySets.length !== nextDisplaySets.length) { + return false; + } + + for (let i = 0; i < prevDisplaySets.length; i++) { + const prevDisplaySet = prevDisplaySets[i]; + + const foundDisplaySet = nextDisplaySets.find( + nextDisplaySet => + nextDisplaySet.displaySetInstanceUID === + prevDisplaySet.displaySetInstanceUID ); + + if (!foundDisplaySet) { + return false; + } + + // check they contain the same image + if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) { + return false; + } + + // check if their imageIds are the same + if (foundDisplaySet.images?.length) { + for (let j = 0; j < foundDisplaySet.images.length; j++) { + if ( + foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId + ) { + return false; + } + } + } } - return false; + + return true; } // Todo: This should be done with expose of internal API similar to react-vtkjs-viewport @@ -85,8 +106,6 @@ const OHIFCornerstoneViewport = React.memo(props => { } = props; const [scrollbarHeight, setScrollbarHeight] = useState('100px'); - const [viewportData, setViewportData] = useState(null); - const [_, viewportGridService] = useViewportGrid(); const elementRef = useRef(); @@ -97,6 +116,8 @@ const OHIFCornerstoneViewport = React.memo(props => { ToolGroupService, SyncGroupService, CornerstoneViewportService, + CornerstoneCacheService, + ViewportGridService, } = servicesManager.services; // useCallback for scroll bar height calculation @@ -153,7 +174,7 @@ const OHIFCornerstoneViewport = React.memo(props => { // disable the element upon unmounting useEffect(() => { - CornerstoneViewportService.enableElement( + CornerstoneViewportService.enableViewport( viewportIndex, viewportOptions, elementRef.current @@ -175,7 +196,11 @@ const OHIFCornerstoneViewport = React.memo(props => { const renderingEngineId = viewportInfo.getRenderingEngineId(); const syncGroups = viewportInfo.getSyncGroups(); - ToolGroupService.disable(viewportId, renderingEngineId); + ToolGroupService.removeViewportFromToolGroup( + viewportId, + renderingEngineId + ); + SyncGroupService.removeViewportFromSyncGroup( viewportId, renderingEngineId, @@ -184,14 +209,14 @@ const OHIFCornerstoneViewport = React.memo(props => { CornerstoneViewportService.disableElement(viewportIndex); + if (onElementDisabled) { + onElementDisabled(viewportInfo); + } + eventTarget.removeEventListener( Enums.Events.ELEMENT_ENABLED, elementEnabledHandler ); - - if (onElementDisabled) { - onElementDisabled(); - } }; }, []); @@ -207,11 +232,12 @@ const OHIFCornerstoneViewport = React.memo(props => { const { unsubscribe } = DisplaySetService.subscribe( DisplaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED, async invalidatedDisplaySetInstanceUID => { - if ( - viewportData.displaySetInstanceUIDs.includes( - invalidatedDisplaySetInstanceUID - ) - ) { + const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex( + viewportIndex + ); + + if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) { + const viewportData = viewportInfo.getViewportData(); const newViewportData = await CornerstoneCacheService.invalidateViewportData( viewportData, invalidatedDisplaySetInstanceUID, @@ -225,15 +251,13 @@ const OHIFCornerstoneViewport = React.memo(props => { newViewportData, keepCamera ); - - setViewportData(newViewportData); } } ); return () => { unsubscribe(); }; - }, [viewportData, viewportIndex]); + }, [viewportIndex]); useEffect(() => { // handle the default viewportType to be stack @@ -242,22 +266,19 @@ const OHIFCornerstoneViewport = React.memo(props => { } const loadViewportData = async () => { - await CornerstoneCacheService.getViewportData( - viewportIndex, + const viewportData = await CornerstoneCacheService.createViewportData( displaySets, - viewportOptions.viewportType, + viewportOptions, dataSource, - viewportDataLoaded => { - CornerstoneViewportService.setViewportDisplaySets( - viewportIndex, - viewportDataLoaded, - viewportOptions, - displaySetOptions - ); - setViewportData(viewportDataLoaded); - }, initialImageIndex ); + + CornerstoneViewportService.setViewportData( + viewportIndex, + viewportData, + viewportOptions, + displaySetOptions + ); }; loadViewportData(); @@ -280,7 +301,8 @@ const OHIFCornerstoneViewport = React.memo(props => { elementRef, viewportIndex, displaySets, - viewportGridService + ViewportGridService, + CornerstoneViewportService ); _checkForCachedJumpToMeasurementEvents( @@ -289,13 +311,14 @@ const OHIFCornerstoneViewport = React.memo(props => { elementRef, viewportIndex, displaySets, - viewportGridService + ViewportGridService, + CornerstoneViewportService ); return () => { unsubscribeFromJumpToMeasurementEvents(); }; - }, [displaySets, elementRef, viewportIndex, viewportData]); + }, [displaySets, elementRef, viewportIndex]); return (
@@ -303,7 +326,7 @@ const OHIFCornerstoneViewport = React.memo(props => { handleWidth handleHeight skipOnMount={true} // Todo: make these configurable - refreshMode={'debounce'} + refreshMode={'throttle'} refreshRate={100} onResize={onResize} targetRef={elementRef.current} @@ -332,7 +355,8 @@ function _subscribeToJumpToMeasurementEvents( elementRef, viewportIndex, displaySets, - viewportGridService + viewportGridService, + CornerstoneViewportService ) { const displaysUIDs = displaySets.map( displaySet => displaySet.displaySetInstanceUID @@ -350,7 +374,8 @@ function _subscribeToJumpToMeasurementEvents( viewportIndex, MeasurementService, DisplaySetService, - viewportGridService + ViewportGridService, + CornerstoneViewportService ); } } @@ -366,7 +391,8 @@ function _checkForCachedJumpToMeasurementEvents( elementRef, viewportIndex, displaySets, - viewportGridService + ViewportGridService, + CornerstoneViewportService ) { const displaysUIDs = displaySets.map( displaySet => displaySet.displaySetInstanceUID @@ -389,7 +415,8 @@ function _checkForCachedJumpToMeasurementEvents( viewportIndex, MeasurementService, DisplaySetService, - viewportGridService + ViewportGridService, + CornerstoneViewportService ); } } @@ -401,7 +428,8 @@ function _jumpToMeasurement( viewportIndex, MeasurementService, DisplaySetService, - viewportGridService + ViewportGridService, + CornerstoneViewportService ) { const targetElement = targetElementRef.current; const { displaySetInstanceUID, SOPInstanceUID, frameNumber } = measurement; @@ -418,14 +446,20 @@ function _jumpToMeasurement( // to set it properly // setCornerstoneMeasurementActive(measurement); - viewportGridService.setActiveViewportIndex(viewportIndex); + ViewportGridService.setActiveViewportIndex(viewportIndex); const enableElement = getEnabledElement(targetElement); + + const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex( + viewportIndex + ); + if (enableElement) { // See how the jumpToSlice() of Cornerstone3D deals with imageIdx param. const viewport = enableElement.viewport as IStackViewport | IVolumeViewport; let imageIdIndex = 0; + let viewportCameraDirectionMatch = true; if (viewport instanceof StackViewport) { const imageIds = viewport.getImageIds(); @@ -440,9 +474,29 @@ 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; imageIdIndex = referencedDisplaySet.images.findIndex( i => i.SOPInstanceUID === SOPInstanceUID ); + + const { orientation } = viewportInfo.getViewportOptions(); + + if ( + orientation && + viewPlaneNormal && + !csUtils.isEqual( + CONSTANTS.MPR_CAMERA_VALUES[orientation]?.viewPlaneNormal, + viewPlaneNormal + ) + ) { + viewportCameraDirectionMatch = false; + } + } + + if (!viewportCameraDirectionMatch || imageIdIndex === -1) { + return; } cs3DTools.utilities.jumpToSlice(targetElement, { diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index b55f6642d00..92e88d4f5d2 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx @@ -3,8 +3,7 @@ import React, { useEffect, useState } from 'react'; import ViewportImageScrollbar from './ViewportImageScrollbar'; import ViewportOverlay from './ViewportOverlay'; import ViewportOrientationMarkers from './ViewportOrientationMarkers'; -import ViewportLoadingIndicator from './ViewportLoadingIndicator'; -import CornerstoneCacheService from '../../services/ViewportService/CornerstoneCacheService'; +import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator'; function CornerstoneOverlays(props) { const { viewportIndex, element, scrollbarHeight, servicesManager } = props; @@ -16,8 +15,8 @@ function CornerstoneOverlays(props) { const [viewportData, setViewportData] = useState(null); useEffect(() => { - const { unsubscribe } = CornerstoneCacheService.subscribe( - CornerstoneCacheService.EVENTS.VIEWPORT_DATA_CHANGED, + const { unsubscribe } = CornerstoneViewportService.subscribe( + CornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED, props => { if (props.viewportIndex !== viewportIndex) { return; @@ -47,7 +46,7 @@ function CornerstoneOverlays(props) { } return ( -
+
- +
diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx index 9e978cd91f7..638fe97f75f 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx @@ -57,7 +57,7 @@ function CornerstoneImageScrollbar({ setImageSliceData({ imageIndex: imageIndex, - numberOfSlices: viewportData.imageIds.length, + numberOfSlices: viewportData.data.imageIds.length, }); return; @@ -87,7 +87,7 @@ function CornerstoneImageScrollbar({ // find the index of imageId in the imageIds setImageSliceData({ imageIndex: newImageIdIndex, - numberOfSlices: viewportData.imageIds.length, + numberOfSlices: viewportData.data.imageIds.length, }); }; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportLoadingIndicator.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx similarity index 92% rename from extensions/cornerstone/src/Viewport/Overlays/ViewportLoadingIndicator.tsx rename to extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx index 41efa66c094..fc483b5f475 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportLoadingIndicator.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import { Enums } from '@cornerstonejs/core'; -function ViewportLoadingIndicator({ viewportData, element }) { +function ViewportImageSliceLoadingIndicator({ viewportData, element }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(false); @@ -89,15 +89,15 @@ function ViewportLoadingIndicator({ viewportData, element }) { return null; } -ViewportLoadingIndicator.propTypes = { +ViewportImageSliceLoadingIndicator.propTypes = { percentComplete: PropTypes.number, error: PropTypes.object, element: PropTypes.object, }; -ViewportLoadingIndicator.defaultProps = { +ViewportImageSliceLoadingIndicator.defaultProps = { percentComplete: 0, error: null, }; -export default ViewportLoadingIndicator; +export default ViewportImageSliceLoadingIndicator; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css index c8d826d321f..9493548b759 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css @@ -11,7 +11,7 @@ position: absolute; } .ViewportOrientationMarkers .top-mid { - top: 5px; + top: 0.6rem; left: 50%; } .ViewportOrientationMarkers .left-mid { @@ -23,7 +23,7 @@ left: calc(100% - var(--marker-width) - var(--scrollbar-width)); } .ViewportOrientationMarkers .bottom-mid { - top: calc(100% - var(--marker-height) - 5px); + top: calc(100% - var(--marker-height) - 0.6rem); left: 47%; } .ViewportOrientationMarkers .right-mid .orientation-marker-value { diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx index 1570ba00808..025f7fb71b9 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx @@ -1,10 +1,158 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { metaData, Enums, Types } from '@cornerstonejs/core'; +import React, { useEffect, useState, useMemo } from 'react'; +import { + metaData, + Enums, + Types, + getEnabledElement, + utilities as csUtils, +} from '@cornerstonejs/core'; import { utilities } from '@cornerstonejs/tools'; +import PropTypes from 'prop-types'; +import { vec3 } from 'gl-matrix'; import './ViewportOrientationMarkers.css'; -import { getEnabledElement } from '../../state'; + +const { + getOrientationStringLPS, + invertOrientationStringLPS, +} = utilities.orientation; + +function ViewportOrientationMarkers({ + element, + viewportData, + imageSliceData, + viewportIndex, + servicesManager, + orientationMarkers = ['top', 'left'], +}) { + // Rotation is in degrees + const [rotation, setRotation] = useState(0); + const [flipHorizontal, setFlipHorizontal] = useState(false); + const [flipVertical, setFlipVertical] = useState(false); + const { CornerstoneViewportService } = servicesManager.services; + + useEffect(() => { + const cameraModifiedListener = ( + evt: Types.EventTypes.CameraModifiedEvent + ) => { + const { rotation, previousCamera, camera } = evt.detail; + + if (rotation !== undefined) { + setRotation(rotation); + } + + if ( + camera.flipHorizontal !== undefined && + previousCamera.flipHorizontal !== camera.flipHorizontal + ) { + setFlipHorizontal(camera.flipHorizontal); + } + + if ( + camera.flipVertical !== undefined && + previousCamera.flipVertical !== camera.flipVertical + ) { + setFlipVertical(camera.flipVertical); + } + }; + + element.addEventListener( + Enums.Events.CAMERA_MODIFIED, + cameraModifiedListener + ); + + return () => { + element.removeEventListener( + Enums.Events.CAMERA_MODIFIED, + cameraModifiedListener + ); + }; + }, []); + + const markers = useMemo(() => { + if (!viewportData) { + return ''; + } + + let rowCosines, columnCosines; + if (viewportData.viewportType === 'stack') { + const imageIndex = imageSliceData.imageIndex; + const imageId = viewportData.data.imageIds?.[imageIndex]; + + // Workaround for below TODO stub + if (!imageId) { + return false; + } + + ({ rowCosines, columnCosines } = + metaData.get('imagePlaneModule', imageId) || {}); + } else { + if (!element || !getEnabledElement(element)) { + return ''; + } + + const { viewport } = getEnabledElement(element); + const { viewUp, viewPlaneNormal } = viewport.getCamera(); + + const viewRight = vec3.create(); + vec3.cross(viewRight, viewUp, viewPlaneNormal); + + columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]]; + rowCosines = viewRight; + } + + if (!rowCosines || !columnCosines || rotation === undefined) { + return ''; + } + + const markers = _getOrientationMarkers( + rowCosines, + columnCosines, + rotation, + flipVertical, + flipHorizontal + ); + + const ohifViewport = CornerstoneViewportService.getViewportInfoByIndex( + viewportIndex + ); + + const backgroundColor = ohifViewport.getViewportOptions().background; + + const isLight = backgroundColor + ? csUtils.isEqual(backgroundColor, [1, 1, 1]) + : false; + + return orientationMarkers.map((m, index) => ( +
+
{markers[m]}
+
+ )); + }, [ + viewportData, + imageSliceData, + rotation, + flipVertical, + flipHorizontal, + orientationMarkers, + element, + ]); + + return
{markers}
; +} + +ViewportOrientationMarkers.propTypes = { + percentComplete: PropTypes.number, + error: PropTypes.object, +}; + +ViewportOrientationMarkers.defaultProps = { + percentComplete: 0, + error: null, +}; /** * @@ -16,17 +164,13 @@ import { getEnabledElement } from '../../state'; * @param {*} rotation in degrees * @returns */ -function getOrientationMarkers( +function _getOrientationMarkers( rowCosines, columnCosines, rotation, flipVertical, flipHorizontal ) { - const { - getOrientationStringLPS, - invertOrientationStringLPS, - } = utilities.orientation; const rowString = getOrientationStringLPS(rowCosines); const columnString = getOrientationStringLPS(columnCosines); const oppositeRowString = invertOrientationStringLPS(rowString); @@ -79,117 +223,4 @@ function getOrientationMarkers( return markers; } -function ViewportOrientationMarkers({ - element, - viewportData, - imageSliceData, - viewportIndex, - orientationMarkers = ['top', 'left'], -}) { - // Rotation is in degrees - const [rotation, setRotation] = useState(0); - const [flipHorizontal, setFlipHorizontal] = useState(false); - const [flipVertical, setFlipVertical] = useState(false); - - useEffect(() => { - const cameraModifiedListener = ( - evt: Types.EventTypes.CameraModifiedEvent - ) => { - - const { rotation, previousCamera, camera } = evt.detail; - - if (rotation !== undefined) { - setRotation(rotation); - } - - if (camera.flipHorizontal !== undefined && - previousCamera.flipHorizontal !== camera.flipHorizontal) { - setFlipHorizontal(camera.flipHorizontal); - } - - if (camera.flipVertical !== undefined && - previousCamera.flipVertical !== camera.flipVertical) { - setFlipVertical(camera.flipVertical); - } - }; - - element.addEventListener( - Enums.Events.CAMERA_MODIFIED, - cameraModifiedListener - ); - - return () => { - element.removeEventListener( - Enums.Events.CAMERA_MODIFIED, - cameraModifiedListener - ); - }; - }, []); - - const getMarkers = useCallback( - orientationMarkers => { - // Todo: support orientation markers for the volume viewports - if ( - !viewportData || - viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC - ) { - return ''; - } - - const imageIndex = imageSliceData.imageIndex; - const imageId = viewportData.imageIds?.[imageIndex]; - - // Workaround for below TODO stub - if (!imageId) { - return false; - } - - const { rowCosines, columnCosines } = - metaData.get('imagePlaneModule', imageId) || {}; - - if (!rowCosines || !columnCosines || rotation === undefined) { - return false; - } - - if (!rowCosines || !columnCosines) { - return ''; - } - - const markers = getOrientationMarkers( - rowCosines, - columnCosines, - rotation, - flipVertical, - flipHorizontal - ); - - return orientationMarkers.map((m, index) => ( -
-
{markers[m]}
-
- )); - }, - [flipHorizontal, flipVertical, rotation, viewportData, imageSliceData] - ); - - return ( -
- {getMarkers(orientationMarkers)} -
- ); -} - -ViewportOrientationMarkers.propTypes = { - percentComplete: PropTypes.number, - error: PropTypes.object, -}; - -ViewportOrientationMarkers.defaultProps = { - percentComplete: 0, - error: null, -}; - export default ViewportOrientationMarkers; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx index 7c5a071c765..977bd588ab0 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOverlay.tsx @@ -28,6 +28,25 @@ function CornerstoneViewportOverlay({ setActiveTools(ToolBarService.getActiveTools()); }, []); + useEffect(() => { + let isMounted = true; + const { unsubscribe } = ToolBarService.subscribe( + ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED, + () => { + if (!isMounted) { + return; + } + + setActiveTools(ToolBarService.getActiveTools()); + } + ); + + return () => { + isMounted = false; + unsubscribe(); + }; + }, []); + /** * Updating the VOI when the viewport changes its voi */ @@ -100,23 +119,6 @@ function CornerstoneViewportOverlay({ }; }, [viewportIndex, viewportData]); - /** - * Updating the active tools when the toolbar changes - */ - // Todo: this should act on the toolGroups instead of the toolbar state - useEffect(() => { - const { unsubscribe } = ToolBarService.subscribe( - ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED, - () => { - setActiveTools(ToolBarService.getActiveTools()); - } - ); - - return () => { - unsubscribe(); - }; - }, [ToolBarService]); - const getTopLeftContent = useCallback(() => { const { windowWidth, windowCenter } = voi; @@ -186,12 +188,6 @@ function CornerstoneViewportOverlay({ return null; } - if (viewportData.imageIds.length === 0) { - throw new Error( - 'ViewportOverlay: only viewports with imageIds is supported at this time' - ); - } - return ( { const { ViewportGridService, ToolGroupService, + DisplaySetService, + SyncGroupService, CineService, ToolBarService, UIDialogService, CornerstoneViewportService, - SegmentationService, - DisplaySetService, HangingProtocolService, UINotificationService, } = servicesManager.services; @@ -133,34 +143,23 @@ const commandsModule = ({ servicesManager }) => { }); viewport.render(); }, - toggleCrosshairs({ toolGroupId, toggledState }) { - const toolName = 'Crosshairs'; - // If it is Enabled - if (toggledState) { - actions.setToolActive({ toolName, toolGroupId }); - return; - } - const toolGroup = _getToolGroup(toolGroupId); - - if (!toolGroup) { - return; + setToolActive: ({ toolName, toolGroupId = null }) => { + if (toolName === 'Crosshairs') { + const activeViewportToolGroup = _getToolGroup(null); + + if (!activeViewportToolGroup._toolInstances.Crosshairs) { + UINotificationService.show({ + title: 'Crosshairs', + message: + 'You need to be in a MPR view to use Crosshairs. Click on MPR button in the toolbar to activate it.', + type: 'info', + duration: 3000, + }); + + throw new Error('Crosshairs tool is not available in this viewport'); + } } - toolGroup.setToolDisabled(toolName); - - // Get the primary toolId from the ToolBarService and set it to active - // Since it was set to passive if not already active - const primaryActiveTool = ToolBarService.state.primaryToolId; - if ( - toolGroup?.toolOptions[primaryActiveTool]?.mode === - Enums.ToolModes.Passive - ) { - toolGroup.setToolActive(primaryActiveTool, { - bindings: [{ mouseButton: Enums.MouseBindings.Primary }], - }); - } - }, - setToolActive: ({ toolName, toolGroupId = null }) => { const toolGroup = _getToolGroup(toolGroupId); if (!toolGroup) { @@ -298,8 +297,12 @@ const commandsModule = ({ servicesManager }) => { if (viewport instanceof StackViewport) { viewport.resetProperties(); viewport.resetCamera(); - viewport.render(); + } else { + // Todo: add reset properties for volume viewport + viewport.resetCamera(); } + + viewport.render(); }, scaleViewport: ({ direction }) => { const enabledElement = _getActiveViewportEnabledElement(); @@ -331,59 +334,7 @@ const commandsModule = ({ servicesManager }) => { const { viewport } = enabledElement; const options = { delta: direction }; - csToolsUtils.scroll(viewport, options); - }, - async createSegmentationForDisplaySet({ displaySetInstanceUID }) { - const volumeId = displaySetInstanceUID; - - const segmentationUID = csUtils.uuidv4(); - const segmentationId = `${volumeId}::${segmentationUID}`; - - await volumeLoader.createAndCacheDerivedVolume(volumeId, { - volumeId: segmentationId, - }); - - // Add the segmentations to state - segmentation.addSegmentations([ - { - segmentationId, - representation: { - // The type of segmentation - type: Enums.SegmentationRepresentations.Labelmap, - // The actual segmentation data, in the case of labelmap this is a - // reference to the source volume of the segmentation. - data: { - volumeId: segmentationId, - }, - }, - }, - ]); - - return segmentationId; - }, - async addSegmentationRepresentationToToolGroup({ - segmentationId, - toolGroupId, - representationType, - }) { - // // Add the segmentation representation to the toolgroup - await segmentation.addSegmentationRepresentations(toolGroupId, [ - { - segmentationId, - type: representationType, - }, - ]); - }, - getLabelmapVolumes: ({ segmentations }) => { - if (!segmentations || !segmentations.length) { - segmentations = SegmentationService.getSegmentations(); - } - - const labelmapVolumes = segmentations.map(segmentation => { - return cache.getVolume(segmentation.id); - }); - - return labelmapVolumes; + cstUtils.scroll(viewport, options); }, setViewportColormap: ({ viewportIndex, @@ -398,7 +349,7 @@ const commandsModule = ({ servicesManager }) => { const actorEntries = viewport.getActors(); const actorEntry = actorEntries.find(actorEntry => { - return actorEntry.uid === displaySetInstanceUID; + return actorEntry.uid.includes(displaySetInstanceUID); }); const { actor: volumeActor } = actorEntry; @@ -423,6 +374,254 @@ const commandsModule = ({ servicesManager }) => { setHangingProtocol: ({ protocolId }) => { HangingProtocolService.setProtocol(protocolId); }, + toggleMPR: ({ toggledState }) => { + const { activeViewportIndex, viewports } = ViewportGridService.getState(); + const viewportDisplaySetInstanceUIDs = + viewports[activeViewportIndex].displaySetInstanceUIDs; + + const errorCallback = error => { + UINotificationService.show({ + title: 'Multiplanar reconstruction (MPR) ', + message: + 'Cannot create MPR for this DisplaySet since it is not reconstructable.', + type: 'info', + duration: 3000, + }); + }; + + const cacheId = 'beforeMPR'; + if (toggledState) { + ViewportGridService.setCachedLayout({ + cacheId, + cachedLayout: ViewportGridService.getState(), + }); + + const matchDetails = { + displaySetInstanceUIDs: viewportDisplaySetInstanceUIDs, + }; + + HangingProtocolService.setProtocol( + MPR_TOOLGROUP_ID, + matchDetails, + errorCallback + ); + return; + } + + const { cachedLayout } = ViewportGridService.getState(); + + if (!cachedLayout || !cachedLayout[cacheId]) { + return; + } + + const { viewports: cachedViewports, numRows, numCols } = cachedLayout[ + cacheId + ]; + + // Todo: The following assumes that when turning off MPR we are applying the default + // protocol which might not be the one that was used before MPR was turned on + // In order to properly implement this logic, we should modify the hanging protocol + // upon layout change with layout selector, and cache and restore it when turning + // MPR on and off + const viewportStructure = getProtocolViewportStructureFromGridViewports({ + viewports: cachedViewports, + numRows, + numCols, + }); + + const viewportSpecificMatch = cachedViewports.reduce( + (acc, viewport, index) => { + const { + displaySetInstanceUIDs, + viewportOptions, + displaySetOptions, + } = viewport; + + acc[index] = { + displaySetInstanceUIDs, + viewportOptions, + displaySetOptions, + }; + + return acc; + }, + {} + ); + + const defaultProtocol = HangingProtocolService.getProtocolById('default'); + + // Todo: this assumes there is only one stage in the default protocol + const defaultProtocolStage = defaultProtocol.stages[0]; + defaultProtocolStage.viewportStructure = viewportStructure; + + const { primaryToolId } = ToolBarService.state; + const mprToolGroup = _getToolGroup(MPR_TOOLGROUP_ID); + // turn off crosshairs if it is on + if ( + primaryToolId === 'Crosshairs' || + mprToolGroup.getToolInstance('Crosshairs')?.mode === + Enums.ToolModes.Active + ) { + const toolGroup = _getToolGroup(MPR_TOOLGROUP_ID); + toolGroup.setToolDisabled('Crosshairs'); + ToolBarService.recordInteraction({ + groupId: 'WindowLevel', + itemId: 'WindowLevel', + interactionType: 'tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'WindowLevel', + }, + context: 'CORNERSTONE', + }, + ], + }); + } + + // clear segmentations if they exist + removeToolGroupSegmentationRepresentations(MPR_TOOLGROUP_ID); + + HangingProtocolService.setProtocol( + 'default', + viewportSpecificMatch, + error => { + UINotificationService.show({ + title: 'Multiplanar reconstruction (MPR) ', + message: + 'Something went wrong while trying to restore the previous layout.', + type: 'info', + duration: 3000, + }); + } + ); + }, + toggleStackImageSync: ({ toggledState }) => { + if (!toggledState) { + STACK_IMAGE_SYNC_GROUPS_INFO.forEach(syncGroupInfo => { + const { viewports, synchronizerId } = syncGroupInfo; + + viewports.forEach(({ viewportId, renderingEngineId }) => { + SyncGroupService.removeViewportFromSyncGroup( + viewportId, + renderingEngineId, + synchronizerId + ); + }); + }); + + return; + } + + STACK_IMAGE_SYNC_GROUPS_INFO = []; + + // create synchronization groups and add viewports + let { viewports } = ViewportGridService.getState(); + + // filter empty viewports + viewports = viewports.filter( + viewport => + viewport.displaySetInstanceUIDs && + viewport.displaySetInstanceUIDs.length + ); + + // filter reconstructable viewports + viewports = viewports.filter(viewport => { + const { displaySetInstanceUIDs } = viewport; + + for (const displaySetInstanceUID of displaySetInstanceUIDs) { + const displaySet = DisplaySetService.getDisplaySetByUID( + displaySetInstanceUID + ); + + if (displaySet && displaySet.isReconstructable) { + return true; + } + + return false; + } + }); + + const viewportsByOrientation = viewports.reduce((acc, viewport) => { + const { viewportId, viewportType } = viewport.viewportOptions; + + if (viewportType !== 'stack') { + console.warn('Viewport is not a stack, cannot sync images yet'); + return acc; + } + + const { element } = CornerstoneViewportService.getViewportInfo( + viewportId + ); + const { viewport: csViewport, renderingEngineId } = getEnabledElement( + element + ); + const { viewPlaneNormal } = csViewport.getCamera(); + + // Should we round here? I guess so, but not sure how much precision we need + const orientation = viewPlaneNormal.map(v => Math.round(v)).join(','); + + if (!acc[orientation]) { + acc[orientation] = []; + } + + acc[orientation].push({ viewportId, renderingEngineId }); + + return acc; + }, {}); + + // create synchronizer for each group + Object.values(viewportsByOrientation).map(viewports => { + let synchronizerId = viewports + .map(({ viewportId }) => viewportId) + .join(','); + + synchronizerId = `imageSync_${synchronizerId}`; + + calculateViewportRegistrations(viewports); + + viewports.forEach(({ viewportId, renderingEngineId }) => { + SyncGroupService.addViewportToSyncGroup( + viewportId, + renderingEngineId, + { + type: 'stackimage', + id: synchronizerId, + source: true, + target: true, + } + ); + }); + + STACK_IMAGE_SYNC_GROUPS_INFO.push({ + synchronizerId, + viewports, + }); + }); + }, + toggleReferenceLines: ({ toggledState }) => { + const { activeViewportIndex } = ViewportGridService.getState(); + const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex( + activeViewportIndex + ); + + const viewportId = viewportInfo.getViewportId(); + const toolGroup = ToolGroupService.getToolGroupForViewport(viewportId); + + if (!toggledState) { + toolGroup.setToolDisabled(ReferenceLinesTool.toolName); + } + + toolGroup.setToolConfiguration( + ReferenceLinesTool.toolName, + { + sourceViewportId: viewportId, + }, + true // overwrite + ); + toolGroup.setToolEnabled(ReferenceLinesTool.toolName); + }, }; const definitions = { @@ -524,29 +723,28 @@ const commandsModule = ({ servicesManager }) => { storeContexts: [], options: {}, }, - createSegmentationForDisplaySet: { - commandFn: actions.createSegmentationForDisplaySet, + setViewportColormap: { + commandFn: actions.setViewportColormap, storeContexts: [], options: {}, }, - addSegmentationRepresentationToToolGroup: { - commandFn: actions.addSegmentationRepresentationToToolGroup, + setHangingProtocol: { + commandFn: actions.setHangingProtocol, storeContexts: [], options: {}, }, - - getLabelmapVolumes: { - commandFn: actions.getLabelmapVolumes, + toggleMPR: { + commandFn: actions.toggleMPR, storeContexts: [], options: {}, }, - setViewportColormap: { - commandFn: actions.setViewportColormap, + toggleStackImageSync: { + commandFn: actions.toggleStackImageSync, storeContexts: [], options: {}, }, - setHangingProtocol: { - commandFn: actions.setHangingProtocol, + toggleReferenceLines: { + commandFn: actions.toggleReferenceLines, storeContexts: [], options: {}, }, diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index c728912f743..2d37b96a12d 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -1,144 +1,140 @@ -import { Types } from '@ohif/core'; - -const MPRHangingProtocolGenerator: Types.HangingProtocol.ProtocolGenerator = ({ - servicesManager, - commandsManager, -}) => { - const { - ViewportGridService, - UINotificationService, - DisplaySetService, - } = servicesManager.services; - - const { activeViewportIndex, viewports } = ViewportGridService.getState(); - const viewportDisplaySetInstanceUIDs = - viewports[activeViewportIndex].displaySetInstanceUIDs; - - if ( - !viewportDisplaySetInstanceUIDs || - !viewportDisplaySetInstanceUIDs.length - ) { - return; - } - - const displaySetsToHang = viewportDisplaySetInstanceUIDs.map( - displaySetInstanceUID => { - const displaySet = DisplaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - return displaySet; - } - ); - - if (displaySetsToHang.some(ds => !ds.isReconstructable)) { - UINotificationService.show({ - title: 'Multiplanar reconstruction (MPR) ', - message: - 'Cannot create MPR for this series since it is not reconstructable.', - type: 'warning', - displayTime: 3000, - }); - - return; - } - - const matchingDisplaySets = {}; - - displaySetsToHang.forEach(displaySet => { - const { - displaySetInstanceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = displaySet; - - matchingDisplaySets[displaySetInstanceUID] = { - displaySetInstanceUID, - SeriesInstanceUID, - StudyInstanceUID, - } as Types.HangingProtocol.DisplaySetMatchDetails; - }); - - const hpViewports: Types.HangingProtocol.Viewport[] = [ - 'axial', - 'sagittal', - 'coronal', - ].map(viewportOrientation => { - return { - viewportOptions: { - toolGroupId: 'mpr', - viewportType: 'volume', - orientation: viewportOrientation, - initialImageOptions: { - preset: 'middle', - }, - syncGroups: [ - { - type: 'voi', - id: 'mpr', - source: true, - target: true, +const mpr = { + id: 'mpr', + locked: true, + hasUpdatedPriorsInformation: false, + name: 'mpr', + createdDate: '2021-02-23T19:22:08.894Z', + modifiedDate: '2022-10-04T19:22:08.894Z', + availableTo: {}, + editableBy: {}, + protocolMatchingRules: [], + displaySetSelectors: { + mprDisplaySet: { + seriesMatchingRules: [ + { + weight: 1, + attribute: 'isReconstructable', + constraint: { + equals: { + value: true, + }, }, - ], + required: true, + }, + ], + }, + }, + stages: [ + { + id: 'mpr3Stage', + name: 'mpr', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 3, + layoutOptions: [ + { + x: 0, + y: 0, + width: 1 / 3, + height: 1, + }, + { + x: 1 / 3, + y: 0, + width: 1 / 3, + height: 1, + }, + { + x: 2 / 3, + y: 0, + width: 1 / 3, + height: 1, + }, + ], + }, }, - displaySets: viewportDisplaySetInstanceUIDs.map(displaySetInstanceUID => { - return { - id: displaySetInstanceUID, - }; - }), - }; - }); - - const protocol = { - id: 'mpr', - displaySetSelectors: {}, - stages: [ - { - id: 'mprStage', - name: 'mpr', - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 1, - columns: 3, - layoutOptions: [ + viewports: [ + { + viewportOptions: { + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'axial', + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ { - x: 0, - y: 0, - width: 1 / 3, - height: 1, + type: 'voi', + id: 'mpr', + source: true, + target: true, }, + ], + }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'sagittal', + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ { - x: 1 / 3, - y: 0, - width: 1 / 3, - height: 1, + type: 'voi', + id: 'mpr', + source: true, + target: true, }, + ], + }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'coronal', + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ { - x: 2 / 3, - y: 0, - width: 1 / 3, - height: 1, + type: 'voi', + id: 'mpr', + source: true, + target: true, }, ], }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], }, - viewports: hpViewports, - }, - ], - }; - - return { - protocol, - matchingDisplaySets, - }; + ], + }, + ], }; function getHangingProtocolModule() { return [ { id: 'mpr', - protocol: MPRHangingProtocolGenerator, + protocol: mpr, }, ]; } diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 470e6c46a60..ebddeb64c22 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -7,18 +7,22 @@ import { imageRetrievalPoolManager, } from '@cornerstonejs/core'; import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; -import init from './init.js'; +import init from './init'; import commandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import ToolGroupService from './services/ToolGroupService'; import SyncGroupService from './services/SyncGroupService'; +import SegmentationService from './services/SegmentationService'; +import CornerstoneCacheService from './services/CornerstoneCacheService'; + import { toolNames } from './initCornerstoneTools'; -import { getEnabledElement } from './state'; +import { getEnabledElement, reset as enabledElementReset } from './state'; import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService'; import dicomLoaderService from './utils/dicomLoaderService'; import { registerColormap } from './utils/colormap/transferFunctionHelpers'; import { id } from './id'; +import * as csWADOImageLoader from './initWADOImageLoader.js'; const Component = React.lazy(() => { return import( @@ -50,6 +54,9 @@ const cornerstoneExtension = { imageLoadPoolManager.clearRequestStack(type); imageRetrievalPoolManager.clearRequestStack(type); }); + + csWADOImageLoader.destroy(); + enabledElementReset(); }, /** @@ -69,6 +76,9 @@ const cornerstoneExtension = { ); servicesManager.registerService(ToolGroupService(servicesManager)); servicesManager.registerService(SyncGroupService(servicesManager)); + servicesManager.registerService(SegmentationService(servicesManager)); + servicesManager.registerService(CornerstoneCacheService(servicesManager)); + await init({ servicesManager, commandsManager, configuration, appConfig }); }, getHangingProtocolModule, diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.tsx similarity index 67% rename from extensions/cornerstone/src/init.js rename to extensions/cornerstone/src/init.tsx index f9c72993a71..b4319383c70 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.tsx @@ -1,4 +1,5 @@ import OHIF from '@ohif/core'; +import React from 'react'; import { ContextMenuMeasurements } from '@ohif/ui'; import * as cornerstone from '@cornerstonejs/core'; @@ -13,7 +14,7 @@ import { imageLoadPoolManager, Settings, } from '@cornerstonejs/core'; -import { Enums, utilities } from '@cornerstonejs/tools'; +import { Enums, utilities, ReferenceLinesTool } from '@cornerstonejs/tools'; import { cornerstoneStreamingImageVolumeLoader, sharedArrayBufferImageLoader, @@ -27,7 +28,6 @@ import callInputDialog from './utils/callInputDialog'; import initCineService from './initCineService'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; -import initSegmentationService from './initSegmentationService'; const cs3DToolsEvents = Enums.Events; @@ -59,25 +59,48 @@ export default async function init({ initCornerstoneTools(); - Settings.getRuntimeSettings().set('useCursors', Boolean(appConfig.useCursors)); + Settings.getRuntimeSettings().set( + 'useCursors', + Boolean(appConfig.useCursors) + ); const { UserAuthenticationService, - ToolGroupService, MeasurementService, DisplaySetService, UIDialogService, + UIModalService, CineService, CornerstoneViewportService, HangingProtocolService, + ToolGroupService, SegmentationService, + ViewportGridService, } = servicesManager.services; - const metadataProvider = OHIF.classes.MetadataProvider; + window.SegmentationService = SegmentationService; + window.DisplaySetService = DisplaySetService; + window.services = servicesManager.services; - volumeLoader.registerUnknownVolumeLoader( - cornerstoneStreamingImageVolumeLoader + if (cornerstone.getShouldUseCPURendering()) { + _showCPURenderingModal(UIModalService, HangingProtocolService); + } + + const labelmapRepresentation = + cornerstoneTools.Enums.SegmentationRepresentations.Labelmap; + + cornerstoneTools.segmentation.config.setGlobalRepresentationConfig( + labelmapRepresentation, + { + fillAlpha: 0.3, + fillAlphaInactive: 0.2, + outlineOpacity: 1, + outlineOpacityInactive: 0.65, + } ); + + const metadataProvider = OHIF.classes.MetadataProvider; + volumeLoader.registerVolumeLoader( 'cornerstoneStreamingImageVolume', cornerstoneStreamingImageVolumeLoader @@ -114,8 +137,6 @@ export default async function init({ CornerstoneViewportService ); - initSegmentationService(SegmentationService, CornerstoneViewportService); - initCineService(CineService); const _getDefaultPosition = event => ({ @@ -137,8 +158,8 @@ export default async function init({ currentPoints.canvas ); - let menuItems = []; - if (nearbyToolData) { + const menuItems = []; + if (nearbyToolData && nearbyToolData.metadata.toolName !== 'Crosshairs') { defaultMenuItems.forEach(item => { item.value = nearbyToolData; item.element = element; @@ -266,6 +287,32 @@ export default async function init({ utilities.stackPrefetch.enable(element); }; + const resetCrosshairs = evt => { + const { element } = evt.detail; + const { viewportId, renderingEngineId } = cornerstone.getEnabledElement( + element + ); + + const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport( + viewportId, + renderingEngineId + ); + + if (!toolGroup || !toolGroup._toolInstances?.['Crosshairs']) { + return; + } + + const mode = toolGroup._toolInstances['Crosshairs'].mode; + + if (mode === Enums.ToolModes.Active) { + toolGroup.setToolActive('Crosshairs'); + } else if (mode === Enums.ToolModes.Passive) { + toolGroup.setToolPassive('Crosshairs'); + } else if (mode === Enums.ToolModes.Enabled) { + toolGroup.setToolEnabled('Crosshairs'); + } + }; + function elementEnabledHandler(evt) { const { element } = evt.detail; @@ -274,6 +321,8 @@ export default async function init({ contextMenuHandleClick ); + element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); + eventTarget.addEventListener( EVENTS.STACK_VIEWPORT_NEW_STACK, newStackCallback @@ -281,16 +330,15 @@ export default async function init({ } function elementDisabledHandler(evt) { - const { viewportId, element } = evt.detail; - - const viewportInfo = CornerstoneViewportService.getViewportInfo(viewportId); - ToolGroupService.disable(viewportInfo); + const { element } = evt.detail; element.removeEventListener( cs3DToolsEvents.MOUSE_CLICK, contextMenuHandleClick ); + element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); + // TODO - consider removing the callback when all elements are gone // eventTarget.removeEventListener( // EVENTS.STACK_VIEWPORT_NEW_STACK, @@ -307,4 +355,74 @@ export default async function init({ EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null) ); + + ViewportGridService.subscribe( + ViewportGridService.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED, + ({ viewportIndex }) => { + const viewportId = `viewport-${viewportIndex}`; + const toolGroup = ToolGroupService.getToolGroupForViewport(viewportId); + + if (!toolGroup) { + return; + } + + // check if reference lines are active + const referenceLinesEnabled = + toolGroup._toolInstances?.['ReferenceLines'].mode === + Enums.ToolModes.Enabled; + + if (!referenceLinesEnabled) { + return; + } + + toolGroup.setToolConfiguration( + ReferenceLinesTool.toolName, + { + sourceViewportId: viewportId, + }, + true // overwrite + ); + + // make sure to set it to enabled again since we want to recalculate + // the source-target lines + toolGroup.setToolEnabled(ReferenceLinesTool.toolName); + } + ); +} + +function CPUModal() { + return ( +
+

+ Your computer does not have enough GPU power to support the default GPU + rendering mode. OHIF has switched to CPU rendering mode. Please note + that CPU rendering does not support all features such as Volume + Rendering, Multiplanar Reconstruction, and Segmentation Overlays. +

+
+ ); +} + +function _showCPURenderingModal(UIModalService, HangingProtocolService) { + const callback = progress => { + if (progress === 100) { + UIModalService.show({ + content: CPUModal, + title: 'OHIF Fell Back to CPU Rendering', + }); + + return true; + } + }; + + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT, + ({ progress }) => { + const done = callback(progress); + + if (done) { + unsubscribe(); + } + } + ); } diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index a092976e678..635b7b77eb5 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -12,6 +12,7 @@ import { BidirectionalTool, ArrowAnnotateTool, DragProbeTool, + ProbeTool, AngleTool, MagnifyTool, CrosshairsTool, @@ -19,6 +20,7 @@ import { init, addTool, annotation, + ReferenceLinesTool, } from '@cornerstonejs/tools'; export default function initCornerstoneTools(configuration = {}) { @@ -28,6 +30,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(StackScrollMouseWheelTool); addTool(StackScrollTool); addTool(ZoomTool); + addTool(ProbeTool); addTool(VolumeRotateMouseWheelTool); addTool(MIPJumpToClickTool); addTool(LengthTool); @@ -40,6 +43,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(MagnifyTool); addTool(CrosshairsTool); addTool(SegmentationDisplayTool); + addTool(ReferenceLinesTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { @@ -67,6 +71,7 @@ const toolNames = { MipJumpToClick: MIPJumpToClickTool.toolName, Length: LengthTool.toolName, DragProbe: DragProbeTool.toolName, + Probe: ProbeTool.toolName, RectangleROI: RectangleROITool.toolName, EllipticalROI: EllipticalROITool.toolName, Bidirectional: BidirectionalTool.toolName, @@ -74,6 +79,7 @@ const toolNames = { Magnify: MagnifyTool.toolName, Crosshairs: CrosshairsTool.toolName, SegmentationDisplay: SegmentationDisplayTool.toolName, + ReferenceLines: ReferenceLinesTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/initSegmentationService.js b/extensions/cornerstone/src/initSegmentationService.js deleted file mode 100644 index 3ea099fcec5..00000000000 --- a/extensions/cornerstone/src/initSegmentationService.js +++ /dev/null @@ -1,141 +0,0 @@ -import { eventTarget, cache, triggerEvent } from '@cornerstonejs/core'; -import * as csTools from '@cornerstonejs/tools'; -import { Enums as csToolsEnums } from '@cornerstonejs/tools'; -import Labelmap from './utils/segmentationServiceMappings/Labelmap'; - -function initSegmentationService( - SegmentationService, - CornerstoneViewportService -) { - connectToolsToSegmentationService( - SegmentationService, - CornerstoneViewportService - ); - - connectSegmentationServiceToTools( - SegmentationService, - CornerstoneViewportService - ); -} - -function connectToolsToSegmentationService( - SegmentationService, - CornerstoneViewportService -) { - connectSegmentationServiceToTools( - SegmentationService, - CornerstoneViewportService - ); - const segmentationUpdated = csToolsEnums.Events.SEGMENTATION_MODIFIED; - - eventTarget.addEventListener(segmentationUpdated, evt => { - const { segmentationId } = evt.detail; - const segmentationState = csTools.segmentation.state.getSegmentation( - segmentationId - ); - - if (!segmentationState) { - return; - } - - if ( - !Object.keys(segmentationState.representationData).includes( - csToolsEnums.SegmentationRepresentations.Labelmap - ) - ) { - throw new Error('Non-labelmap representations are not supported yet'); - } - - // Todo: handle other representations when available in cornerstone3D - const segmentationSchema = Labelmap.toSegmentation(segmentationState); - - try { - SegmentationService.addOrUpdateSegmentation( - segmentationId, - segmentationSchema - ); - } catch (error) { - console.warn( - `Failed to add/update segmentation ${segmentationId}`, - error - ); - } - }); -} - -function connectSegmentationServiceToTools( - SegmentationService, - CornerstoneViewportService -) { - const { - SEGMENTATION_UPDATED, - SEGMENTATION_REMOVED, - } = SegmentationService.EVENTS; - - SegmentationService.subscribe(SEGMENTATION_REMOVED, ({ id }) => { - // Todo: This should be from the configuration - const removeFromCache = true; - - const sourceSegState = csTools.segmentation.state.getSegmentation(id); - - if (!sourceSegState) { - return; - } - - const toolGroupIds = csTools.segmentation.state.getToolGroupsWithSegmentation( - id - ); - - toolGroupIds.forEach(toolGroupId => { - const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations( - toolGroupId - ); - - const UIDsToRemove = []; - segmentationRepresentations.forEach(representation => { - if (representation.segmentationId === id) { - UIDsToRemove.push(representation.segmentationRepresentationUID); - } - }); - - csTools.segmentation.removeSegmentationsFromToolGroup( - toolGroupId, - UIDsToRemove - ); - }); - - // cleanup the segmentation state too - csTools.segmentation.state.removeSegmentation(id); - - if (removeFromCache) { - cache.removeVolumeLoadObject(id); - } - }); - - SegmentationService.subscribe( - SEGMENTATION_UPDATED, - ({ id, segmentation, notYetUpdatedAtSource }) => { - if (notYetUpdatedAtSource === false) { - return; - } - const { label, text } = segmentation; - - const sourceSegmentation = csTools.segmentation.state.getSegmentation(id); - - // Update the label in the source if necessary - if (sourceSegmentation.label !== label) { - sourceSegmentation.label = label; - } - - if (sourceSegmentation.text !== text) { - sourceSegmentation.text = text; - } - - triggerEvent(eventTarget, csTools.Enums.Events.SEGMENTATION_MODIFIED, { - segmentationId: id, - }); - } - ); -} - -export default initSegmentationService; diff --git a/extensions/cornerstone/src/initWADOImageLoader.js b/extensions/cornerstone/src/initWADOImageLoader.js index df8357a3e02..a7a8ec0f86c 100644 --- a/extensions/cornerstone/src/initWADOImageLoader.js +++ b/extensions/cornerstone/src/initWADOImageLoader.js @@ -1,7 +1,9 @@ import * as cornerstone from '@cornerstonejs/core'; import { volumeLoader } from '@cornerstonejs/core'; import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader'; -import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; +import cornerstoneWADOImageLoader, { + webWorkerManager, +} from 'cornerstone-wado-image-loader'; import dicomParser from 'dicom-parser'; import { errorHandler } from '@ohif/core'; @@ -80,3 +82,13 @@ export default function initWADOImageLoader( initWebWorkers(appConfig); } + +export function destroy() { + // Note: we don't want to call .terminate on the webWorkerManager since + // that resets the config + const webWorkers = webWorkerManager.webWorkers; + for (let i = 0; i < webWorkers.length; i++) { + webWorkers[i].worker.terminate(); + } + webWorkers.length = 0; +} diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts new file mode 100644 index 00000000000..caef8ebac90 --- /dev/null +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -0,0 +1,252 @@ +import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core'; +import { utils } from '@ohif/core'; + +import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; +import { + StackViewportData, + VolumeViewportData, +} from '../../types/CornerstoneCacheService'; + +const VOLUME_IMAGE_LOADER_SCHEME = 'streaming-wadors'; +const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume'; + +class CornerstoneCacheService { + stackImageIds: Map = new Map(); + volumeImageIds: Map = new Map(); + + constructor(servicesManager) { + this.servicesManager = servicesManager; + } + + public getCacheSize() { + return cs3DCache.getCacheSize(); + } + + public getCacheFreeSpace() { + return cs3DCache.getBytesAvailable(); + } + + public async createViewportData( + displaySets: unknown[], + viewportOptions: Record, + dataSource: unknown, + initialImageIndex?: number + ): Promise { + let viewportType = viewportOptions.viewportType as string; + + // Todo: Since Cornerstone 3D currently doesn't support segmentation + // on stack viewport, we should check if whether the the displaySets + // that are about to be displayed are referenced in a segmentation + // as a reference volume, if so, we should hang a volume viewport + // instead of a stack viewport + if (this._shouldRenderSegmentation(displaySets)) { + viewportType = 'volume'; + + // update viewportOptions to reflect the new viewport type + viewportOptions.viewportType = viewportType; + } + + const cs3DViewportType = getCornerstoneViewportType(viewportType); + let viewportData: StackViewportData | VolumeViewportData; + + if (cs3DViewportType === Enums.ViewportType.STACK) { + viewportData = await this._getStackViewportData( + dataSource, + displaySets, + initialImageIndex + ); + } + + if (cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC) { + viewportData = await this._getVolumeViewportData(dataSource, displaySets); + } + + viewportData.viewportType = cs3DViewportType; + + return viewportData; + } + + public async invalidateViewportData( + viewportData: VolumeViewportData, + invalidatedDisplaySetInstanceUID: string, + dataSource, + DisplaySetService + ) { + if (viewportData.viewportType === Enums.ViewportType.STACK) { + throw new Error('Invalidation of StackViewport is not supported yet'); + } + + // Todo: grab the volume and get the id from the viewport itself + const volumeId = `${VOLUME_LOADER_SCHEME}:${invalidatedDisplaySetInstanceUID}`; + + const volume = cs3DCache.getVolume(volumeId); + + if (volume) { + cs3DCache.removeVolumeLoadObject(volumeId); + } + + const displaySets = viewportData.data.map(({ displaySetInstanceUID }) => + DisplaySetService.getDisplaySetByUID(displaySetInstanceUID) + ); + + const newViewportData = await this._getVolumeViewportData( + dataSource, + displaySets + ); + + return newViewportData; + } + + private _getStackViewportData( + dataSource, + displaySets, + initialImageIndex + ): StackViewportData { + // For Stack Viewport we don't have fusion currently + const displaySet = displaySets[0]; + + let stackImageIds = this.stackImageIds.get( + displaySet.displaySetInstanceUID + ); + + if (!stackImageIds) { + stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource); + this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds); + } + + const { displaySetInstanceUID, StudyInstanceUID } = displaySet; + + const StackViewportData: StackViewportData = { + viewportType: Enums.ViewportType.STACK, + data: { + StudyInstanceUID, + displaySetInstanceUID, + imageIds: stackImageIds, + }, + }; + + if (typeof initialImageIndex === 'number') { + StackViewportData.data.initialImageIndex = initialImageIndex; + } + + return StackViewportData; + } + + private async _getVolumeViewportData( + dataSource, + displaySets + ): Promise { + // Todo: Check the cache for multiple scenarios to see if we need to + // decache the volume data from other viewports or not + + const volumeData = []; + + for (const displaySet of displaySets) { + // Don't create volumes for the displaySets that have custom load + // function (e.g., SEG, RT, since they rely on the reference volumes + // and they take care of their own loading after they are created in their + // getSOPClassHandler method + + if (displaySet.load && displaySet.load instanceof Function) { + const { UserAuthenticationService } = this.servicesManager.services; + const headers = UserAuthenticationService.getAuthorizationHeader(); + await displaySet.load({ headers }); + + volumeData.push({ + studyInstanceUID: displaySet.StudyInstanceUID, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + }); + + // Todo: do some cache check and empty the cache if needed + continue; + } + + const volumeLoaderSchema = + displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME; + + const volumeId = `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`; + + let volumeImageIds = this.volumeImageIds.get( + displaySet.displaySetInstanceUID + ); + + let volume = cs3DCache.getVolume(volumeId); + + if (!volumeImageIds || !volume) { + volumeImageIds = this._getCornerstoneVolumeImageIds( + displaySet, + dataSource + ); + + volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds: volumeImageIds, + }); + + this.volumeImageIds.set( + displaySet.displaySetInstanceUID, + volumeImageIds + ); + } + + volumeData.push({ + StudyInstanceUID: displaySet.StudyInstanceUID, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + volume, + volumeId, + imageIds: volumeImageIds, + }); + } + + return { + viewportType: Enums.ViewportType.ORTHOGRAPHIC, + data: volumeData, + }; + } + + private _shouldRenderSegmentation(displaySets) { + const { SegmentationService } = this.servicesManager.services; + + const viewportDisplaySetInstanceUIDs = displaySets.map( + ({ displaySetInstanceUID }) => displaySetInstanceUID + ); + + // check inside segmentations if any of them are referencing the displaySets + // that are about to be displayed + const segmentations = SegmentationService.getSegmentations(); + + for (const segmentation of segmentations) { + const segDisplaySetInstanceUID = segmentation.displaySetInstanceUID; + + const shouldDisplaySeg = SegmentationService.shouldRenderSegmentation( + viewportDisplaySetInstanceUIDs, + segDisplaySetInstanceUID + ); + + if (shouldDisplaySeg) { + return true; + } + } + } + + private _getCornerstoneStackImageIds(displaySet, dataSource): string[] { + return dataSource.getImageIdsForDisplaySet(displaySet); + } + + private _getCornerstoneVolumeImageIds(displaySet, dataSource): string[] { + const stackImageIds = this._getCornerstoneStackImageIds( + displaySet, + dataSource + ); + + if (stackImageIds[0].startsWith('dicomfile')) { + return stackImageIds; + } + + return stackImageIds.map(imageId => { + const imageURI = utils.imageIdToURI(imageId); + return `${VOLUME_IMAGE_LOADER_SCHEME}:${imageURI}`; + }); + } +} + +export default CornerstoneCacheService; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/index.js b/extensions/cornerstone/src/services/CornerstoneCacheService/index.js new file mode 100644 index 00000000000..4386dcddede --- /dev/null +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/index.js @@ -0,0 +1,10 @@ +import CornerstoneCacheService from './CornerstoneCacheService'; + +export default function ExtendedCornerstoneCacheService(serviceManager) { + return { + name: 'CornerstoneCacheService', + create: ({ configuration = {} }) => { + return new CornerstoneCacheService(serviceManager); + }, + }; +} diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts new file mode 100644 index 00000000000..478e0195bed --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -0,0 +1,1985 @@ +import cloneDeep from 'lodash.clonedeep'; + +import { pubSubServiceInterface } from '@ohif/core'; +import { + utilities as cstUtils, + segmentation as cstSegmentation, + CONSTANTS as cstConstants, + Enums as csToolsEnums, + Types as cstTypes, +} from '@cornerstonejs/tools'; +import { + eventTarget, + cache, + utilities as csUtils, + volumeLoader, + Types, + metaData, + getEnabledElementByIds, +} from '@cornerstonejs/core'; +import isEqual from 'lodash.isequal'; +import { easeInOutBell } from '../../utils/transitions'; +import { + Segmentation, + SegmentationConfig, + SegmentationSchema, +} from './SegmentationServiceTypes'; + +const { COLOR_LUT } = cstConstants; +const LABELMAP = csToolsEnums.SegmentationRepresentations.Labelmap; + +const EVENTS = { + // fired when the segmentation is updated (e.g. when a segment is added, removed, or modified, locked, visibility changed etc.) + SEGMENTATION_UPDATED: 'event::segmentation_updated', + // fired when the segmentation data (e.g., labelmap pixels) is modified + SEGMENTATION_DATA_MODIFIED: 'event::segmentation_data_modified', + // fired when the segmentation is added to the cornerstone + SEGMENTATION_ADDED: 'event::segmentation_added', + // fired when the segmentation is removed + SEGMENTATION_REMOVED: 'event::segmentation_removed', + // 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', + // for all segments + SEGMENTATION_PIXEL_DATA_CREATED: 'event::segmentation_pixel_data_created', +}; + +const VALUE_TYPES = {}; + +class SegmentationService { + listeners = {}; + segmentations: Record; + servicesManager = null; + highlightIntervalId = null; + _broadcastEvent: (eventName: string, callbackProps: any) => void; + readonly EVENTS = EVENTS; + + constructor({ servicesManager }) { + this.segmentations = {}; + this.listeners = {}; + + Object.assign(this, pubSubServiceInterface); + this.servicesManager = servicesManager; + + this._initSegmentationService(); + } + + public destroy = () => { + eventTarget.removeEventListener( + csToolsEnums.Events.SEGMENTATION_MODIFIED, + this._onSegmentationModified + ); + + eventTarget.removeEventListener( + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, + this._onSegmentationDataModified + ); + + // remove the segmentations from the cornerstone + Object.keys(this.segmentations).forEach(segmentationId => { + this._removeSegmentationFromCornerstone(segmentationId); + }); + + this.segmentations = {}; + this.listeners = {}; + }; + + /** + * It adds a segment to a segmentation, basically just setting the properties for + * the segment + * @param segmentationId - The ID of the segmentation you want to add a + * segment to. + * @param segmentIndex - The index of the segment to add. + * @param properties - The properties of the segment to add including + * -- label: the label of the segment + * -- color: the color of the segment + * -- opacity: the opacity of the segment + * -- visibility: the visibility of the segment (boolean) + * -- isLocked: whether the segment is locked for editing + * -- active: whether the segment is currently the active segment to be edited + */ + public addSegment( + segmentationId: string, + segmentIndex: number, + toolGroupId?: string, + properties?: { + label?: string; + color?: Types.Point3; + opacity?: number; + visibility?: boolean; + isLocked?: boolean; + active?: boolean; + } + ): void { + if (segmentIndex === 0) { + throw new Error('Segment index 0 is reserved for "no label"'); + } + + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const { + segmentationRepresentationUID, + segmentation, + } = this._getSegmentationInfo(segmentationId, toolGroupId); + + if (this._getSegmentInfo(segmentation, segmentIndex)) { + throw new Error(`Segment ${segmentIndex} already exists`); + } + + const rgbaColor = cstSegmentation.config.color.getColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex + ); + + segmentation.segments[segmentIndex] = { + label: properties.label, + segmentIndex: segmentIndex, + color: [rgbaColor[0], rgbaColor[1], rgbaColor[2]], + opacity: rgbaColor[3], + isVisible: true, + isLocked: false, + }; + + segmentation.segmentCount++; + + const suppressEvents = true; + if (properties !== undefined) { + const { + color: newColor, + opacity, + isLocked, + visibility, + active, + } = properties; + + if (newColor !== undefined) { + this._setSegmentColor( + segmentationId, + segmentIndex, + newColor, + toolGroupId, + suppressEvents + ); + } + + if (opacity !== undefined) { + this._setSegmentOpacity( + segmentationId, + segmentIndex, + opacity, + toolGroupId, + suppressEvents + ); + } + + if (visibility !== undefined) { + this._setSegmentVisibility( + segmentationId, + segmentIndex, + visibility, + toolGroupId, + suppressEvents + ); + } + + if (active !== undefined) { + this._setActiveSegment(segmentationId, segmentIndex, suppressEvents); + } + + if (isLocked !== undefined) { + this._setSegmentLocked( + segmentationId, + segmentIndex, + isLocked, + suppressEvents + ); + } + } + + if (segmentation.activeSegmentIndex === null) { + this._setActiveSegment(segmentationId, segmentIndex, suppressEvents); + } + + // Todo: this includes nonhydrated segmentations which might not be + // persisted in the store + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + + public removeSegment(segmentationId: string, segmentIndex: number): void { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + if (segmentIndex === 0) { + throw new Error('Segment index 0 is reserved for "no label"'); + } + + if (!this._getSegmentInfo(segmentation, segmentIndex)) { + return; + } + + segmentation.segmentCount--; + + segmentation.segments[segmentIndex] = null; + + // Get volume and delete the labels + // Todo: handle other segmentations other than labelmap + const labelmapVolume = this.getLabelmapVolume(segmentationId); + + const { scalarData, dimensions } = labelmapVolume; + + // Set all values of this segment to zero and get which frames have been edited. + const frameLength = dimensions[0] * dimensions[1]; + const numFrames = dimensions[2]; + + let voxelIndex = 0; + + const modifiedFrames = new Set() as Set; + + for (let frame = 0; frame < numFrames; frame++) { + for (let p = 0; p < frameLength; p++) { + if (scalarData[voxelIndex] === segmentIndex) { + scalarData[voxelIndex] = 0; + modifiedFrames.add(frame); + } + + voxelIndex++; + } + } + + const modifiedFramesArray: number[] = Array.from(modifiedFrames); + + // Trigger texture update of modified segmentation frames. + cstSegmentation.triggerSegmentationEvents.triggerSegmentationDataModified( + segmentationId, + modifiedFramesArray + ); + + if (segmentation.activeSegmentIndex === segmentIndex) { + const segmentIndices = Object.keys(segmentation.segments); + + const newActiveSegmentIndex = segmentIndices.length + ? Number(segmentIndices[0]) + : 1; + + this._setActiveSegment(segmentationId, newActiveSegmentIndex, true); + } + + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + + public setSegmentVisibility( + segmentationId: string, + segmentIndex: number, + isVisible: boolean, + toolGroupId?: string, + suppressEvents = false + ): void { + this._setSegmentVisibility( + segmentationId, + segmentIndex, + isVisible, + toolGroupId, + suppressEvents + ); + } + + public setSegmentLockedForSegmentation( + segmentationId: string, + segmentIndex: number, + isLocked: boolean + ): void { + const suppressEvents = false; + this._setSegmentLocked( + segmentationId, + segmentIndex, + isLocked, + suppressEvents + ); + } + + public setSegmentLabel( + segmentationId: string, + segmentIndex: number, + segmentLabel: string + ): void { + this._setSegmentLabel(segmentationId, segmentIndex, segmentLabel); + } + + public setSegmentColor( + segmentationId: string, + segmentIndex: number, + color: Types.Point3, + toolGroupId?: string + ): void { + this._setSegmentColor(segmentationId, segmentIndex, color, toolGroupId); + } + + public setSegmentRGBA = ( + segmentationId: string, + segmentIndex: number, + rgbaColor: cstTypes.Color, + toolGroupId?: string + ): void => { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const suppressEvents = true; + this._setSegmentOpacity( + segmentationId, + segmentIndex, + rgbaColor[3], + toolGroupId, + suppressEvents + ); + + this._setSegmentColor( + segmentationId, + segmentIndex, + [rgbaColor[0], rgbaColor[1], rgbaColor[2]], + toolGroupId, + suppressEvents + ); + + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + }; + + public setSegmentOpacity( + segmentationId: string, + segmentIndex: number, + opacity: number, + toolGroupId?: string + ): void { + this._setSegmentOpacity(segmentationId, segmentIndex, opacity, toolGroupId); + } + + public setActiveSegmentationForToolGroup( + segmentationId: string, + toolGroupId?: string + ): void { + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const suppressEvents = false; + this._setActiveSegmentationForToolGroup( + segmentationId, + toolGroupId, + suppressEvents + ); + } + + public setActiveSegmentForSegmentation( + segmentationId: string, + segmentIndex: number + ): void { + this._setActiveSegment(segmentationId, segmentIndex, false); + } + + /** + * Get all segmentations. + * + * * @param filterNonHydratedSegmentations - If true, only return hydrated segmentations + * hydrated segmentations are those that have been loaded and persisted + * in the state, but non hydrated segmentations are those that are + * only created for the SEG displayset (SEG viewport) and the user might not + * have loaded them yet fully. + * + + * @return Array of segmentations + */ + public getSegmentations( + filterNonHydratedSegmentations = true + ): Segmentation[] { + const segmentations = this._getSegmentations(); + + return ( + segmentations && + segmentations.filter(segmentation => { + return !filterNonHydratedSegmentations || segmentation.hydrated; + }) + ); + } + + private _getSegmentations(): Segmentation[] { + const segmentations = this.arrayOfObjects(this.segmentations); + return ( + segmentations && + segmentations.map(m => this.segmentations[Object.keys(m)[0]]) + ); + } + + /** + * Get specific segmentation by its id. + * + * @param segmentationId If of the segmentation + * @return segmentation instance + */ + public getSegmentation(segmentationId: string): Segmentation { + return this.segmentations[segmentationId]; + } + + public addOrUpdateSegmentation( + segmentationSchema: SegmentationSchema, + suppressEvents = false, + notYetUpdatedAtSource = false + ): string { + const { id: segmentationId } = segmentationSchema; + let segmentation = this.segmentations[segmentationId]; + if (segmentation) { + // Update the segmentation (mostly for assigning metadata/labels) + Object.assign(segmentation, segmentationSchema); + + this._updateCornerstoneSegmentations({ + segmentationId, + notYetUpdatedAtSource, + }); + + if (!suppressEvents) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + + return segmentationId; + } + + // Add the segmentation otherwise + cstSegmentation.addSegmentations([ + { + segmentationId, + representation: { + type: LABELMAP, + // Todo: need to be generalized + data: { + volumeId: segmentationId, + }, + }, + }, + ]); + + // 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, + isActive: false, + colorLUTIndex: newColorLUTIndex, + isVisible: true, + }; + + segmentation = this.segmentations[segmentationId]; + + this._updateCornerstoneSegmentations({ + segmentationId, + notYetUpdatedAtSource: true, + }); + + if (!suppressEvents) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_ADDED, { + segmentation, + }); + } + + return segmentation.id; + } + + public async createSegmentationForSEGDisplaySet( + segDisplaySet, + segmentationId?: string, + suppressEvents = false + ): Promise { + segmentationId = segmentationId ?? segDisplaySet.displaySetInstanceUID; + const { segments, referencedVolumeId } = segDisplaySet; + + if (!segments || !referencedVolumeId) { + throw new Error( + 'To create the segmentation from SEG displaySet, the displaySet should be loaded first, you can perform segDisplaySet.load() before calling this method.' + ); + } + + 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) { + throw new Error( + `No volume found for referencedVolumeId: ${referencedVolumeId}` + ); + } + + // Force use of a Uint8Array SharedArrayBuffer for the segmentation to save space and so + // it is easily compressible in worker thread. + const derivedVolume = await volumeLoader.createAndCacheDerivedVolume( + referencedVolumeId, + { + volumeId: segmentationId, + targetBuffer: { + type: 'Uint8Array', + sharedArrayBuffer: true, + }, + } + ); + const [rows, columns] = derivedVolume.dimensions; + const derivedVolumeScalarData = derivedVolume.scalarData; + + const { imageIds } = referencedVolume; + const sopUIDImageIdIndexMap = imageIds.reduce((acc, imageId, index) => { + const { sopInstanceUid } = metaData.get('generalImageModule', imageId); + acc[sopInstanceUid] = index; + return acc; + }, {} as { [sopUID: string]: number }); + + const numSegments = Object.keys(segments).length; + // Note: ideally we could use the TypedArray set method, but since each + // slice can have multiple segments, we need to loop over each slice and + // set the segment value for each segment. + const _segmentInfoUpdate = (segmentInfo, segmentIndex) => { + const { pixelData: segPixelData } = segmentInfo; + + let segmentX = 0; + let segmentY = 0; + let segmentZ = 0; + let count = 0; + + for (const [ + functionalGroupIndex, + functionalGroup, + ] of segmentInfo.functionalGroups.entries()) { + const { + ReferencedSOPInstanceUID, + } = functionalGroup.DerivationImageSequence.SourceImageSequence; + + const imageIdIndex = sopUIDImageIdIndexMap[ReferencedSOPInstanceUID]; + + if (imageIdIndex === -1) { + return; + } + + const step = rows * columns; + + // we need a faster way to get the pixel data for the current + // functional group, which we use typed array view + + const functionGroupPixelData = new Uint8Array( + segPixelData.buffer, + functionalGroupIndex * step, + step + ); + + const functionalGroupStartIndex = imageIdIndex * step; + const functionalGroupEndIndex = (imageIdIndex + 1) * step; + + // Note: this for loop is not optimized, since DICOM SEG stores + // each segment as a separate labelmap so if there is a slice + // that has multiple segments, we will have to loop over each + // segment and we cannot use the TypedArray set method. + for ( + let i = functionalGroupStartIndex, j = 0; + i < functionalGroupEndIndex; + i++, j++ + ) { + if (functionGroupPixelData[j] !== 0) { + derivedVolumeScalarData[i] = segmentIndex; + + // centroid calculations + segmentX += i % columns; + segmentY += Math.floor(i / columns) % rows; + segmentZ += Math.floor(i / (columns * rows)); + count++; + } + } + } + + // centroid calculations + const x = Math.floor(segmentX / count); + const y = Math.floor(segmentY / count); + const z = Math.floor(segmentZ / count); + + const centerWorld = derivedVolume.imageData.indexToWorld([x, y, z]); + + segmentationSchema.cachedStats = { + ...segmentationSchema.cachedStats, + segmentCenter: { + ...segmentationSchema.cachedStats.segmentCenter, + [segmentIndex]: { + center: { + image: [x, y, z], + world: centerWorld, + }, + modifiedTime: Date.now(), + }, + }, + }; + + this._broadcastEvent(EVENTS.SEGMENT_PIXEL_DATA_CREATED, { + segmentIndex: Number(segmentIndex), + numSegments, + }); + }; + + for (const segmentIndex in segments) { + const segmentInfo = segments[segmentIndex]; + + // Important: we need a non-blocking way to update the segmentation + // state, otherwise the UI will freeze and the user will not be able + // to interact with the app or progress bars will not be updated. + const promise = new Promise((resolve, reject) => { + setTimeout(() => { + _segmentInfoUpdate(segmentInfo, segmentIndex); + resolve(); + }, 0); + }); + + await promise; + } + + segmentationSchema.segmentCount = Object.keys(segments).length; + segmentationSchema.segments = [null]; // segment 0 + + Object.keys(segments).forEach(segmentIndex => { + const segmentInfo = segments[segmentIndex]; + const segIndex = Number(segmentIndex); + + segmentationSchema.segments[segIndex] = { + label: segmentInfo.label || `Segment ${segIndex}`, + segmentIndex: Number(segmentIndex), + color: [ + segmentInfo.color[0], + segmentInfo.color[1], + segmentInfo.color[2], + ], + opacity: segmentInfo.color[3], + isVisible: true, + isLocked: false, + }; + }); + + segDisplaySet.isLoaded = true; + + this._broadcastEvent(EVENTS.SEGMENTATION_PIXEL_DATA_CREATED, { + segmentationId, + segDisplaySet, + }); + + return this.addOrUpdateSegmentation(segmentationSchema, suppressEvents); + } + + public jumpToSegmentCenter( + segmentationId: string, + segmentIndex: number, + toolGroupId?: string, + highlightAlpha = 0.9, + highlightSegment = true, + animationLength = 750, + highlightHideOthers = false, + highlightFunctionType: 'ease-in-out' // todo: make animation functions configurable from outside + ): void { + const { ToolGroupService } = this.servicesManager.services; + const center = this._getSegmentCenter(segmentationId, segmentIndex); + + const { world } = center; + + // todo: generalize + toolGroupId = + toolGroupId || this._getToolGroupIdsWithSegmentation(segmentationId); + + const toolGroups = []; + + if (Array.isArray(toolGroupId)) { + toolGroupId.forEach(toolGroup => { + toolGroups.push(ToolGroupService.getToolGroup(toolGroup)); + }); + } else { + toolGroups.push(ToolGroupService.getToolGroup(toolGroupId)); + } + + toolGroups.forEach(toolGroup => { + const viewportsInfo = toolGroup.getViewportsInfo(); + + // @ts-ignore + for (const { viewportId, renderingEngineId } of viewportsInfo) { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + cstUtils.viewport.jumpToWorld(viewport, world); + } + + if (highlightSegment) { + this.highlightSegment( + segmentationId, + segmentIndex, + toolGroup.id, + highlightAlpha, + animationLength, + highlightHideOthers, + highlightFunctionType + ); + } + }); + } + + public highlightSegment( + segmentationId: string, + segmentIndex: number, + toolGroupId?: string, + alpha = 0.9, + animationLength = 750, + hideOthers = true, + highlightFunctionType: 'ease-in-out' + ): void { + if (this.highlightIntervalId) { + clearInterval(this.highlightIntervalId); + } + + const segmentation = this.getSegmentation(segmentationId); + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const segmentationRepresentation = this._getSegmentationRepresentation( + segmentationId, + toolGroupId + ); + + const { segments } = segmentation; + + 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); + } + + public createSegmentationForDisplaySet = async ( + displaySetInstanceUID: string, + options?: { + segmentationId: string; + 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 + + const segmentationId = options?.segmentationId ?? `${csUtils.uuidv4()}`; + + // Force use of a Uint8Array SharedArrayBuffer for the segmentation to save space and so + // it is easily compressible in worker thread. + await volumeLoader.createAndCacheDerivedVolume(volumeId, { + volumeId: segmentationId, + targetBuffer: { + type: 'Uint8Array', + sharedArrayBuffer: true, + }, + }); + + const segmentationSchema: SegmentationSchema = { + 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: [], + }; + + this.addOrUpdateSegmentation(segmentationSchema); + + return segmentationId; + }; + + /** + * Toggles the visibility of a segmentation in the state, and broadcasts the event. + * Note: this method does not update the segmentation state in the source. It only + * updates the state, and there should be separate listeners for that. + * @param ids segmentation ids + */ + public toggleSegmentationVisibility = (segmentationId: string): void => { + this._toggleSegmentationVisibility(segmentationId, false); + }; + + public addSegmentationRepresentationToToolGroup = async ( + toolGroupId: string, + segmentationId: string, + hydrateSegmentation = false, + representationType = csToolsEnums.SegmentationRepresentations.Labelmap + ): Promise => { + const segmentation = this.getSegmentation(segmentationId); + + if (!segmentation) { + throw new Error( + `Segmentation with segmentationId ${segmentationId} not found.` + ); + } + + if (hydrateSegmentation) { + // hydrate the segmentation if it's not hydrated yet + segmentation.hydrated = true; + } + + const { colorLUTIndex } = segmentation; + + // Based on the segmentationId, set the colorLUTIndex. + const segmentationRepresentationUIDs = await cstSegmentation.addSegmentationRepresentations( + toolGroupId, + [ + { + segmentationId, + type: representationType, + }, + ] + ); + + // set the latest segmentation representation as active one + this._setActiveSegmentationForToolGroup( + segmentationId, + toolGroupId, + segmentationRepresentationUIDs[0] + ); + + cstSegmentation.config.color.setColorLUT( + toolGroupId, + segmentationRepresentationUIDs[0], + colorLUTIndex + ); + + // add the segmentation segments properly + for (const segment of segmentation.segments) { + if (segment === null || segment === undefined) { + continue; + } + + const { + segmentIndex, + color, + isLocked, + isVisible: visibility, + opacity, + } = segment; + + const suppressEvents = true; + + if (color !== undefined) { + this._setSegmentColor( + segmentationId, + segmentIndex, + color, + toolGroupId, + suppressEvents + ); + } + + if (opacity !== undefined) { + this._setSegmentOpacity( + segmentationId, + segmentIndex, + opacity, + toolGroupId, + suppressEvents + ); + } + + if (visibility !== undefined) { + this._setSegmentVisibility( + segmentationId, + segmentIndex, + visibility, + toolGroupId, + suppressEvents + ); + } + + if (isLocked !== undefined) { + this._setSegmentLocked( + segmentationId, + segmentIndex, + isLocked, + suppressEvents + ); + } + } + }; + + public setSegmentRGBAColorForSegmentation = ( + segmentationId: string, + segmentIndex: number, + rgbaColor, + toolGroupId?: string + ) => { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + this._setSegmentOpacity( + segmentationId, + segmentIndex, + rgbaColor[3], + toolGroupId, // toolGroupId + true + ); + this._setSegmentColor( + segmentationId, + segmentIndex, + [rgbaColor[0], rgbaColor[1], rgbaColor[2]], + toolGroupId, // toolGroupId + true + ); + + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + }; + + public getToolGroupIdsWithSegmentation = ( + segmentationId: string + ): string[] => { + const toolGroupIds = cstSegmentation.state.getToolGroupIdsWithSegmentation( + segmentationId + ); + return toolGroupIds; + }; + + public hydrateSegmentation = ( + segmentationId: string, + suppressEvents = false + ): void => { + const segmentation = this.getSegmentation(segmentationId); + + if (!segmentation) { + throw new Error( + `Segmentation with segmentationId ${segmentationId} not found.` + ); + } + + segmentation.hydrated = true; + + if (!suppressEvents) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + }; + + public removeSegmentationRepresentationFromToolGroup( + toolGroupId: string, + segmentationIds?: string[] + ): void { + segmentationIds = + segmentationIds ?? + cstSegmentation.state + .getSegmentationRepresentations(toolGroupId) + .map(rep => rep.segmentationRepresentationUID); + + cstSegmentation.removeSegmentationsFromToolGroup( + toolGroupId, + segmentationIds, + true // immediate render + ); + } + + /** + * Removes a segmentation and broadcasts the removed event. + * + * @param {string} segmentationId The segmentation id + */ + public remove(segmentationId: string): void { + const segmentation = this.segmentations[segmentationId]; + const wasActive = segmentation.isActive; + + if (!segmentationId || !segmentation) { + console.warn( + `No segmentationId provided, or unable to find segmentation by id.` + ); + return; + } + + const { colorLUTIndex } = segmentation; + + this._removeSegmentationFromCornerstone(segmentationId); + + // Delete associated colormap + // Todo: bring this back + cstSegmentation.state.removeColorLUT(colorLUTIndex); + + delete this.segmentations[segmentationId]; + + // If this segmentation was active, and there is another segmentation, set another one active. + + if (wasActive) { + const remainingSegmentations = this._getSegmentations(); + + if (remainingSegmentations.length) { + const { id } = remainingSegmentations[0]; + + this._setActiveSegmentationForToolGroup( + id, + this._getFirstToolGroupId(), + false + ); + } + } + + this._broadcastEvent(this.EVENTS.SEGMENTATION_REMOVED, { + segmentationId, + }); + } + + public getConfiguration = (toolGroupId?: string): SegmentationConfig => { + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const brushSize = 1; + // const brushSize = cstUtils.segmentation.getBrushSizeForToolGroup( + // toolGroupId + // ); + + const brushThresholdGate = 1; + // const brushThresholdGate = cstUtils.segmentation.getBrushThresholdForToolGroup( + // toolGroupId + // ); + + const config = cstSegmentation.config.getGlobalConfig(); + const { renderInactiveSegmentations } = config; + + const labelmapRepresentationConfig = config.representations.LABELMAP; + + const { + renderOutline, + outlineWidthActive, + renderFill, + fillAlpha, + fillAlphaInactive, + outlineOpacity, + outlineOpacityInactive, + } = labelmapRepresentationConfig; + + return { + brushSize, + brushThresholdGate, + fillAlpha, + fillAlphaInactive, + outlineWidthActive, + renderFill, + renderInactiveSegmentations, + renderOutline, + outlineOpacity, + outlineOpacityInactive, + }; + }; + + public setConfiguration = (configuration: SegmentationConfig): void => { + const { + brushSize, + brushThresholdGate, + fillAlpha, + fillAlphaInactive, + outlineWidthActive, + outlineOpacity, + renderFill, + renderInactiveSegmentations, + renderOutline, + } = configuration; + + if (renderOutline !== undefined) { + this._setLabelmapConfigValue('renderOutline', renderOutline); + } + + 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); + } + + 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; + + // const toolGroupIds = ToolGroupService.getToolGroupIds(); + + // toolGroupIds.forEach(toolGroupId => { + // cstUtils.segmentation.setBrushSizeForToolGroup(toolGroupId, brushSize); + // }); + // } + + // if (brushThresholdGate !== undefined) { + // const { ToolGroupService } = this.servicesManager.services; + + // const toolGroupIds = ToolGroupService.getFirstToolGroupIds(); + + // toolGroupIds.forEach(toolGroupId => { + // cstUtils.segmentation.setBrushThresholdForToolGroup( + // toolGroupId, + // brushThresholdGate + // ); + // }); + // } + + this._broadcastEvent( + this.EVENTS.SEGMENTATION_CONFIGURATION_CHANGED, + this.getConfiguration() + ); + }; + + public getLabelmapVolume = (segmentationId: string) => { + return cache.getVolume(segmentationId); + }; + + public getSegmentationRepresentationsForToolGroup = toolGroupId => { + return cstSegmentation.state.getSegmentationRepresentations(toolGroupId); + }; + + public setSegmentLabelForSegmentation( + segmentationId: string, + segmentIndex: number, + label: string + ) { + this._setSegmentLabelForSegmentation(segmentationId, segmentIndex, label); + } + + private _setSegmentLabelForSegmentation( + segmentationId: string, + segmentIndex: number, + label: string, + suppressEvents = false + ) { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = segmentation.segments[segmentIndex]; + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + segmentInfo.label = label; + + if (suppressEvents === false) { + // this._setSegmentationModified(segmentationId); + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + } + public shouldRenderSegmentation( + viewportDisplaySetInstanceUIDs, + segDisplaySetInstanceUID + ) { + if ( + !viewportDisplaySetInstanceUIDs || + !viewportDisplaySetInstanceUIDs.length + ) { + return false; + } + + const { DisplaySetService } = this.servicesManager.services; + + let shouldDisplaySeg = false; + + const segDisplaySet = DisplaySetService.getDisplaySetByUID( + segDisplaySetInstanceUID + ); + + const { + FrameOfReferenceUID: segFrameOfReferenceUID, + } = segDisplaySet.instance; + + viewportDisplaySetInstanceUIDs.forEach(displaySetInstanceUID => { + // check if the displaySet is sharing the same frameOfReferenceUID + // with the new segmentation + const displaySet = DisplaySetService.getDisplaySetByUID( + displaySetInstanceUID + ); + + // Todo: this might not be ideal for use cases such as 4D, since we + // don't want to show the segmentation for all the frames + if ( + displaySet.isReconstructable && + displaySet?.images?.[0]?.FrameOfReferenceUID === segFrameOfReferenceUID + ) { + shouldDisplaySeg = true; + } + }); + + return shouldDisplaySeg; + } + + private _setActiveSegmentationForToolGroup( + segmentationId: string, + toolGroupId: string, + suppressEvents = false + ) { + const segmentations = this._getSegmentations(); + const targetSegmentation = this.getSegmentation(segmentationId); + + if (targetSegmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + segmentations.forEach(segmentation => { + segmentation.isActive = segmentation.id === segmentationId; + }); + + const representation = this._getSegmentationRepresentation( + segmentationId, + toolGroupId + ); + + cstSegmentation.activeSegmentation.setActiveSegmentationRepresentation( + toolGroupId, + representation.segmentationRepresentationUID + ); + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation: targetSegmentation, + }); + } + } + + private _toggleSegmentationVisibility = ( + segmentationId: string, + suppressEvents = false + ) => { + const segmentation = this.segmentations[segmentationId]; + + if (!segmentation) { + throw new Error( + `Segmentation with segmentationId ${segmentationId} not found.` + ); + } + + segmentation.isVisible = !segmentation.isVisible; + + this._updateCornerstoneSegmentationVisibility(segmentationId); + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + }; + + private _setActiveSegment( + segmentationId: string, + segmentIndex: number, + suppressEvents = false + ) { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + cstSegmentation.segmentIndex.setActiveSegmentIndex( + segmentationId, + segmentIndex + ); + + segmentation.activeSegmentIndex = segmentIndex; + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + } + + private _getSegmentInfo(segmentation: Segmentation, segmentIndex: number) { + const segments = segmentation.segments; + + if (!segments) { + return; + } + + if (segments && segments.length > 0) { + return segments[segmentIndex]; + } + } + + private _setSegmentColor = ( + segmentationId: string, + segmentIndex: number, + color: Types.Point3, + toolGroupId?: string, + suppressEvents = false + ) => { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = this._getSegmentInfo(segmentation, segmentIndex); + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const segmentationRepresentation = this._getSegmentationRepresentation( + segmentationId, + toolGroupId + ); + + if (!segmentationRepresentation) { + throw new Error( + 'Must add representation to toolgroup before setting segments' + ); + } + const { segmentationRepresentationUID } = segmentationRepresentation; + + const rgbaColor = cstSegmentation.config.color.getColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex + ); + + cstSegmentation.config.color.setColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex, + [...color, rgbaColor[3]] + ); + + segmentInfo.color = color; + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + }; + + private _getSegmentCenter(segmentationId, segmentIndex) { + const segmentation = this.getSegmentation(segmentationId); + + if (!segmentation) { + return; + } + + const { cachedStats } = segmentation; + + if (!cachedStats) { + return; + } + + const { segmentCenter } = cachedStats; + + if (!segmentCenter) { + return; + } + + const { center } = segmentCenter[segmentIndex]; + + return center; + } + + private _setSegmentLocked( + segmentationId: string, + segmentIndex: number, + isLocked: boolean, + suppressEvents = false + ) { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = this._getSegmentInfo(segmentation, segmentIndex); + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + segmentInfo.isLocked = isLocked; + + cstSegmentation.segmentLocking.setSegmentIndexLocked( + segmentationId, + segmentIndex, + isLocked + ); + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + } + + private _setSegmentVisibility( + segmentationId: string, + segmentIndex: number, + isVisible: boolean, + toolGroupId?: string, + suppressEvents = false + ) { + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const { + segmentationRepresentationUID, + segmentation, + } = this._getSegmentationInfo(segmentationId, toolGroupId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = this._getSegmentInfo(segmentation, segmentIndex); + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + segmentInfo.isVisible = isVisible; + + cstSegmentation.config.visibility.setVisibilityForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex, + isVisible + ); + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + } + + private _setSegmentOpacity = ( + segmentationId: string, + segmentIndex: number, + opacity: number, + toolGroupId?: string, + suppressEvents = false + ) => { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = this._getSegmentInfo(segmentation, segmentIndex); + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + + const segmentationRepresentation = this._getSegmentationRepresentation( + segmentationId, + toolGroupId + ); + + if (!segmentationRepresentation) { + throw new Error( + 'Must add representation to toolgroup before setting segments' + ); + } + const { segmentationRepresentationUID } = segmentationRepresentation; + + const rgbaColor = cstSegmentation.config.color.getColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex + ); + + cstSegmentation.config.color.setColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + segmentIndex, + [rgbaColor[0], rgbaColor[1], rgbaColor[2], opacity] + ); + + segmentInfo.opacity = opacity; + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + }; + + private _setSegmentLabel( + segmentationId: string, + segmentIndex: number, + segmentLabel: string, + suppressEvents = false + ) { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + + const segmentInfo = this._getSegmentInfo(segmentation, segmentIndex); + + if (segmentInfo === undefined) { + throw new Error( + `Segment ${segmentIndex} not yet added to segmentation: ${segmentationId}` + ); + } + + segmentInfo.label = segmentLabel; + + if (suppressEvents === false) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } + } + + private _getSegmentationRepresentation(segmentationId, toolGroupId) { + const segmentationRepresentations = this.getSegmentationRepresentationsForToolGroup( + toolGroupId + ); + + if (segmentationRepresentations.length === 0) { + return; + } + + // Todo: this finds the first segmentation representation that matches the segmentationId + // If there are two labelmap representations from the same segmentation, this will not work + const representation = segmentationRepresentations.find( + representation => representation.segmentationId === segmentationId + ); + + return representation; + } + + private _setLabelmapConfigValue = (property, value) => { + const { CornerstoneViewportService } = this.servicesManager.services; + + const config = cstSegmentation.config.getGlobalConfig(); + + config.representations.LABELMAP[property] = value; + + // Todo: add non global (representation specific config as well) + cstSegmentation.config.setGlobalConfig(config); + + const renderingEngine = CornerstoneViewportService.getRenderingEngine(); + const viewportIds = CornerstoneViewportService.getViewportIds(); + + renderingEngine.renderViewports(viewportIds); + }; + + private _initSegmentationService() { + // Connect Segmentation Service to Cornerstone3D. + eventTarget.addEventListener( + csToolsEnums.Events.SEGMENTATION_MODIFIED, + this._onSegmentationModified + ); + + eventTarget.addEventListener( + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, + this._onSegmentationDataModified + ); + } + + private _onSegmentationDataModified = evt => { + const { segmentationId } = evt.detail; + + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + // Part of add operation, not update operation, exit early. + return; + } + + this._broadcastEvent(this.EVENTS.SEGMENTATION_DATA_MODIFIED, { + segmentation, + }); + }; + + private _onSegmentationModified = evt => { + const { segmentationId } = evt.detail; + + const segmentation = this.segmentations[segmentationId]; + + if (segmentation === undefined) { + // Part of add operation, not update operation, exit early. + return; + } + + const segmentationState = cstSegmentation.state.getSegmentation( + segmentationId + ); + + if (!segmentationState) { + 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]; + + // TODO: handle other representations when available in cornerstone3D + const segmentationSchema = { + activeSegmentIndex, + cachedStats, + displayText: [], + id: segmentationId, + label, + segmentsLocked, + type, + volumeId: labelmapRepresentationData.volumeId, + }; + + try { + this.addOrUpdateSegmentation(segmentationSchema); + } catch (error) { + console.warn( + `Failed to add/update segmentation ${segmentationId}`, + error + ); + } + }; + + private _getSegmentationInfo(segmentationId: string, toolGroupId: string) { + const segmentation = this.getSegmentation(segmentationId); + + if (segmentation === undefined) { + throw new Error(`no segmentation for segmentationId: ${segmentationId}`); + } + const segmentationRepresentation = this._getSegmentationRepresentation( + segmentationId, + toolGroupId + ); + + if (!segmentationRepresentation) { + throw new Error( + 'Must add representation to toolgroup before setting segments' + ); + } + + const { segmentationRepresentationUID } = segmentationRepresentation; + + return { segmentationRepresentationUID, segmentation }; + } + + private _removeSegmentationFromCornerstone(segmentationId: string) { + // TODO: This should be from the configuration + const removeFromCache = true; + const segmentationState = cstSegmentation.state; + const sourceSegState = segmentationState.getSegmentation(segmentationId); + + if (!sourceSegState) { + return; + } + + const toolGroupIds = segmentationState.getToolGroupIdsWithSegmentation( + segmentationId + ); + + toolGroupIds.forEach(toolGroupId => { + const segmentationRepresentations = segmentationState.getSegmentationRepresentations( + toolGroupId + ); + + const UIDsToRemove = []; + segmentationRepresentations.forEach(representation => { + if (representation.segmentationId === segmentationId) { + UIDsToRemove.push(representation.segmentationRepresentationUID); + } + }); + + // remove segmentation representations + cstSegmentation.removeSegmentationsFromToolGroup( + toolGroupId, + UIDsToRemove, + true // immediate + ); + }); + + // cleanup the segmentation state too + segmentationState.removeSegmentation(segmentationId); + + if (removeFromCache) { + cache.removeVolumeLoadObject(segmentationId); + } + } + + private _updateCornerstoneSegmentations({ + segmentationId, + notYetUpdatedAtSource, + }) { + if (notYetUpdatedAtSource === false) { + return; + } + const segmentationState = cstSegmentation.state; + const sourceSegmentation = segmentationState.getSegmentation( + segmentationId + ); + const segmentation = this.segmentations[segmentationId]; + const { label, cachedStats } = segmentation; + + // Update the label in the source if necessary + if (sourceSegmentation.label !== label) { + sourceSegmentation.label = label; + } + + if (!isEqual(sourceSegmentation.cachedStats, cachedStats)) { + sourceSegmentation.cachedStats = cachedStats; + } + } + + private _updateCornerstoneSegmentationVisibility = segmentationId => { + const segmentationState = cstSegmentation.state; + const toolGroupIds = segmentationState.getToolGroupIdsWithSegmentation( + segmentationId + ); + + toolGroupIds.forEach(toolGroupId => { + const segmentationRepresentations = cstSegmentation.state.getSegmentationRepresentations( + toolGroupId + ); + + if (segmentationRepresentations.length === 0) { + return; + } + + // Todo: this finds the first segmentation representation that matches the segmentationId + // If there are two labelmap representations from the same segmentation, this will not work + const representation = segmentationRepresentations.find( + representation => representation.segmentationId === segmentationId + ); + + const visibility = cstSegmentation.config.visibility.getSegmentationVisibility( + toolGroupId, + representation.segmentationRepresentationUID + ); + + cstSegmentation.config.visibility.setSegmentationVisibility( + toolGroupId, + representation.segmentationRepresentationUID, + !visibility + ); + + // set all segments to visible as well + const segments = this.getSegmentation(segmentationId).segments; + Object.keys(segments).forEach(segmentIndex => { + if (segmentIndex !== '0') { + this._setSegmentVisibility( + segmentationId, + Number(segmentIndex), + !visibility, + toolGroupId + ); + } + }); + }); + }; + + private _getToolGroupIdsWithSegmentation(segmentationId: string) { + const segmentationState = cstSegmentation.state; + const toolGroupIds = segmentationState.getToolGroupIdsWithSegmentation( + segmentationId + ); + + return toolGroupIds; + } + + private _getFirstToolGroupId = () => { + const { ToolGroupService } = this.servicesManager.services; + const toolGroupIds = ToolGroupService.getToolGroupIds(); + + return toolGroupIds[0]; + }; + + private getNextColorLUTIndex = (): number => { + let i = 0; + while (true) { + if (cstSegmentation.state.getColorLUT(i) === undefined) { + return i; + } + + i++; + } + }; + + private generateNewColorLUT() { + const newColorLUT = cloneDeep(COLOR_LUT); + + return newColorLUT; + } + + /** + * Converts object of objects to array. + * + * @return {Array} Array of objects + */ + private arrayOfObjects = obj => { + return Object.entries(obj).map(e => ({ [e[0]]: e[1] })); + }; +} + +export default SegmentationService; +export { EVENTS, VALUE_TYPES }; diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts new file mode 100644 index 00000000000..3d4066d096c --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts @@ -0,0 +1,88 @@ +import { Enums as csToolsEnums, Types as cstTypes } from '@cornerstonejs/tools'; +import { Types } from '@cornerstonejs/core'; + +type SegmentationConfig = cstTypes.LabelmapTypes.LabelmapConfig & { + renderInactiveSegmentations: boolean; + brushSize: number; + brushThresholdGate: number; +}; + +type Segment = { + // the label for the segment + label: string; + // the index of the segment in the segmentation + segmentIndex: number; + // the color of the segment + color: Types.Point3; + // the opacity of the segment + opacity: number; + // whether the segment is visible + isVisible: boolean; + // whether the segment is locked + isLocked: boolean; +}; + +type Segmentation = { + // active segment index is the index of the segment that is currently being edited. + activeSegmentIndex: number; + // colorLUTIndex is the index of the color LUT that is currently being used. + colorLUTIndex: number; + // if segmentation contains any data (often calculated from labelmap) + cachedStats: Record; + // displayText is the text that is displayed on the segmentation panel (often derived from the data) + displayText?: string[]; + // the id of the segmentation + id: string; + // if the segmentation is the active segmentation being used in the viewer + isActive: boolean; + // if the segmentation is visible in the viewer + isVisible: boolean; + // the label of the segmentation + label: string; + // the number of segments in the segmentation + segmentCount: number; + // the array of segments with their details, [null, segment1, segment2, ...] + 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; +}; + +export { SegmentationConfig, Segment, Segmentation, SegmentationSchema }; diff --git a/extensions/cornerstone/src/services/SegmentationService/index.js b/extensions/cornerstone/src/services/SegmentationService/index.js new file mode 100644 index 00000000000..3cc5767b6b1 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/index.js @@ -0,0 +1,10 @@ +import SegmentationService from './SegmentationService'; + +export default function ExtendedSegmentationService(servicesManager) { + return { + name: 'SegmentationService', + create: ({ configuration = {} }) => { + return new SegmentationService({ servicesManager }); + }, + }; +} diff --git a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts index e4e6a6b79b0..6acfce70a83 100644 --- a/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts +++ b/extensions/cornerstone/src/services/SyncGroupService/SyncGroupService.ts @@ -31,6 +31,7 @@ export type SyncGroup = { const POSITION = 'cameraposition'; const VOI = 'voi'; const ZOOMPAN = 'zoompan'; +const STACKIMAGE = 'stackimage'; const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup => typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup; @@ -43,6 +44,7 @@ export default class SyncGroupService { [POSITION]: synchronizers.createCameraPositionSynchronizer, [VOI]: synchronizers.createVOISynchronizer, [ZOOMPAN]: synchronizers.createZoomPanSynchronizer, + [STACKIMAGE]: synchronizers.createStackImageSynchronizer, }; constructor(serviceManager) { @@ -72,7 +74,7 @@ export default class SyncGroupService { * @param creator */ public setSynchronizer(type: string, creator: SyncCreator): void { - this.synchronizerCreators[type] = creator; + this.synchronizerCreators[type.toLowerCase()] = creator; } protected _getOrCreateSynchronizer( @@ -91,20 +93,29 @@ export default class SyncGroupService { public addViewportToSyncGroup( viewportId: string, renderingEngineId: string, - syncGroups?: (SyncGroup | string)[] + syncGroups?: SyncGroup | string | SyncGroup[] | string[] ): void { - if (!syncGroups || !syncGroups.length) { + if (!syncGroups) { return; } - syncGroups.forEach(syncGroup => { + const syncGroupsArray = Array.isArray(syncGroups) + ? syncGroups + : [syncGroups]; + + syncGroupsArray.forEach(syncGroup => { const syncGroupObj = asSyncGroup(syncGroup); - const { type, target = true, source = true, options = {} } = syncGroupObj; - const { id = type } = syncGroupObj; + const { + type, + target = true, + source = true, + options = {}, + id = type, + } = syncGroupObj; const synchronizer = this._getOrCreateSynchronizer(type, id, options); - synchronizer.setOptions(viewportId, options); + const viewportInfo = { viewportId, renderingEngineId }; if (target && source) { synchronizer.add(viewportInfo); @@ -123,11 +134,16 @@ export default class SyncGroupService { public removeViewportFromSyncGroup( viewportId: string, - renderingEngineId: string + renderingEngineId: string, + syncGroupId?: string ): void { const synchronizers = SynchronizerManager.getAllSynchronizers(); - synchronizers.forEach(synchronizer => { + const filteredSynchronizers = syncGroupId + ? synchronizers.filter(s => s.id === syncGroupId) + : synchronizers; + + filteredSynchronizers.forEach(synchronizer => { if (!synchronizer) { return; } diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index 3e896a83896..96d9cd31ea1 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -72,7 +72,16 @@ export default class ToolGroupService { this.toolGroupIds = new Set(); } - public disable(viewportId: string, renderingEngineId: string): void { + public destroyToolGroup(toolGroupId: string) { + ToolGroupManager.destroyToolGroup(toolGroupId); + this.toolGroupIds.delete(toolGroupId); + } + + public removeViewportFromToolGroup( + viewportId: string, + renderingEngineId: string, + deleteToolGroupIfEmpty?: boolean + ): void { const toolGroup = ToolGroupManager.getToolGroupForViewport( viewportId, renderingEngineId @@ -85,9 +94,10 @@ export default class ToolGroupService { toolGroup.removeViewports(renderingEngineId, viewportId); const viewportIds = toolGroup.getViewportIds(); - // if (viewportIds.length === 0) { - // ToolGroupManager.destroyToolGroup(toolGroup.id); - // } + + if (viewportIds.length === 0 && deleteToolGroupIfEmpty) { + ToolGroupManager.destroyToolGroup(toolGroup.id); + } } public addViewportToToolGroup( @@ -110,7 +120,10 @@ export default class ToolGroupService { toolGroup.addViewport(viewportId, renderingEngineId); } - this._broadcastEvent(EVENTS.VIEWPORT_ADDED, { viewportId, toolGroupId }); + this._broadcastEvent(EVENTS.VIEWPORT_ADDED, { + viewportId, + toolGroupId, + }); } public createToolGroup(toolGroupId: string): Types.IToolGroup { @@ -122,7 +135,9 @@ export default class ToolGroupService { const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); this.toolGroupIds.add(toolGroupId); - this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, { toolGroupId }); + this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, { + toolGroupId, + }); return toolGroup; } @@ -195,9 +210,11 @@ export default class ToolGroupService { private _getToolNames(toolGroupTools: Tools): string[] { const toolNames = []; - toolGroupTools.active.forEach(tool => { - toolNames.push(tool.toolName); - }); + if (toolGroupTools.active) { + toolGroupTools.active.forEach(tool => { + toolNames.push(tool.toolName); + }); + } if (toolGroupTools.passive) { toolGroupTools.passive.forEach(tool => { toolNames.push(tool.toolName); @@ -221,9 +238,12 @@ export default class ToolGroupService { private _setToolsMode(toolGroup, tools) { const { active, passive, enabled, disabled } = tools; - active.forEach(({ toolName, bindings }) => { - toolGroup.setToolActive(toolName, { bindings }); - }); + + if (active) { + active.forEach(({ toolName, bindings }) => { + toolGroup.setToolActive(toolName, { bindings }); + }); + } if (passive) { passive.forEach(({ toolName }) => { diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneCacheService.ts deleted file mode 100644 index 0e65bf5f72f..00000000000 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneCacheService.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { - cache as cs3DCache, - Enums, - Types, - volumeLoader, -} from '@cornerstonejs/core'; -import { utils, pubSubServiceInterface } from '@ohif/core'; - -import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; - -export type StackData = { - StudyInstanceUID: string; - displaySetInstanceUID: string; - imageIds: string[]; - frameRate?: number; - isClip?: boolean; - initialImageIndex?: number | string | null; - viewportType: Enums.ViewportType; -}; - -export type VolumeData = { - StudyInstanceUID: string; - displaySetInstanceUIDs: string[]; // can have more than one displaySet (fusion) - imageIds: string[][]; // can have more than one imageId list (fusion) - volumes: Types.IVolume[]; - viewportType: Enums.ViewportType; -}; - -const VOLUME_LOADER_SCHEME = 'streaming-wadors'; - -const EVENTS = { - VIEWPORT_DATA_CHANGED: 'event::cornerstone::viewportdatachanged', -}; - -export type IViewportData = StackData | VolumeData; - -class CornerstoneCacheService { - stackImageIds: Map = new Map(); - volumeImageIds: Map = new Map(); - listeners: { [key: string]: (...args: any[]) => void } = {}; - EVENTS: { [key: string]: string }; - - constructor() { - this.listeners = {}; - this.EVENTS = EVENTS; - Object.assign(this, pubSubServiceInterface); - } - - public getCacheSize() { - return cs3DCache.getCacheSize(); - } - - public getCacheFreeSpace() { - return cs3DCache.getBytesAvailable(); - } - - public async getViewportData( - viewportIndex: number, - displaySets: unknown[], - viewportType: string, - dataSource: unknown, - callback: (val: IViewportData) => unknown, - initialImageIndex?: number - ): Promise { - const cs3DViewportType = getCornerstoneViewportType(viewportType); - let viewportData: IViewportData; - - if (cs3DViewportType === Enums.ViewportType.STACK) { - viewportData = await this._getStackViewportData( - dataSource, - displaySets, - initialImageIndex - ); - } - - if (cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC) { - viewportData = await this._getVolumeViewportData(dataSource, displaySets); - } - - viewportData.viewportType = cs3DViewportType; - - await callback(viewportData); - - this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { - viewportData, - viewportIndex, - }); - - return viewportData; - } - public async invalidateViewportData( - viewportData: VolumeData, - invalidatedDisplaySetInstanceUID: string, - dataSource, - DisplaySetService - ) { - if (viewportData.viewportType === Enums.ViewportType.STACK) { - throw new Error('Invalidation of StackViewport is not supported yet'); - } - - const volumeId = invalidatedDisplaySetInstanceUID; - const volume = cs3DCache.getVolume(volumeId); - - if (volume) { - cs3DCache.removeVolumeLoadObject(volumeId); - } - - const displaySets = viewportData.displaySetInstanceUIDs.map( - DisplaySetService.getDisplaySetByUID - ); - - const newViewportData = await this._getVolumeViewportData( - dataSource, - displaySets - ); - - return newViewportData; - } - - private _getStackViewportData( - dataSource, - displaySets, - initialImageIndex - ): StackData { - // For Stack Viewport we don't have fusion currently - const displaySet = displaySets[0]; - - let stackImageIds = this.stackImageIds.get( - displaySet.displaySetInstanceUID - ); - - if (!stackImageIds) { - stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource); - this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds); - } - - const { displaySetInstanceUID, StudyInstanceUID } = displaySet; - - const stackData: StackData = { - StudyInstanceUID, - displaySetInstanceUID, - viewportType: Enums.ViewportType.STACK, - imageIds: stackImageIds, - }; - - if (typeof initialImageIndex === 'number') { - stackData.initialImageIndex = initialImageIndex; - } - - return stackData; - } - - private async _getVolumeViewportData( - dataSource, - displaySets - ): Promise { - // Check the cache for multiple scenarios to see if we need to - // decache the volume data from other viewports or not - - const volumeImageIdsArray = []; - const volumes = []; - - for (const displaySet of displaySets) { - const volumeId = displaySet.displaySetInstanceUID; - - let volumeImageIds = this.volumeImageIds.get( - displaySet.displaySetInstanceUID - ); - - let volume = cs3DCache.getVolume(volumeId); - - if (!volumeImageIds || !volume) { - volumeImageIds = this._getCornerstoneVolumeImageIds( - displaySet, - dataSource - ); - - volume = await volumeLoader.createAndCacheVolume(volumeId, { - imageIds: volumeImageIds, - }); - - this.volumeImageIds.set( - displaySet.displaySetInstanceUID, - volumeImageIds - ); - } - - volumeImageIdsArray.push(volumeImageIds); - volumes.push(volume); - } - - // assert displaySets are from the same study - const { StudyInstanceUID } = displaySets[0]; - const displaySetInstanceUIDs = []; - - displaySets.forEach(displaySet => { - if (displaySet.StudyInstanceUID !== StudyInstanceUID) { - throw new Error('Display sets are not from the same study'); - } - - displaySetInstanceUIDs.push(displaySet.displaySetInstanceUID); - }); - - return { - StudyInstanceUID, - displaySetInstanceUIDs, - imageIds: volumeImageIdsArray, - viewportType: Enums.ViewportType.ORTHOGRAPHIC, - volumes, - }; - } - - private _getCornerstoneStackImageIds(displaySet, dataSource): string[] { - return dataSource.getImageIdsForDisplaySet(displaySet); - } - - private _getCornerstoneVolumeImageIds(displaySet, dataSource): string[] { - const stackImageIds = this._getCornerstoneStackImageIds( - displaySet, - dataSource - ); - - return stackImageIds.map(imageId => { - const imageURI = utils.imageIdToURI(imageId); - return `${VOLUME_LOADER_SCHEME}:${imageURI}`; - }); - } -} - -const CacheService = new CornerstoneCacheService(); -export default CacheService; diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 85c6a4a55f0..6b25b43fe03 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -2,7 +2,6 @@ import { pubSubServiceInterface } from '@ohif/core'; import { RenderingEngine, StackViewport, - Enums, Types, getRenderingEngine, utilities as csUtils, @@ -18,7 +17,10 @@ import ViewportInfo, { DisplaySetOptions, PublicViewportOptions, } from './Viewport'; -import { StackData, VolumeData } from './CornerstoneCacheService'; +import { + StackViewportData, + VolumeViewportData, +} from '../../types/CornerstoneCacheService'; import { setColormap, setLowerUpperColorTransferFunction, @@ -27,8 +29,8 @@ import { import JumpPresets from '../../utils/JumpPresets'; const EVENTS = { - VIEWPORT_INFO_CREATED: - 'event::cornerstone::viewportservice:viewportinfocreated', + VIEWPORT_DATA_CHANGED: + 'event::cornerstoneViewportService:viewportDataChanged', }; /** @@ -39,8 +41,7 @@ class CornerstoneViewportService implements IViewportService { renderingEngine: Types.IRenderingEngine | null; viewportsInfo: Map; viewportGridResizeObserver: ResizeObserver | null; - // TODO - get the right type here. - hangingProtocolService: object; + viewportsDisplaySets: Map = new Map(); /** * Service-specific @@ -52,6 +53,7 @@ class CornerstoneViewportService implements IViewportService { enableResizeDetector: true; resizeRefreshRateMs: 200; resizeRefreshMode: 'debounce'; + servicesManager = null; constructor(servicesManager) { this.renderingEngine = null; @@ -60,8 +62,7 @@ class CornerstoneViewportService implements IViewportService { // this.listeners = {}; this.EVENTS = EVENTS; - const { HangingProtocolService } = servicesManager.services; - this.hangingProtocolService = HangingProtocolService; + this.servicesManager = servicesManager; Object.assign(this, pubSubServiceInterface); // } @@ -71,7 +72,7 @@ class CornerstoneViewportService implements IViewportService { * @param {*} viewportIndex * @param {*} elementRef */ - public enableElement( + public enableViewport( viewportIndex: number, viewportOptions: PublicViewportOptions, elementRef: HTMLDivElement @@ -87,6 +88,16 @@ class CornerstoneViewportService implements IViewportService { return `viewport-${viewportIndex}`; } + public getViewportIds(): string[] { + const viewportIds = []; + + this.viewportsInfo.forEach(viewportInfo => { + viewportIds.push(viewportInfo.getViewportId()); + }); + + return viewportIds; + } + /** * It retrieves the renderingEngine if it does exist, or creates one otherwise * @returns {RenderingEngine} rendering engine @@ -112,10 +123,9 @@ class CornerstoneViewportService implements IViewportService { */ public resize() { const immediate = true; - const resetPan = false; - const resetZoom = false; + const keepCamera = true; - this.renderingEngine.resize(immediate, resetPan, resetZoom); + this.renderingEngine.resize(immediate, keepCamera); this.renderingEngine.render(); } @@ -126,8 +136,9 @@ class CornerstoneViewportService implements IViewportService { this._removeResizeObserver(); this.viewportGridResizeObserver = null; this.renderingEngine.destroy(); + this.viewportsDisplaySets.clear(); this.renderingEngine = null; - cache.purgeVolumeCache(); + cache.purgeCache(); } /** @@ -142,12 +153,11 @@ class CornerstoneViewportService implements IViewportService { } const viewportId = viewportInfo.getViewportId(); - this.renderingEngine.disableElement(viewportId); - this.viewportsInfo.delete(viewportIndex); - if (this.viewportsInfo.size === 0) { - this.destroy(); - } + this.renderingEngine && this.renderingEngine.disableElement(viewportId); + + this.viewportsInfo.get(viewportIndex).destroy(); + this.viewportsInfo.delete(viewportIndex); } /** @@ -158,9 +168,9 @@ class CornerstoneViewportService implements IViewportService { * @param {*} dataSource * @returns */ - public setViewportDisplaySets( + public setViewportData( viewportIndex: number, - viewportData: StackData | VolumeData, + viewportData: StackViewportData | VolumeViewportData, publicViewportOptions: PublicViewportOptions, publicDisplaySetOptions: DisplaySetOptions[] ): void { @@ -179,8 +189,12 @@ class CornerstoneViewportService implements IViewportService { viewportInfo.setViewportOptions(viewportOptions); viewportInfo.setDisplaySetOptions(displaySetOptions); + viewportInfo.setViewportData(viewportData); - this._broadcastEvent(EVENTS.VIEWPORT_INFO_CREATED, viewportInfo); + this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { + viewportData, + viewportIndex, + }); const viewportId = viewportInfo.getViewportId(); const element = viewportInfo.getElement(); @@ -203,6 +217,7 @@ class CornerstoneViewportService implements IViewportService { // renderingEngine is designed to be used like this. This will trigger // ENABLED_ELEMENT again and again, which will run onEnableElement callbacks renderingEngine.enableElement(viewportInput); + this._setDisplaySets(viewportId, viewportData, viewportInfo); } @@ -265,11 +280,18 @@ class CornerstoneViewportService implements IViewportService { _setStackViewport( viewport: Types.IStackViewport, - viewportData: StackData, + viewportData: StackViewportData, viewportInfo: ViewportInfo ) { const displaySetOptions = viewportInfo.getDisplaySetOptions(); - const { imageIds, initialImageIndex } = viewportData; + + const { + imageIds, + initialImageIndex, + displaySetInstanceUID, + } = viewportData.data; + + this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]); let initialImageIndexToUse = initialImageIndex; @@ -331,7 +353,15 @@ class CornerstoneViewportService implements IViewportService { } if (preset === JumpPresets.Middle) { - return Math.floor(lastSliceIndex / 2); + // Note: this is a simple but yet very important formula. + // since viewport reset works with the middle slice + // if the below formula is not correct, on a viewport reset + // it will jump to a different slice than the middle one which + // was the initial slice, and we have some tools such as Crosshairs + // which rely on a relative camera modifications and those will break. + return lastSliceIndex % 2 === 0 + ? lastSliceIndex / 2 + : (lastSliceIndex + 1) / 2; } return 0; @@ -339,7 +369,7 @@ class CornerstoneViewportService implements IViewportService { async _setVolumeViewport( viewport: Types.IVolumeViewport, - viewportData: VolumeData, + viewportData: VolumeViewportData, viewportInfo: ViewportInfo ): Promise { // TODO: We need to overhaul the way data sources work so requests can be made @@ -355,21 +385,24 @@ class CornerstoneViewportService implements IViewportService { // (This call may or may not create sub-requests for series metadata) const volumeInputArray = []; const displaySetOptionsArray = viewportInfo.getDisplaySetOptions(); - const { hangingProtocolService } = this; + const { HangingProtocolService } = this.servicesManager.services; - for (let i = 0; i < viewportData.imageIds.length; i++) { - const imageIds = viewportData.imageIds[i]; - const displaySetInstanceUID = viewportData.displaySetInstanceUIDs[i]; - const displaySetOptions = displaySetOptionsArray[i]; + const volumeToLoad = []; + const displaySetInstanceUIDs = []; - const volumeId = displaySetInstanceUID; + for (const [index, data] of viewportData.data.entries()) { + const { volume, imageIds, displaySetInstanceUID } = data; - // if (displaySet.needsRerendering) { - // console.warn('Removing volume from cache', volumeId); - // cache.removeVolumeLoadObject(volumeId); - // displaySet.needsRerendering = false; - // this.displaySetsNeedRerendering.add(displaySet.displaySetInstanceUID); - // } + displaySetInstanceUIDs.push(displaySetInstanceUID); + + if (!volume) { + continue; + } + + volumeToLoad.push(volume); + + const displaySetOptions = displaySetOptionsArray[index]; + const { volumeId } = volume; const voiCallbacks = this._getVOICallbacks(volumeId, displaySetOptions); @@ -386,18 +419,20 @@ class CornerstoneViewportService implements IViewportService { }); } + this.viewportsDisplaySets.set(viewport.id, displaySetInstanceUIDs); + if ( - hangingProtocolService.hasCustomImageLoadStrategy() && - !hangingProtocolService.customImageLoadPerformed + HangingProtocolService.hasCustomImageLoadStrategy() && + !HangingProtocolService.customImageLoadPerformed ) { // delegate the volume loading to the hanging protocol service if it has a custom image load strategy - return hangingProtocolService.runImageLoadStrategy({ + return HangingProtocolService.runImageLoadStrategy({ viewportId: viewport.id, volumeInputArray, }); } - viewportData.volumes.forEach(volume => { + volumeToLoad.forEach(volume => { volume.load(); }); @@ -406,9 +441,98 @@ class CornerstoneViewportService implements IViewportService { } public async setVolumesForViewport(viewport, volumeInputArray) { + const { + DisplaySetService, + SegmentationService, + ToolGroupService, + } = this.servicesManager.services; + await viewport.setVolumes(volumeInputArray); + // load any secondary displaySets + const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id); + + const segDisplaySet = displaySetInstanceUIDs + .map(DisplaySetService.getDisplaySetByUID) + .find(displaySet => displaySet && displaySet.Modality === 'SEG'); + + 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 + ); + } + } 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 + ); + } + } + } + const viewportInfo = this.getViewportInfo(viewport.id); + + const toolGroup = ToolGroupService.getToolGroupForViewport(viewport.id); + csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id); + const initialImageOptions = viewportInfo.getInitialImageOptions(); if ( @@ -493,17 +617,21 @@ class CornerstoneViewportService implements IViewportService { _setDisplaySets( viewportId: string, - viewportData: StackData | VolumeData, + viewportData: StackViewportData | VolumeViewportData, viewportInfo: ViewportInfo ): void { const viewport = this.getCornerstoneViewport(viewportId); if (viewport instanceof StackViewport) { - this._setStackViewport(viewport, viewportData as StackData, viewportInfo); + this._setStackViewport( + viewport, + viewportData as StackViewportData, + viewportInfo + ); } else if (viewport instanceof VolumeViewport) { this._setVolumeViewport( viewport, - viewportData as VolumeData, + viewportData as VolumeViewportData, viewportInfo ); } else { @@ -559,6 +687,10 @@ class CornerstoneViewportService implements IViewportService { } { const viewportIndex = viewportInfo.getViewportIndex(); + if (!publicViewportOptions.viewportId) { + publicViewportOptions.viewportId = this.getViewportId(viewportIndex); + } + // Creating a temporary viewportInfo to handle defaults const newViewportInfo = new ViewportInfo( viewportIndex, @@ -578,6 +710,31 @@ class CornerstoneViewportService implements IViewportService { displaySetOptions: newDisplaySetOptions, }; } + + _getFrameOfReferenceUID(displaySetInstanceUID) { + const { DisplaySetService } = this.servicesManager.services; + const displaySet = DisplaySetService.getDisplaySetByUID( + displaySetInstanceUID + ); + + if (!displaySet) { + return; + } + + if (displaySet.frameOfReferenceUID) { + return displaySet.frameOfReferenceUID; + } + + if (displaySet.Modality === 'SEG') { + const { instance } = displaySet; + return instance.FrameOfReferenceUID; + } + + const { images } = displaySet; + if (images && images.length) { + return images[0].FrameOfReferenceUID; + } + } } export default function ExtendedCornerstoneViewportService(serviceManager) { diff --git a/extensions/cornerstone/src/services/ViewportService/IViewportService.ts b/extensions/cornerstone/src/services/ViewportService/IViewportService.ts index ce47f28fcad..5fe88ba63f7 100644 --- a/extensions/cornerstone/src/services/ViewportService/IViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/IViewportService.ts @@ -1,5 +1,5 @@ import { Types } from '@cornerstonejs/core'; -import { StackData, VolumeData } from './CornerstoneCacheService'; +import { StackData, VolumeData } from '../../types/CornerstoneCacheService'; import { DisplaySetOptions, PublicViewportOptions, @@ -31,7 +31,7 @@ export interface IViewportService { * @param {*} viewportIndex * @param {*} elementRef */ - enableElement( + enableViewport( viewportIndex: number, viewportOptions: ViewportOptions, elementRef: HTMLDivElement @@ -65,7 +65,7 @@ export interface IViewportService { * @param {*} dataSource * @returns */ - setViewportDisplaySets( + setViewportData( viewportIndex: number, viewportData: StackData | VolumeData, publicViewportOptions: PublicViewportOptions, diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index f537ce123e4..ff1e75bfc50 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -4,6 +4,10 @@ import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation'; import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; import JumpPresets from '../../utils/JumpPresets'; import { SyncGroup } from '../SyncGroupService/SyncGroupService'; +import { + StackViewportData, + VolumeViewportData, +} from './CornerstoneCacheService'; export type InitialImageOptions = { index?: number; @@ -67,6 +71,7 @@ class ViewportInfo { private element: HTMLDivElement; private viewportOptions: ViewportOptions; private displaySetOptions: Array; + private viewportData: StackViewportData | VolumeViewportData; private renderingEngineId: string; constructor(viewportIndex: number, viewportId: string) { @@ -76,6 +81,13 @@ class ViewportInfo { this.setPublicDisplaySetOptions([{}]); } + public destroy = (): void => { + this.element = null; + this.viewportData = null; + this.viewportOptions = null; + this.displaySetOptions = null; + }; + public setRenderingEngineId(renderingEngineId: string): void { this.renderingEngineId = renderingEngineId; } @@ -95,6 +107,16 @@ class ViewportInfo { this.element = element; } + public setViewportData( + viewportData: StackViewportData | VolumeViewportData + ): void { + this.viewportData = viewportData; + } + + public getViewportData(): StackViewportData | VolumeViewportData { + return this.viewportData; + } + public getViewportIndex(): number { return this.viewportIndex; } @@ -118,6 +140,23 @@ class ViewportInfo { this.setDisplaySetOptions(displaySetOptions); } + public hasDisplaySet(displaySetInstanceUID: string): boolean { + // Todo: currently this does not work for non image & referenceImage displaySets. + // Since SEG and other derived displaySets are loaded in a different way, and not + // via cornerstoneViewportService + let viewportData = this.getViewportData(); + + if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) { + viewportData = viewportData as VolumeViewportData; + return viewportData.data.some( + ({ displaySetInstanceUID: dsUID }) => dsUID === displaySetInstanceUID + ); + } + + viewportData = viewportData as StackViewportData; + return viewportData.data.displaySetInstanceUID === displaySetInstanceUID; + } + public setPublicViewportOptions( viewportOptionsEntry: PublicViewportOptions ): void { @@ -201,12 +240,21 @@ class ViewportInfo { const displaySetOptions: Array = []; publicDisplaySetOptions.forEach(option => { + if (!option) { + option = { + blendMode: undefined, + slabThickness: undefined, + colormap: undefined, + voi: {}, + voiInverted: false, + }; + } const blendMode = getCornerstoneBlendMode(option.blendMode); displaySetOptions.push({ - voi: option.voi || ({} as VOI), - voiInverted: option.voiInverted || false, - colormap: option.colormap || undefined, + voi: option.voi, + voiInverted: option.voiInverted, + colormap: option.colormap, slabThickness: option.slabThickness, blendMode, }); diff --git a/extensions/cornerstone/src/state.ts b/extensions/cornerstone/src/state.ts index 6c6aa3d7d01..e1d67a56159 100644 --- a/extensions/cornerstone/src/state.ts +++ b/extensions/cornerstone/src/state.ts @@ -31,4 +31,8 @@ const getEnabledElement = viewportIndex => { return state.enabledElements[viewportIndex]; }; -export { setEnabledElement, getEnabledElement }; +const reset = () => { + state.enabledElements = {}; +}; + +export { setEnabledElement, getEnabledElement, reset }; diff --git a/extensions/cornerstone/src/types/CornerstoneCacheService.ts b/extensions/cornerstone/src/types/CornerstoneCacheService.ts new file mode 100644 index 00000000000..2a877e9c00b --- /dev/null +++ b/extensions/cornerstone/src/types/CornerstoneCacheService.ts @@ -0,0 +1,38 @@ +import { + Enums, + Types, +} from '@cornerstonejs/core'; + + +type StackData = { + StudyInstanceUID: string; + displaySetInstanceUID: string; + imageIds: string[]; + frameRate?: number; + isClip?: boolean; + initialImageIndex?: number | string | null; +}; + +type VolumeData = { + studyInstanceUID: string; + displaySetInstanceUID: string; + volume?: Types.IVolume; + imageIds?: string[]; +}; + + type StackViewportData = { + viewportType: Enums.ViewportType; + data: StackData; +}; + + type VolumeViewportData = { + viewportType: Enums.ViewportType; + data: VolumeData[]; +}; + +export type { + StackViewportData, + VolumeViewportData, + StackData, + VolumeData, +}; diff --git a/extensions/cornerstone/src/types/index.ts b/extensions/cornerstone/src/types/index.ts new file mode 100644 index 00000000000..8c8e32e9832 --- /dev/null +++ b/extensions/cornerstone/src/types/index.ts @@ -0,0 +1,5 @@ +import * as CornerstoneCacheService from './CornerstoneCacheService' + +export type { + CornerstoneCacheService +} diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index f29aa7c4a3d..973bd9dbe25 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import domtoimage from 'dom-to-image'; - +import React, { useEffect, useState } from 'react'; +import html2canvas from 'html2canvas'; import { Enums, getEnabledElement, getOrCreateCanvas, StackViewport, + VolumeViewport, } from '@cornerstonejs/core'; import { ToolGroupManager } from '@cornerstonejs/tools'; import PropTypes from 'prop-types'; @@ -17,7 +17,6 @@ const MINIMUM_SIZE = 100; const DEFAULT_SIZE = 512; const MAX_TEXTURE_SIZE = 10000; const VIEWPORT_ID = 'cornerstone-viewport-download-form'; -const TOOLGROUP_ID = 'cornerstone-viewport-download-form-toolgroup'; const CornerstoneViewportDownloadForm = ({ onClose, @@ -26,15 +25,57 @@ const CornerstoneViewportDownloadForm = ({ }) => { const enabledElement = OHIFgetEnabledElement(activeViewportIndex); const activeViewportElement = enabledElement?.element; + const activeViewportEnabledElement = getEnabledElement(activeViewportElement); + + const { + viewportId: activeViewportId, + renderingEngineId, + } = activeViewportEnabledElement; + + const toolGroup = ToolGroupManager.getToolGroupForViewport( + activeViewportId, + renderingEngineId + ); + + const toolModeAndBindings = Object.keys(toolGroup.toolOptions).reduce( + (acc, toolName) => { + const tool = toolGroup.toolOptions[toolName]; + const { mode, bindings } = tool; + + return { + ...acc, + [toolName]: { + mode, + bindings, + }, + }; + }, + {} + ); + + useEffect(() => { + return () => { + Object.keys(toolModeAndBindings).forEach(toolName => { + const { mode, bindings } = toolModeAndBindings[toolName]; + toolGroup.setToolMode(toolName, mode, { bindings }); + }); + }; + }, []); const enableViewport = viewportElement => { if (viewportElement) { - const renderingEngine = CornerstoneViewportService.getRenderingEngine(); + const { renderingEngine, viewport } = getEnabledElement( + activeViewportElement + ); const viewportInput = { viewportId: VIEWPORT_ID, element: viewportElement, - type: Enums.ViewportType.STACK, + type: viewport.type, + defaultOptions: { + background: viewport.defaultOptions.background, + orientation: viewport.defaultOptions.orientation, + }, }; renderingEngine.enableElement(viewportInput); @@ -43,11 +84,9 @@ const CornerstoneViewportDownloadForm = ({ const disableViewport = viewportElement => { if (viewportElement) { - const renderingEngine = CornerstoneViewportService.getRenderingEngine(); - + const { renderingEngine } = getEnabledElement(viewportElement); return new Promise(resolve => { renderingEngine.disableElement(VIEWPORT_ID); - ToolGroupManager.destroyToolGroup(TOOLGROUP_ID); }); } }; @@ -115,26 +154,39 @@ const CornerstoneViewportDownloadForm = ({ const { viewport } = activeViewportEnabledElement; - if (!(viewport instanceof StackViewport)) { - throw new Error('Viewport is not a StackViewport'); - } - - const imageId = viewport.getCurrentImageId(); - const renderingEngine = CornerstoneViewportService.getRenderingEngine(); - const downloadViewport = renderingEngine.getViewport( - VIEWPORT_ID - ) as StackViewport; + const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); - downloadViewport.setStack([imageId]).then(() => { + if (downloadViewport instanceof StackViewport) { + const imageId = viewport.getCurrentImageId(); const properties = viewport.getProperties(); - downloadViewport.setProperties(properties); + + downloadViewport.setStack([imageId]).then(() => { + downloadViewport.setProperties(properties); + + const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); + const newHeight = Math.min( + height || image.height, + MAX_TEXTURE_SIZE + ); + + resolve({ width: newWidth, height: newHeight }); + }); + } else if (downloadViewport instanceof VolumeViewport) { + const actors = viewport.getActors(); + // downloadViewport.setActors(actors); + actors.forEach(actor => { + downloadViewport.addActor(actor); + }); + + downloadViewport.setCamera(viewport.getCamera()); + downloadViewport.render(); const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); resolve({ width: newWidth, height: newHeight }); - }); + } } }); @@ -164,31 +216,20 @@ const CornerstoneViewportDownloadForm = ({ renderingEngineId ); - let downloadToolGroup = ToolGroupManager.getToolGroupForViewport( - downloadViewportId, - renderingEngineId - ); - - if (downloadToolGroup === undefined) { - downloadToolGroup = ToolGroupManager.createToolGroup(TOOLGROUP_ID); - - // what tools were in the active viewport? - // make them all enabled instances so that they can not be interacted - // with in the download viewport - Object.values(toolGroup._toolInstances).forEach(tool => { - downloadToolGroup.addTool(tool.getToolName()); - }); - - // add the viewport to the toolGroup - downloadToolGroup.addViewport(downloadViewportId); - } - - Object.values(downloadToolGroup._toolInstances).forEach(tool => { - const toolName = tool.getToolName(); - if (toggle) { - downloadToolGroup.setToolEnabled(toolName); + // add the viewport to the toolGroup + toolGroup.addViewport(downloadViewportId); + + Object.keys(toolGroup._toolInstances).forEach(toolName => { + // make all tools Enabled so that they can not be interacted with + // in the download viewport + if (toggle && toolName !== 'Crosshairs') { + try { + toolGroup.setToolEnabled(toolName); + } catch (e) { + console.log(e); + } } else { - downloadToolGroup.setToolDisabled(toolName); + toolGroup.setToolDisabled(toolName); } }); }; @@ -199,10 +240,10 @@ const CornerstoneViewportDownloadForm = ({ `div[data-viewport-uid="${VIEWPORT_ID}"]` ); - domtoimage.toPng(divForDownloadViewport).then(dataUrl => { + html2canvas(divForDownloadViewport).then(canvas => { const link = document.createElement('a'); link.download = file; - link.href = dataUrl; + link.href = canvas.toDataURL(fileType, 1.0); link.click(); }); }; diff --git a/extensions/cornerstone/src/utils/calculateViewportRegistrations.ts b/extensions/cornerstone/src/utils/calculateViewportRegistrations.ts new file mode 100644 index 00000000000..cbb976534be --- /dev/null +++ b/extensions/cornerstone/src/utils/calculateViewportRegistrations.ts @@ -0,0 +1,30 @@ +import { Types, getRenderingEngine, utilities } from '@cornerstonejs/core'; + +export default function calculateViewportRegistrations( + viewports: Types.IViewportId[] +) { + const viewportPairs = _getViewportPairs(viewports); + + for (const [viewport, nextViewport] of viewportPairs) { + // check if they are in the same Frame of Reference + const renderingEngine1 = getRenderingEngine(viewport.renderingEngineId); + const renderingEngine2 = getRenderingEngine(nextViewport.renderingEngineId); + + const csViewport1 = renderingEngine1.getViewport(viewport.viewportId); + const csViewport2 = renderingEngine2.getViewport(nextViewport.viewportId); + + utilities.calculateViewportsSpatialRegistration(csViewport1, csViewport2); + } +} + +const _getViewportPairs = (viewports: Types.IViewportId[]) => { + const viewportPairs = []; + + for (let i = 0; i < viewports.length; i++) { + for (let j = i + 1; j < viewports.length; j++) { + viewportPairs.push([viewports[i], viewports[j]]); + } + } + + return viewportPairs; +}; diff --git a/extensions/cornerstone/src/utils/dicomLoaderService.js b/extensions/cornerstone/src/utils/dicomLoaderService.js index dbc5b1b8310..54836fc1277 100644 --- a/extensions/cornerstone/src/utils/dicomLoaderService.js +++ b/extensions/cornerstone/src/utils/dicomLoaderService.js @@ -38,6 +38,10 @@ const getImageInstance = dataset => { return dataset && dataset.images && dataset.images[0]; }; +const getNonImageInstance = dataset => { + return dataset && dataset.instance; +}; + const getImageInstanceId = imageInstance => { return getImageId(imageInstance); }; @@ -89,19 +93,28 @@ const getImageLoaderType = imageId => { class DicomLoaderService { getLocalData(dataset, studies) { - if (dataset && dataset.localFile) { - // Use referenced imageInstance - const imageInstance = getImageInstance(dataset); - let imageId = getImageInstanceId(imageInstance); - - // or Try to get it from studies - if (someInvalidStrings(imageId)) { - imageId = findImageIdOnStudies(studies, dataset.displaySetInstanceUID); - } + // Use referenced imageInstance + const imageInstance = getImageInstance(dataset); + const nonImageInstance = getNonImageInstance(dataset); - if (!someInvalidStrings(imageId)) { - return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId); - } + if ( + (!imageInstance && !nonImageInstance) || + !nonImageInstance.imageId.startsWith('dicomfile') + ) { + return; + } + + const instance = imageInstance || nonImageInstance; + + let imageId = getImageInstanceId(instance); + + // or Try to get it from studies + if (someInvalidStrings(imageId)) { + imageId = findImageIdOnStudies(studies, dataset.displaySetInstanceUID); + } + + if (!someInvalidStrings(imageId)) { + return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId); } } @@ -182,13 +195,14 @@ class DicomLoaderService { } } - *getLoaderIterator(dataset, studies) { + *getLoaderIterator(dataset, studies, headers) { yield this.getLocalData(dataset, studies); yield this.getDataByImageType(dataset); yield this.getDataByDatasetType(dataset); } - findDicomDataPromise(dataset, studies) { + findDicomDataPromise(dataset, studies, headers) { + dataset.authorizationHeaders = headers; const loaderIterator = this.getLoaderIterator(dataset, studies); // it returns first valid retriever method. for (const loader of loaderIterator) { diff --git a/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts b/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts index 398db953522..1c91dfb1356 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts @@ -8,15 +8,19 @@ const CORONAL = 'coronal'; export default function getCornerstoneOrientation( orientation: string ): Enums.OrientationAxis { - switch (orientation.toLowerCase()) { - case AXIAL: - return Enums.OrientationAxis.AXIAL; - case SAGITTAL: - return Enums.OrientationAxis.SAGITTAL; - case CORONAL: - return Enums.OrientationAxis.CORONAL; - default: - log.wanr('Choosing acquisition plane orientation'); - return Enums.OrientationAxis.ACQUISITION; + if (orientation) { + switch (orientation.toLowerCase()) { + case AXIAL: + return Enums.OrientationAxis.AXIAL; + case SAGITTAL: + return Enums.OrientationAxis.SAGITTAL; + case CORONAL: + return Enums.OrientationAxis.CORONAL; + default: + log.wanr('Choosing acquisition plane orientation'); + return Enums.OrientationAxis.ACQUISITION; + } } + + return Enums.OrientationAxis.ACQUISITION; } diff --git a/extensions/cornerstone/src/utils/getProtocolViewportStructureFromGridViewports.ts b/extensions/cornerstone/src/utils/getProtocolViewportStructureFromGridViewports.ts new file mode 100644 index 00000000000..331c9019d24 --- /dev/null +++ b/extensions/cornerstone/src/utils/getProtocolViewportStructureFromGridViewports.ts @@ -0,0 +1,74 @@ +/** + * Given the ViewportGridService state it will re create the protocol viewport structure + * that was used at the hanging protocol creation time. This is used to re create the + * viewport structure when the user decides to go back to a previous cached + * layout in the viewport grid. + * + * + * viewportGrid's viewports look like + * + * viewports = [ + * { + * displaySetInstanceUIDs: string[], + * displaySetOptions: [], + * viewportOptions: {} + * height: number, + * width: number, + * x: number, + * y: number + * }, + * ] + * + * and hanging protocols viewport structure looks like + * + * viewportStructure: { + * layoutType: 'grid', + * properties: { + * rows: 3, + * columns: 4, + * layoutOptions: [ + * { + * x: 0, + * y: 0, + * width: 1 / 4, + * height: 1 / 3, + * }, + * { + * x: 1 / 4, + * y: 0, + * width: 1 / 4, + * height: 1 / 3, + * }, + * ], + * }, + * }, + */ +export default function getProtocolViewportStructureFromGridViewports({ + numRows, + numCols, + viewports, +}: { + numRows: number; + numCols: number; + viewports: any[]; +}) { + const viewportStructure = { + layoutType: 'grid', + properties: { + rows: numRows, + columns: numCols, + layoutOptions: [], + }, + }; + + viewports.forEach(viewport => { + viewportStructure.properties.layoutOptions.push({ + x: viewport.x, + y: viewport.y, + width: viewport.width, + height: viewport.height, + }); + }); + + return viewportStructure; +} diff --git a/extensions/cornerstone/src/utils/interleaveCenterLoader.ts b/extensions/cornerstone/src/utils/interleaveCenterLoader.ts index d0ea08fbc77..0047f0ac0ab 100644 --- a/extensions/cornerstone/src/utils/interleaveCenterLoader.ts +++ b/extensions/cornerstone/src/utils/interleaveCenterLoader.ts @@ -51,7 +51,7 @@ export default function interleaveCenterLoader({ * listen to it and as the other viewports are created we can set the volumes for them * since volumes are already started loading. */ - if (matchDetails.length !== viewportIdVolumeInputArrayMap.size) { + if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) { return; } diff --git a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts index 1f1bed3f08a..162bb5588cf 100644 --- a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts +++ b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts @@ -50,7 +50,7 @@ export default function interleaveTopToBottom({ * listen to it and as the other viewports are created we can set the volumes for them * since volumes are already started loading. */ - if (matchDetails.length !== viewportIdVolumeInputArrayMap.size) { + if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) { return; } diff --git a/extensions/cornerstone/src/utils/removeToolGroupSegmentationRepresentations.ts b/extensions/cornerstone/src/utils/removeToolGroupSegmentationRepresentations.ts new file mode 100644 index 00000000000..513321b32ff --- /dev/null +++ b/extensions/cornerstone/src/utils/removeToolGroupSegmentationRepresentations.ts @@ -0,0 +1,20 @@ +import { segmentation } from '@cornerstonejs/tools'; + +function removeToolGroupSegmentationRepresentations(toolGroupId) { + const representations = segmentation.state.getSegmentationRepresentations( + toolGroupId + ); + + if (!representations || !representations.length) { + return; + } + + representations.forEach(representation => { + segmentation.state.removeSegmentationRepresentation( + toolGroupId, + representation.segmentationRepresentationUID + ); + }); +} + +export default removeToolGroupSegmentationRepresentations; diff --git a/extensions/cornerstone/src/utils/segmentationServiceMappings/Labelmap.js b/extensions/cornerstone/src/utils/segmentationServiceMappings/Labelmap.js deleted file mode 100644 index cb058b06e62..00000000000 --- a/extensions/cornerstone/src/utils/segmentationServiceMappings/Labelmap.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Enums as csToolsEnums } from '@cornerstonejs/tools'; - -const Labelmap = { - toSegmentation: segmentationState => { - const { - activeSegmentIndex, - cachedStats: data, - segmentsLocked, - representationData, - label, - segmentationId, - text, - } = segmentationState; - - const labelmapRepresentationData = - representationData[csToolsEnums.SegmentationRepresentations.Labelmap]; - - return { - id: segmentationId, - activeSegmentIndex, - segmentsLocked, - data, - label, - volumeId: labelmapRepresentationData.volumeId, - displayText: text || [], - }; - }, -}; - -export default Labelmap; diff --git a/extensions/cornerstone/src/utils/segmentationServiceMappings/segmentationServiceMappingsFactory.js b/extensions/cornerstone/src/utils/segmentationServiceMappings/segmentationServiceMappingsFactory.js deleted file mode 100644 index ea248829abb..00000000000 --- a/extensions/cornerstone/src/utils/segmentationServiceMappings/segmentationServiceMappingsFactory.js +++ /dev/null @@ -1,16 +0,0 @@ -import Labelmap from './Labelmap'; - -const segmentationServiceMappingsFactory = ( - SegmentationService, - DisplaySetService -) => { - return { - Labelmap: { - matchingCriteria: {}, - toSegmentation: csToolsSegmentation => - Labelmap.toSegmentation(csToolsSegmentation, DisplaySetService), - }, - }; -}; - -export default segmentationServiceMappingsFactory; diff --git a/extensions/cornerstone/src/utils/transitions.ts b/extensions/cornerstone/src/utils/transitions.ts new file mode 100644 index 00000000000..2fa96794d09 --- /dev/null +++ b/extensions/cornerstone/src/utils/transitions.ts @@ -0,0 +1,22 @@ +/** + * It is a bell curved function that 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 easeInOutBell(x: number, baseline: number): number { + const alpha = 1 - baseline; + + // prettier-ignore + if (x < 1 / 4) { + return 4 * Math.pow(2 * x, 3) * alpha + baseline; + } else if (x < 1 / 2) { + return (1 - Math.pow(-4 * x + 2, 3) / 2) * alpha + baseline; + } else if (x < 3 / 4) { + return (1 - Math.pow(4 * x - 2, 3) / 2) * alpha + baseline; + } else { + return (- 4 * Math.pow(2 * x - 2, 3)) * alpha + baseline; + } +} diff --git a/extensions/default/package.json b/extensions/default/package.json index 34858832a01..a894ef8d9d8 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -43,6 +43,6 @@ }, "dependencies": { "@babel/runtime": "7.16.3", - "@cornerstonejs/calculate-suv": "1.0.2" + "@cornerstonejs/calculate-suv": "^1.0.3" } } diff --git a/extensions/default/src/DicomLocalDataSource/index.js b/extensions/default/src/DicomLocalDataSource/index.js index faa2b95ae74..1e29238bf16 100644 --- a/extensions/default/src/DicomLocalDataSource/index.js +++ b/extensions/default/src/DicomLocalDataSource/index.js @@ -166,7 +166,6 @@ function createDicomLocalApi(dicomLocalConfig) { displaySet.images.forEach(instance => { const NumberOfFrames = instance.NumberOfFrames; - if (NumberOfFrames > 1) { for (let i = 0; i < NumberOfFrames; i++) { const imageId = this.getImageIdsForInstance({ @@ -190,9 +189,14 @@ function createDicomLocalApi(dicomLocalConfig) { SeriesInstanceUID, SOPInstanceUID ); - if (storedInstance.url) { - return storedInstance.url; + + let imageId = storedInstance.url; + + if (frame !== undefined) { + imageId += `&frame=${frame}`; } + + return imageId; }, deleteStudyMetadataPromise() { console.log('deleteStudyMetadataPromise not implemented'); diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index d18a13f87e0..6f7eb76f962 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -423,6 +423,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) { // any implementation that stores static copies of the metadata StudyInstanceUID: naturalized.StudyInstanceUID, }; + // Todo: this needs to be from wado dicom web client return qidoDicomWebClient.retrieveBulkData(options).then(val => { const ret = (val && val[0]) || undefined; value.Value = ret; @@ -440,6 +441,9 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) { // Adding instanceMetadata to OHIF MetadataProvider naturalizedInstances.forEach((instance, index) => { + instance.wadoRoot = dicomWebConfig.wadoRoot; + instance.wadoUri = dicomWebConfig.wadoUri; + const imageId = implementation.getImageIdsForInstance({ instance, }); diff --git a/extensions/default/src/Panels/PanelMeasurementTable.tsx b/extensions/default/src/Panels/PanelMeasurementTable.tsx index c076692ca80..fb890117828 100644 --- a/extensions/default/src/Panels/PanelMeasurementTable.tsx +++ b/extensions/default/src/Panels/PanelMeasurementTable.tsx @@ -155,7 +155,6 @@ export default function PanelMeasurementTable({ > Studies --> DisplaySets --> Thumbnails @@ -34,10 +39,25 @@ function PanelStudyBrowser({ const isMounted = useRef(true); const onDoubleClickThumbnailHandler = displaySetInstanceUID => { - viewportGridService.setDisplaySetsForViewport({ - viewportIndex: activeViewportIndex, - displaySetInstanceUIDs: [displaySetInstanceUID], - }); + let updatedViewports = []; + const viewportIndex = activeViewportIndex; + try { + updatedViewports = HangingProtocolService.getViewportsRequireUpdate( + viewportIndex, + displaySetInstanceUID + ); + } catch (error) { + console.warn(error); + UINotificationService.show({ + title: 'Thumbnail Double Click', + message: + 'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.', + type: 'info', + duration: 3000, + }); + } + + viewportGridService.setDisplaySetsForViewports(updatedViewports); }; // ~~ studyDisplayList @@ -227,12 +247,7 @@ function PanelStudyBrowser({ } PanelStudyBrowser.propTypes = { - DisplaySetService: PropTypes.shape({ - EVENTS: PropTypes.object.isRequired, - activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired, - getDisplaySetByUID: PropTypes.func.isRequired, - subscribe: PropTypes.func.isRequired, - }).isRequired, + servicesManager: PropTypes.object.isRequired, dataSource: PropTypes.shape({ getImageIdsForDisplaySet: PropTypes.func.isRequired, }).isRequired, diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx index 7ae799a865e..237b5553b7e 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx @@ -35,7 +35,7 @@ function WrappedPanelStudyBrowser({ return ( { + const { unsubscribe: unsub1 } = ToolBarService.subscribe( + ToolBarService.EVENTS.TOOL_BAR_MODIFIED, + () => setToolbarButtons(ToolBarService.getButtonSection('primary')) + ); + const { unsubscribe: unsub2 } = ToolBarService.subscribe( + ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED, + () => setButtonState({ ...ToolBarService.state }) + ); + + return () => { + unsub1(); + unsub2(); + }; + }, [ToolBarService]); + + return ( + <> + {toolbarButtons.map((toolDef, index) => { + const { id, Component, componentProps } = toolDef; + // TODO: ... + + // isActive if: + // - id is primary? + // - id is in list of "toggled on"? + let isActive; + if (componentProps.type === 'toggle') { + isActive = buttonState.toggles[id]; + } + // Also need... to filter list for splitButton, and set primary based on most recently clicked + // Also need to kill the radioGroup button's magic logic + // Everything should be reactive off these props, so commands can inform ToolbarService + + // These can... Trigger toolbar events based on updates? + // Then sync using useEffect, or simply modify the state here? + return ( + ToolBarService.recordInteraction(args)} + servicesManager={servicesManager} + /> + ); + })} + + ); +} diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx index 78377ea9e95..a2e4509cf30 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx @@ -1,20 +1,43 @@ import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; import { LayoutSelector as OHIFLayoutSelector, ToolbarButton, useViewportGrid, } from '@ohif/ui'; -function LayoutSelector({rows, columns}) { +function LayoutSelector({ rows, columns, servicesManager }) { const [isOpen, setIsOpen] = useState(false); + const [disableSelector, setDisableSelector] = useState(false); const [viewportGridState, viewportGridService] = useViewportGrid(); + const { HangingProtocolService } = servicesManager.services; + const closeOnOutsideClick = () => { if (isOpen) { setIsOpen(false); } }; + useEffect(() => { + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.PROTOCOL_CHANGED, + evt => { + const { protocol } = evt; + + if (protocol.id === 'mpr') { + setDisableSelector(true); + } else { + setDisableSelector(false); + } + } + ); + + return () => { + unsubscribe(); + }; + }, [HangingProtocolService]); + useEffect(() => { window.addEventListener('click', closeOnOutsideClick); return () => { @@ -44,15 +67,27 @@ function LayoutSelector({rows, columns}) { rows={rows} columns={columns} onSelection={({ numRows, numCols }) => { - viewportGridService.setLayout({ numCols, numRows }); + viewportGridService.setLayout({ numRows, numCols }); }} /> ) } - isActive={isOpen} + isActive={disableSelector ? false : isOpen} type="toggle" /> ); } +LayoutSelector.propTypes = { + rows: PropTypes.number, + columns: PropTypes.number, + onLayoutChange: PropTypes.func, +}; + +LayoutSelector.defaultProps = { + rows: 3, + columns: 3, + onLayoutChange: () => {}, +}; + export default LayoutSelector; diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index 4326ece4f8f..562760b0435 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -9,74 +9,16 @@ import { AboutModal, Header, useModal, + LoadingIndicatorProgress, } from '@ohif/ui'; import i18n from '@ohif/i18n'; import { hotkeys } from '@ohif/core'; import { useAppConfig } from '@state'; +import Toolbar from '../Toolbar/Toolbar'; const { availableLanguages, defaultLanguage, currentLanguage } = i18n; -function Toolbar({ servicesManager }) { - const { ToolBarService } = servicesManager.services; - const [toolbarButtons, setToolbarButtons] = useState([]); - const [buttonState, setButtonState] = useState({ - primaryToolId: '', - toggles: {}, - groups: {}, - }); - - // Could track buttons and state separately...? - useEffect(() => { - const { unsubscribe: unsub1 } = ToolBarService.subscribe( - ToolBarService.EVENTS.TOOL_BAR_MODIFIED, - () => setToolbarButtons(ToolBarService.getButtonSection('primary')) - ); - const { unsubscribe: unsub2 } = ToolBarService.subscribe( - ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED, - () => setButtonState({ ...ToolBarService.state }) - ); - - return () => { - unsub1(); - unsub2(); - }; - }, [ToolBarService]); - - return ( - <> - {toolbarButtons.map((toolDef, index) => { - const { id, Component, componentProps } = toolDef; - // TODO: ... - - // isActive if: - // - id is primary? - // - id is in list of "toggled on"? - let isActive; - if (componentProps.type === 'toggle') { - isActive = buttonState.toggles[id]; - } - // Also need... to filter list for splitButton, and set primary based on most recently clicked - // Also need to kill the radioGroup button's magic logic - // Everything should be reactive off these props, so commands can inform ToolbarService - - // These can... Trigger toolbar events based on updates? - // Then sync using useEffect, or simply modify the state here? - return ( - ToolBarService.recordInteraction(args)} - /> - ); - })} - - ); -} - function ViewerLayout({ // From Extension Module Params extensionManager, @@ -84,12 +26,12 @@ function ViewerLayout({ hotkeysManager, commandsManager, // From Modes - leftPanels, - rightPanels, - leftPanelDefaultClosed, - rightPanelDefaultClosed, viewports, ViewportGridComp, + leftPanels = [], + rightPanels = [], + leftPanelDefaultClosed = false, + rightPanelDefaultClosed = false, }) { const [appConfig] = useAppConfig(); const navigate = useNavigate(); @@ -101,6 +43,12 @@ function ViewerLayout({ const { t } = useTranslation(); const { show, hide } = useModal(); + const [showLoadingIndicator, setShowLoadingIndicator] = useState( + appConfig.showLoadingIndicator + ); + + const { HangingProtocolService } = servicesManager.services; + const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager; const versionNumber = process.env.VERSION_NUMBER; const buildNumber = process.env.BUILD_NUM; @@ -153,8 +101,10 @@ function ViewerLayout({ title: t('Header:Logout'), icon: 'power-off', onClick: async () => { - navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`); - } + navigate( + `/logout?redirect_uri=${encodeURIComponent(window.location.href)}` + ); + }, }); } @@ -186,6 +136,25 @@ function ViewerLayout({ }; }; + useEffect(() => { + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT, + + // Todo: right now to set the loading indicator to false, we need to wait for the + // HangingProtocolService to finish applying the viewport matching to each viewport, + // however, this might not be the only approach to set the loading indicator to false. we need to explore this further. + ({ progress }) => { + if (progress === 100) { + setShowLoadingIndicator(false); + } + } + ); + + return () => { + unsubscribe(); + }; + }, [HangingProtocolService]); + const getViewportComponentData = viewportComponent => { const entry = extensionManager.getModuleEntry(viewportComponent.namespace); @@ -213,44 +182,45 @@ function ViewerLayout({
- {/* LEFT SIDEPANELS */} - {leftPanelComponents.length ? ( - - - - ) : null} - {/* TOOLBAR + GRID */} -
-
- - + {showLoadingIndicator && ( + + )} + {/* LEFT SIDEPANELS */} + {leftPanelComponents.length ? ( + + + ) : null} + {/* TOOLBAR + GRID */} +
+
+ + + +
-
- {rightPanelComponents.length ? ( - - - - ) : null} + {rightPanelComponents.length ? ( + + + + ) : null} +
); @@ -271,11 +241,4 @@ ViewerLayout.propTypes = { children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, }; -ViewerLayout.defaultProps = { - leftPanels: [], - rightPanels: [], - leftPanelDefaultClosed: false, - rightPanelDefaultClosed: false, -}; - export default ViewerLayout; diff --git a/extensions/default/src/getPanelModule.tsx b/extensions/default/src/getPanelModule.tsx index f5eb53bf2a6..bfc66a36c2c 100644 --- a/extensions/default/src/getPanelModule.tsx +++ b/extensions/default/src/getPanelModule.tsx @@ -34,9 +34,10 @@ function getPanelModule({ }, { name: 'measure', - iconName: 'list-bullets', + iconName: 'tab-linear', iconLabel: 'Measure', label: 'Measurements', + secondaryLabel: 'Measurements', component: wrappedMeasurementPanel, }, ]; diff --git a/extensions/default/src/getSopClassHandlerModule.js b/extensions/default/src/getSopClassHandlerModule.js index 83d1f4ff876..317ffdd646f 100644 --- a/extensions/default/src/getSopClassHandlerModule.js +++ b/extensions/default/src/getSopClassHandlerModule.js @@ -25,6 +25,7 @@ const makeDisplaySet = instances => { StudyInstanceUID: instance.StudyInstanceUID, SeriesNumber: instance.SeriesNumber || 0, FrameRate: instance.FrameTime, + SOPClassUID: instance.SOPClassUID, SeriesDescription: instance.SeriesDescription || '', Modality: instance.Modality, isMultiFrame: isMultiFrame(instance), diff --git a/extensions/dicom-pdf/src/getSopClassHandlerModule.js b/extensions/dicom-pdf/src/getSopClassHandlerModule.js index ac19112a900..949edfd4270 100644 --- a/extensions/dicom-pdf/src/getSopClassHandlerModule.js +++ b/extensions/dicom-pdf/src/getSopClassHandlerModule.js @@ -26,6 +26,7 @@ const _getDisplaySetsFromSeries = ( SeriesDate, SeriesInstanceUID, StudyInstanceUID, + SOPClassUID, } = instance; const pdfUrl = dataSource.retrieve.directURL({ instance, @@ -45,6 +46,7 @@ const _getDisplaySetsFromSeries = ( SeriesInstanceUID, StudyInstanceUID, SOPClassHandlerId, + SOPClassUID, referencedImages: null, measurements: null, pdfUrl, diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 83d4b046034..f556dab8ad0 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.2.6", - "@cornerstonejs/core": "^0.16.8", - "@cornerstonejs/tools": "^0.24.1", + "@cornerstonejs/core": "^0.21.0", + "@cornerstonejs/tools": "^0.29.2", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.28.3", "prop-types": "^15.6.2", diff --git a/extensions/measurement-tracking/src/getPanelModule.tsx b/extensions/measurement-tracking/src/getPanelModule.tsx index 55ee733b434..6c0eb228077 100644 --- a/extensions/measurement-tracking/src/getPanelModule.tsx +++ b/extensions/measurement-tracking/src/getPanelModule.tsx @@ -26,7 +26,7 @@ function getPanelModule({ }, { name: 'trackedMeasurements', - iconName: 'list-bullets', + iconName: 'tab-linear', iconLabel: 'Measure', label: 'Measurements', component: PanelMeasurementTableTracking.bind(null, { diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx index d5f728856d8..d9ce92fc54f 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx @@ -251,7 +251,6 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { )} Studies --> DisplaySets --> Thumbnails @@ -47,10 +52,25 @@ function PanelStudyBrowserTracking({ const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null); const onDoubleClickThumbnailHandler = displaySetInstanceUID => { - viewportGridService.setDisplaySetsForViewport({ - viewportIndex: activeViewportIndex, - displaySetInstanceUIDs: [displaySetInstanceUID], - }); + let updatedViewports = []; + const viewportIndex = activeViewportIndex; + try { + updatedViewports = HangingProtocolService.getViewportsRequireUpdate( + viewportIndex, + displaySetInstanceUID + ); + } catch (error) { + console.warn(error); + UINotificationService.show({ + title: 'Thumbnail Double Click', + message: + 'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.', + type: 'info', + duration: 3000, + }); + } + + viewportGridService.setDisplaySetsForViewports(updatedViewports); }; const activeViewportDisplaySetInstanceUIDs = @@ -353,16 +373,7 @@ function PanelStudyBrowserTracking({ } PanelStudyBrowserTracking.propTypes = { - MeasurementService: PropTypes.shape({ - subscribe: PropTypes.func.isRequired, - EVENTS: PropTypes.object.isRequired, - }).isRequired, - DisplaySetService: PropTypes.shape({ - EVENTS: PropTypes.object.isRequired, - activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired, - getDisplaySetByUID: PropTypes.func.isRequired, - subscribe: PropTypes.func.isRequired, - }).isRequired, + servicesManager: PropTypes.object.isRequired, dataSource: PropTypes.shape({ getImageIdsForDisplaySet: PropTypes.func.isRequired, }).isRequired, @@ -542,8 +553,6 @@ function _getComponentType(Modality) { return 'thumbnailTracked'; } -const _viewportLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']; - /** * * @param {string[]} primaryStudyInstanceUIDs diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx index bacfefe217a..f84f4aa4c1d 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/index.tsx @@ -45,10 +45,7 @@ function WrappedPanelStudyBrowserTracking({ return ( { - cineService.stopClip(element); + if (element && cines?.[viewportIndex]?.isPlaying) { + cineService.stopClip(element); + } }; - }, [cines, viewportIndex, cineService, element]); + }, [cines, viewportIndex, cineService, element, cineHandler]); if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { setIsTracked(!isTracked); @@ -193,16 +201,14 @@ function TrackedCornerstoneViewport(props) { evt.stopPropagation(); evt.preventDefault(); }} - onSeriesChange={direction => switchMeasurement(direction)} + useAltStyling={isTracked} + onArrowsClick={direction => switchMeasurement(direction)} + getStatusComponent={() => _getStatusComponent(isTracked)} studyData={{ label: viewportLabel, - isTracked, - isLocked: false, - isRehydratable: false, studyDate: formatDate(SeriesDate), // TODO: This is series date. Is that ok? currentSeries: SeriesNumber, // TODO - switch entire currentSeries to be UID based or actual position based seriesDescription: SeriesDescription, - modality: Modality, patientInformation: { patientName: PatientName ? OHIF.utils.formatPN(PatientName.Alphabetic) @@ -322,4 +328,43 @@ function _getNextMeasurementUID( return newTrackedMeasurementId; } +function _getStatusComponent(isTracked) { + const trackedIcon = isTracked ? 'tracked' : 'dotted-circle'; + + return ( +
+ +
+ +
+
+ + {isTracked ? ( + <> + Series is + tracked and + can be viewed
in the measurement panel + + ) : ( + <> + Measurements for + untracked + series
will not be shown in the
measurements + panel + + )} +
+
+
+ } + > + + +
+ ); +} + export default TrackedCornerstoneViewport; diff --git a/extensions/tmtv/src/Panels/PanelPetSUV.tsx b/extensions/tmtv/src/Panels/PanelPetSUV.tsx index de3795bcff8..2f1e969f740 100644 --- a/extensions/tmtv/src/Panels/PanelPetSUV.tsx +++ b/extensions/tmtv/src/Panels/PanelPetSUV.tsx @@ -143,7 +143,6 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) { ptDisplaySet.displaySetInstanceUID ); } - return (
{ diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx index f91b007cd17..238dcfc0f19 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx @@ -94,7 +94,7 @@ export default function PanelRoiThresholdSegmentation({ selectedSegmentationId ); - const data = { + const cachedStats = { lesionStats, suvPeak, lesionGlyoclysisStats, @@ -102,11 +102,10 @@ export default function PanelRoiThresholdSegmentation({ const notYetUpdatedAtSource = true; SegmentationService.addOrUpdateSegmentation( - selectedSegmentationId, { ...segmentation, - data: Object.assign(segmentation.data, data), - text: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`], + ...Object.assign(segmentation.cachedStats, cachedStats), + displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`], }, notYetUpdatedAtSource ); @@ -160,23 +159,6 @@ export default function PanelRoiThresholdSegmentation({ }; }, []); - /** - * Toggle visibility of the segmentation - */ - useEffect(() => { - const subscription = SegmentationService.subscribe( - SegmentationService.EVENTS.SEGMENTATION_VISIBILITY_CHANGED, - ({ segmentation }) => { - runCommand('toggleSegmentationVisibility', { - segmentationId: segmentation.id, - }); - } - ); - return () => { - subscription.unsubscribe(); - }; - }, [SegmentationService]); - /** * Whenever the segmentations change, update the TMTV calculations */ @@ -189,7 +171,7 @@ export default function PanelRoiThresholdSegmentation({ }, [segmentations, selectedSegmentationId]); return ( -
+
-
@@ -239,7 +214,6 @@ export default function PanelRoiThresholdSegmentation({ {segmentations?.length ? ( { @@ -249,10 +223,12 @@ export default function PanelRoiThresholdSegmentation({ setSelectedSegmentationId(id); }} onToggleVisibility={id => { - SegmentationService.toggleSegmentationsVisibility([id]); + SegmentationService.toggleSegmentationVisibility(id); }} onToggleVisibilityAll={ids => { - SegmentationService.toggleSegmentationsVisibility(ids); + ids.map(id => { + SegmentationService.toggleSegmentationVisibility(id); + }); }} onDelete={id => { SegmentationService.remove(id); @@ -282,11 +258,11 @@ export default function PanelRoiThresholdSegmentation({ />
{ // navigate to a url in a new tab window.open( - 'https://github.com/OHIF/Viewers/blob/feat/segmentation-service/modes/tmtv/README.md', + 'https://github.com/OHIF/Viewers/blob/v3-stable/modes/tmtv/README.md', '_blank' ); }} @@ -312,10 +288,9 @@ PanelRoiThresholdSegmentation.propTypes = { SegmentationService: PropTypes.shape({ getSegmentation: PropTypes.func.isRequired, getSegmentations: PropTypes.func.isRequired, - toggleSegmentationsVisibility: PropTypes.func.isRequired, + toggleSegmentationVisibility: PropTypes.func.isRequired, subscribe: PropTypes.func.isRequired, EVENTS: PropTypes.object.isRequired, - VALUE_TYPES: PropTypes.object.isRequired, }).isRequired, }).isRequired, }).isRequired, diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx index 48dd226c52c..d4c048cf53b 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx @@ -20,7 +20,7 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) {