feat: Add DICOM SEG support and MPR, referenceLines and StackSync and more (#3015)

* feat: add initial sop class handler for SEG

* feat: move segmentation service

* update segmentation service methods

* fix: viewport data structure

* feat: Add initial render for the DICOM SEG for each displaySet

* fix: various wrong architectural dependencies between services

* feat: initial separate SEG display in each viewport

* feat: refactore viewport action bar

* initial work for SEG hydration

* fix: various bugs regarding drag and dropping different viewports

* fix: rendering issues for multiple seg displaysets

* fix: bugs for thumbnail and hydration

* feat: fix the initial segment color

* update after rebase

* feat: initial design for the segmentation group table

* feat: initial new design for the segmentation panel

* feat: segmentation panel

* feat: make segmentation appear on all related viewports

* fix: segmentation load bug based on functional groups

* initial work for segmentation crosshairs

* fix: various stylings and functionality

* fix: various stylings for the seg panel

* fix: overflow styles

* feat: add more ui components

* feat: added segmentation config

* feat: add jump to segment

* feat: add jump to segment for DICOM SEG viewport

* fix: bugs after rebase

* feat: jump in segmentation viewport

* fix: mpr support for seg

* feat: add more icons

* feat: new icons

* feat: add new side panel

* feat: add segmentation config

* feat: add loading indicator to ohif

* feat: make hanging protocols follow matching rules for viewports

* feat: enhance drag and drop to be hanging protocol aware

* fix: mpr restore previous layout

* fix: crosshairs toggle

* fix: add auth headers to the dicom loader via dicomwebclient

* fix: bug for crosshairs toggle in mpr

* fix: seg viewport reusing old toolGroup

* feat: add loading animation with lottie

* fix: various bugs for Segmentation hydration in mpr

* feat: change outline alpha to outline opacity

* feat: loading indicator for seg viewport

* fix: various segmentation group styles

* fix: local mode for seg

* feat: add animation for the highlight

* fix: loading indicatro to show segment indices

* feat: enhance modality drop down ui

* fix: panels

* fix: download form

* fix: layout shift in loading indicator

* fix: loading indicator to have correct values

* fix: update software number

* fix: image jump between MPR and default

* fix: segmentation cleanup and cine service cleanup

* fix: segmentation toolgroup clena up

* fix: highlight interval should not trigger again

* fix: issue with multiframe sorting

* rename: change onSeriesChange to onArrowsClick

* fix: buttons for tmtv and layout shift

* fix: various bugs wrt crosshairs

* fix: crosshairs re init on reset camera

* fix: middle slice calculation different from cs middle reset camera

* fix: reset camera should reset viewport camera

* fix: loading segments in MPR mode

* fix: tmtv hp back to before

* fix: layout shift

* feat: orientation markers for volume viewport

* fix: capture for volume viewports

* fix: memory leak for back to worklist

* fix: loading bar bg color

* fix: side panel for only one panel

* fix: react select style in production

* fix: various styling for segmentation groups

* fix: various ui styles

* feat: add new segmentation config styles

* fix: hover state for segmentation item

* fix: side panel layout shift

* temp add panels

* try to fix scrollbar for thimbnail

* fix: scroll not appearing for side panels

* feat: changed styles for scrollbar

* feat: add cpu fallback warning

* fix: webworker destroy

* fix: select styles

* fix: orientation marker color and position

* fix: capture screenshot for volume viewports

* fix: hide segment visibility on mpr

* fix: reloading an already loaded seg displayset

* feat: add is equal

* fix: mpr jump to measurements

* apply review comments

* fix: optimization for the segmentation load

* fix: remove unnecessary context menu and hp service reset

* fix: segmentation service wrt brush settings

* feat: initial work for the stack synchronization

* fix: add validateDisplaySetSelectorsForNewDisplaySets to make sure drag and drop can follow requirements

* fix: various react proptypes

* fix: thumbnails for the tmtv and change default props to default params

* feat: make showing loading indicator configurable and add docs

* bump versions and add more docs

* fix demo

* update cornerstone versions

* apply review comments

* feat: add reference lines

* fix build

* fix unit tests

* add reference lines icon

* fix: e2e tests

* fix static wado config

* docs: add segmentation service docs

* fix: docker build

* fix: keep camera and bump versions

* Try re-enabling minification to fix deploy previews

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>
This commit is contained in:
Alireza 2022-11-16 14:19:52 -05:00 committed by GitHub
parent 08c58b0634
commit 83ea228d86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
254 changed files with 12856 additions and 3141 deletions

43
.docker/README.md Normal file
View File

@ -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**

View File

@ -1,5 +1,6 @@
server {
listen 80;
listen ${PORT};
# listen 3000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;

View File

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

5
.gitattributes vendored Normal file
View File

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

2
.gitignore vendored
View File

@ -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/*

View File

@ -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;"]

View File

@ -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 });
};

View File

@ -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;

View File

@ -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.

View File

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

View File

@ -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__'],
},
},
};

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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 };

View File

@ -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 (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
* 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 (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
return [
{
name: 'panelSegmentation',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentation,
},
];
},
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
return (
<OHIFCornerstoneSEGViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
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;

View File

@ -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 (
<div className="flex flex-col justify-between mt-1">
{/* show segmentation table */}
{segmentations?.length ? (
<SegmentationGroupTable
title={t('Segmentations')}
showAddSegmentation={false}
segmentations={segmentations}
isMinimized={isMinimized}
activeSegmentationId={selectedSegmentationId}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
onToggleMinimizeSegmentation={onToggleMinimizeSegmentation}
segmentationConfig={{
initialConfig: initialSegmentationConfigurations,
usePercentage: true,
}}
setRenderOutline={value =>
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}
</div>
);
}
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,
};

View File

@ -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 (
<div className="p-4 bg-primary-dark">
<Input
autoFocus
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
</div>
);
},
},
});
}
}
export default callInputDialog;

View File

@ -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 };

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 (
<Component
{...props}
displaySets={[referencedDisplaySet, segDisplaySet]}
viewportOptions={{
viewportType: 'volume',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
}}
onElementEnabled={onElementEnabled}
onElementDisabled={onElementDisabled}
// initialImageIndex={initialImageIndex}
></Component>
);
}, [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 (
<>
<ViewportActionBar
onDoubleClick={evt => {
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 || '',
},
}}
/>
<div className="relative flex flex-row w-full h-full overflow-hidden">
{segIsLoading && (
<LoadingIndicatorProgress
className="w-full h-full"
progress={
processingProgress.totalSegments !== null
? ((processingProgress.segmentIndex + 1) /
processingProgress.totalSegments) *
100
: null
}
textBlock={
!processingProgress.totalSegments ? (
<span className="text-white text-sm">Loading SEG ...</span>
) : (
<span className="text-white text-sm flex items-baseline space-x-2">
<div>Loading Segment</div>
<div className="w-3">{`${processingProgress.segmentIndex}`}</div>
<div>/</div>
<div>{`${processingProgress.totalSegments}`}</div>
</span>
)
}
/>
)}
{getCornerstoneViewport()}
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
id="viewport-notification"
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
/>
)}
</div>
{childrenWithProps}
</div>
</>
);
}
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;

View File

@ -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 = () => (
<div
className="flex items-center justify-center -mr-1 rounded-full"
style={{
width: '18px',
height: '18px',
backgroundColor: '#98e5c1',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="exclamation"
style={{ color: '#000', width: '12px', height: '12px' }}
/>
</div>
);
ToolTipMessage = () => (
<div>This Segmentation is loaded in the segmentation panel</div>
);
break;
case false:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 bg-white rounded-full group-hover:bg-customblue-200"
style={{
width: '18px',
height: '18px',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="arrow-left"
style={{ color: '#000', width: '14px', height: '14px' }}
/>
</div>
);
ToolTipMessage = () => <div>Click to load segmentation.</div>;
}
const StatusPill = () => (
<div
className={classNames(
'group relative flex items-center justify-center px-8 rounded-full cursor-default bg-customgreen-100',
{
'hover:bg-customblue-100': !isHydrated,
'cursor-pointer': !isHydrated,
}
)}
style={{
height: '24px',
width: '55px',
}}
onClick={() => {
if (!isHydrated) {
if (onPillClick) {
onPillClick();
}
}
}}
>
<div className="pr-1 text-base font-medium leading-none text-black">
SEG
</div>
<StatusIcon />
</div>
);
return (
<>
{ToolTipMessage && (
<Tooltip content={<ToolTipMessage />} position="bottom-left">
<StatusPill />
</Tooltip>
)}
{!ToolTipMessage && <StatusPill />}
</>
);
}

View File

@ -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"
}
}

View File

@ -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,

View File

@ -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 = () => (
<div
className="flex items-center justify-center -mr-1 rounded-full"
style={{
width: '18px',
height: '18px',
backgroundColor: '#98e5c1',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="exclamation"
style={{ color: '#000', width: '12px', height: '12px' }}
/>
</div>
);
ToolTipMessage = () => (
<div>
This structured report is not compatible
<br />
with this application.
</div>
);
break;
case 2:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 bg-black rounded-full"
style={{
width: '18px',
height: '18px',
}}
>
<Icon
name="lock"
style={{ color: '#05D97C', width: '8px', height: '11px' }}
/>
</div>
);
ToolTipMessage = () => (
<div>
This structured report is currently read-only
<br />
because you are tracking measurements in
<br />
another viewport.
</div>
);
break;
case 3:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 bg-white rounded-full group-hover:bg-customblue-200"
style={{
width: '18px',
height: '18px',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="arrow-left"
style={{ color: '#000', width: '14px', height: '14px' }}
/>
</div>
);
ToolTipMessage = () => <div>Click to restore measurements.</div>;
}
const StatusPill = () => (
<div
className={classNames(
'group relative flex items-center justify-center px-2 rounded-full cursor-default bg-customgreen-100',
{
'hover:bg-customblue-100': state === 3,
'cursor-pointer': state === 3,
}
)}
style={{
height: '24px',
width: '55px',
}}
onClick={() => {
if (state === 3) {
if (onPillClick) {
onPillClick();
}
}
}}
>
<span className="pr-1 text-lg font-bold leading-none text-black">SR</span>
<StatusIcon />
</div>
);
return (
<>
{ToolTipMessage && (
<Tooltip content={<ToolTipMessage />} position="bottom-left">
<StatusPill />
</Tooltip>
)}
{!ToolTipMessage && <StatusPill />}
</>
);
}
// function _onDoubleClick() {
// const cancelActiveManipulatorsForElement = cornerstoneTools.getModule(
// 'manipulatorState'

View File

@ -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",

View File

@ -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];
const prevDisplaySets = prevProps.displaySets;
const nextDisplaySets = nextProps.displaySets;
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
);
if (prevDisplaySets.length !== nextDisplaySets.length) {
return false;
}
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 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 (
<div className="viewport-wrapper">
@ -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, {

View File

@ -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 (
<div>
<div className="noselect">
<ViewportImageScrollbar
viewportIndex={viewportIndex}
viewportData={viewportData}
@ -64,11 +63,15 @@ function CornerstoneOverlays(props) {
servicesManager={servicesManager}
element={element}
/>
<ViewportLoadingIndicator viewportData={viewportData} element={element} />
<ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers
imageSliceData={imageSliceData}
element={element}
viewportData={viewportData}
servicesManager={servicesManager}
viewportIndex={viewportIndex}
/>
</div>

View File

@ -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,
});
};

View File

@ -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;

View File

@ -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 {

View File

@ -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) => (
<div
className={`${m}-mid orientation-marker`}
key={`${m}-mid orientation-marker`}
>
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
}, [
viewportData,
imageSliceData,
rotation,
flipVertical,
flipHorizontal,
orientationMarkers,
element,
]);
return <div className="ViewportOrientationMarkers noselect">{markers}</div>;
}
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) => (
<div
className={`${m}-mid orientation-marker`}
key={`${m}-mid orientation-marker`}
>
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
},
[flipHorizontal, flipVertical, rotation, viewportData, imageSliceData]
);
return (
<div className="ViewportOrientationMarkers noselect">
{getMarkers(orientationMarkers)}
</div>
);
}
ViewportOrientationMarkers.propTypes = {
percentComplete: PropTypes.number,
error: PropTypes.object,
};
ViewportOrientationMarkers.defaultProps = {
percentComplete: 0,
error: null,
};
export default ViewportOrientationMarkers;

View File

@ -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 (
<ViewportOverlay
topLeft={getTopLeftContent()}
@ -201,7 +197,7 @@ function CornerstoneViewportOverlay({
}
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.imageIds;
const imageIds = viewportData.data.imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {

View File

@ -1,34 +1,44 @@
import {
getEnabledElement,
StackViewport,
volumeLoader,
cache,
utilities as csUtils,
} from '@cornerstonejs/core';
import {
ToolGroupManager,
Enums,
segmentation,
utilities as csToolsUtils,
utilities as cstUtils,
segmentation as cstSegmentation,
ReferenceLinesTool,
synchronizers as cstSynchronizers,
} from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { getEnabledElement as OHIFgetEnabledElement } from './state';
import callInputDialog from './utils/callInputDialog';
import { setColormap } from './utils/colormap/transferFunctionHelpers';
import getProtocolViewportStructureFromGridViewports from './utils/getProtocolViewportStructureFromGridViewports';
import removeToolGroupSegmentationRepresentations from './utils/removeToolGroupSegmentationRepresentations';
import calculateViewportRegistrations from './utils/calculateViewportRegistrations';
const MPR_TOOLGROUP_ID = 'mpr';
// [ {
// synchronizerId: string,
// viewports: [ { viewportId: number, renderingEngineId: string, index: number } , ...]
// ]}
let STACK_IMAGE_SYNC_GROUPS_INFO = [];
const commandsModule = ({ servicesManager }) => {
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;
}
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 }) => {
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');
}
}
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,22 +723,6 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
createSegmentationForDisplaySet: {
commandFn: actions.createSegmentationForDisplaySet,
storeContexts: [],
options: {},
},
addSegmentationRepresentationToToolGroup: {
commandFn: actions.addSegmentationRepresentationToToolGroup,
storeContexts: [],
options: {},
},
getLabelmapVolumes: {
commandFn: actions.getLabelmapVolumes,
storeContexts: [],
options: {},
},
setViewportColormap: {
commandFn: actions.setViewportColormap,
storeContexts: [],
@ -550,6 +733,21 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
toggleMPR: {
commandFn: actions.toggleMPR,
storeContexts: [],
options: {},
},
toggleStackImageSync: {
commandFn: actions.toggleStackImageSync,
storeContexts: [],
options: {},
},
toggleReferenceLines: {
commandFn: actions.toggleReferenceLines,
storeContexts: [],
options: {},
},
};
return {

View File

@ -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,
},
{
x: 1 / 3,
y: 0,
width: 1 / 3,
height: 1,
},
{
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,
};
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
},
],
},
],
},
],
};
function getHangingProtocolModule() {
return [
{
id: 'mpr',
protocol: MPRHangingProtocolGenerator,
protocol: mpr,
},
];
}

View File

@ -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,

View File

@ -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;
window.SegmentationService = SegmentationService;
window.DisplaySetService = DisplaySetService;
window.services = servicesManager.services;
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.registerUnknownVolumeLoader(
cornerstoneStreamingImageVolumeLoader
);
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 (
<div>
<p>
Your computer does not have enough GPU power to support the default GPU
rendering mode. OHIF has switched to CPU rendering mode. Please note
that CPU rendering does not support all features such as Volume
Rendering, Multiplanar Reconstruction, and Segmentation Overlays.
</p>
</div>
);
}
function _showCPURenderingModal(UIModalService, HangingProtocolService) {
const callback = progress => {
if (progress === 100) {
UIModalService.show({
content: CPUModal,
title: 'OHIF Fell Back to CPU Rendering',
});
return true;
}
};
const { unsubscribe } = HangingProtocolService.subscribe(
HangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT,
({ progress }) => {
const done = callback(progress);
if (done) {
unsubscribe();
}
}
);
}

View File

@ -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 };

View File

@ -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;

View File

@ -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;
}

View File

@ -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<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = new Map();
constructor(servicesManager) {
this.servicesManager = servicesManager;
}
public getCacheSize() {
return cs3DCache.getCacheSize();
}
public getCacheFreeSpace() {
return cs3DCache.getBytesAvailable();
}
public async createViewportData(
displaySets: unknown[],
viewportOptions: Record<string, unknown>,
dataSource: unknown,
initialImageIndex?: number
): Promise<StackViewportData | VolumeViewportData> {
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<VolumeViewportData> {
// Todo: Check the cache for multiple scenarios to see if we need to
// decache the volume data from other viewports or not
const volumeData = [];
for (const displaySet of displaySets) {
// 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;

View File

@ -0,0 +1,10 @@
import CornerstoneCacheService from './CornerstoneCacheService';
export default function ExtendedCornerstoneCacheService(serviceManager) {
return {
name: 'CornerstoneCacheService',
create: ({ configuration = {} }) => {
return new CornerstoneCacheService(serviceManager);
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -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<string, any>;
// 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<Segment>;
// the set of segments that are locked
segmentsLocked: Array<number>;
// 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<string, any>;
// 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<number>;
// 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<Segment>;
};
export { SegmentationConfig, Segment, Segmentation, SegmentationSchema };

View File

@ -0,0 +1,10 @@
import SegmentationService from './SegmentationService';
export default function ExtendedSegmentationService(servicesManager) {
return {
name: 'SegmentationService',
create: ({ configuration = {} }) => {
return new SegmentationService({ servicesManager });
},
};
}

View File

@ -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;
}

View File

@ -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 }) => {

View File

@ -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<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = 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<StackData | VolumeData> {
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<VolumeData> {
// 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;

View File

@ -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<number, ViewportInfo>;
viewportGridResizeObserver: ResizeObserver | null;
// TODO - get the right type here.
hangingProtocolService: object;
viewportsDisplaySets: Map<string, string[]> = 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<void> {
// 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) {

View File

@ -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,

View File

@ -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<DisplaySetOptions>;
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<DisplaySetOptions> = [];
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,
});

View File

@ -31,4 +31,8 @@ const getEnabledElement = viewportIndex => {
return state.enabledElements[viewportIndex];
};
export { setEnabledElement, getEnabledElement };
const reset = () => {
state.enabledElements = {};
};
export { setEnabledElement, getEnabledElement, reset };

View File

@ -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,
};

View File

@ -0,0 +1,5 @@
import * as CornerstoneCacheService from './CornerstoneCacheService'
export type {
CornerstoneCacheService
}

View File

@ -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
);
// add the viewport to the toolGroup
toolGroup.addViewport(downloadViewportId);
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);
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();
});
};

View File

@ -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;
};

View File

@ -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);
// Use referenced imageInstance
const imageInstance = getImageInstance(dataset);
const nonImageInstance = getNonImageInstance(dataset);
// or Try to get it from studies
if (someInvalidStrings(imageId)) {
imageId = findImageIdOnStudies(studies, dataset.displaySetInstanceUID);
}
if (
(!imageInstance && !nonImageInstance) ||
!nonImageInstance.imageId.startsWith('dicomfile')
) {
return;
}
if (!someInvalidStrings(imageId)) {
return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId);
}
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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -1,16 +0,0 @@
import Labelmap from './Labelmap';
const segmentationServiceMappingsFactory = (
SegmentationService,
DisplaySetService
) => {
return {
Labelmap: {
matchingCriteria: {},
toSegmentation: csToolsSegmentation =>
Labelmap.toSegmentation(csToolsSegmentation, DisplaySetService),
},
};
};
export default segmentationServiceMappingsFactory;

View File

@ -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;
}
}

View File

@ -43,6 +43,6 @@
},
"dependencies": {
"@babel/runtime": "7.16.3",
"@cornerstonejs/calculate-suv": "1.0.2"
"@cornerstonejs/calculate-suv": "^1.0.3"
}
}

View File

@ -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');

View File

@ -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,
});

View File

@ -155,7 +155,6 @@ export default function PanelMeasurementTable({
>
<MeasurementTable
title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}

View File

@ -10,12 +10,17 @@ const { formatDate } = utils;
* @param {*} param0
*/
function PanelStudyBrowser({
DisplaySetService,
servicesManager,
getImageSrc,
getStudiesForPatientByStudyInstanceUID,
requestDisplaySetCreationForStudy,
dataSource,
}) {
const {
HangingProtocolService,
DisplaySetService,
UINotificationService,
} = servicesManager.services;
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> 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,

View File

@ -35,7 +35,7 @@ function WrappedPanelStudyBrowser({
return (
<PanelStudyBrowser
DisplaySetService={servicesManager.services.DisplaySetService}
servicesManager={servicesManager}
dataSource={dataSource}
getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByStudyInstanceUID={

View File

@ -0,0 +1,62 @@
import React, { useEffect, useState } from 'react';
export default 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 (
<Component
key={id}
id={id}
{...componentProps}
bState={buttonState}
isActive={isActive}
onInteraction={args => ToolBarService.recordInteraction(args)}
servicesManager={servicesManager}
/>
);
})}
</>
);
}

View File

@ -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;

View File

@ -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 (
<Component
key={id}
id={id}
{...componentProps}
bState={buttonState}
isActive={isActive}
onInteraction={args => 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({
</ErrorBoundary>
</Header>
<div
className="bg-black flex flex-row items-stretch w-full overflow-hidden flex-nowrap"
className="bg-black flex flex-row items-stretch w-full overflow-hidden flex-nowrap relative"
style={{ height: 'calc(100vh - 52px' }}
>
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel">
<SidePanel
side="left"
defaultComponentOpen={
leftPanelDefaultClosed ? null : leftPanelComponents[0].name
}
childComponents={leftPanelComponents}
/>
</ErrorBoundary>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
<React.Fragment>
{showLoadingIndicator && (
<LoadingIndicatorProgress className="h-full w-full bg-black" />
)}
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel">
<SidePanel
side="left"
activeTabIndex={leftPanelDefaultClosed ? null : 0}
tabs={leftPanelComponents}
/>
</ErrorBoundary>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black relative">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</ErrorBoundary>
</div>
</div>
</div>
{rightPanelComponents.length ? (
<ErrorBoundary context="Right Panel">
<SidePanel
side="right"
defaultComponentOpen={
rightPanelDefaultClosed ? null : rightPanelComponents[0].name
}
childComponents={rightPanelComponents}
/>
</ErrorBoundary>
) : null}
{rightPanelComponents.length ? (
<ErrorBoundary context="Right Panel">
<SidePanel
side="right"
activeTabIndex={rightPanelDefaultClosed ? null : 0}
tabs={rightPanelComponents}
/>
</ErrorBoundary>
) : null}
</React.Fragment>
</div>
</div>
);
@ -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;

View File

@ -34,9 +34,10 @@ function getPanelModule({
},
{
name: 'measure',
iconName: 'list-bullets',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurements',
secondaryLabel: 'Measurements',
component: wrappedMeasurementPanel,
},
];

View File

@ -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),

View File

@ -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,

View File

@ -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",

View File

@ -26,7 +26,7 @@ function getPanelModule({
},
{
name: 'trackedMeasurements',
iconName: 'list-bullets',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurements',
component: PanelMeasurementTableTracking.bind(null, {

View File

@ -251,7 +251,6 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
)}
<MeasurementTable
title="Measurements"
amount={displayMeasurementsWithoutFindings.length}
data={displayMeasurementsWithoutFindings}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
@ -259,7 +258,6 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
{additionalFindings.length !== 0 && (
<MeasurementTable
title="Additional Findings"
amount={additionalFindings.length}
data={additionalFindings}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { utils } from '@ohif/core';
import { HangingProtocolService, utils } from '@ohif/core';
import {
StudyBrowser,
useImageViewer,
@ -16,15 +16,20 @@ const { formatDate } = utils;
* @param {*} param0
*/
function PanelStudyBrowserTracking({
MeasurementService,
DisplaySetService,
UIDialogService,
UINotificationService,
servicesManager,
getImageSrc,
getStudiesForPatientByStudyInstanceUID,
requestDisplaySetCreationForStudy,
dataSource,
}) {
const {
MeasurementService,
DisplaySetService,
UIDialogService,
HangingProtocolService,
UINotificationService,
} = servicesManager.services;
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> 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

View File

@ -45,10 +45,7 @@ function WrappedPanelStudyBrowserTracking({
return (
<PanelStudyBrowserTracking
MeasurementService={servicesManager.services.MeasurementService}
DisplaySetService={servicesManager.services.DisplaySetService}
UIDialogService={servicesManager.services.UIDialogService}
UINotificationService={servicesManager.services.UINotificationService}
servicesManager={servicesManager}
dataSource={dataSource}
getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByStudyInstanceUID={

View File

@ -8,8 +8,12 @@ import {
useCine,
useViewportGrid,
useViewportDialog,
Tooltip,
Icon,
} from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import { eventTarget, Enums } from '@cornerstonejs/core';
import { annotation } from '@cornerstonejs/tools';
import { useTrackedMeasurements } from './../getContextModule';
@ -27,6 +31,8 @@ function TrackedCornerstoneViewport(props) {
commandsManager,
} = props;
const { t } = useTranslation('TrackedViewport');
const {
MeasurementService,
CornerstoneViewportService,
@ -137,9 +143,11 @@ function TrackedCornerstoneViewport(props) {
cineHandler();
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 (
<div className="relative">
<Tooltip
position="bottom-left"
content={
<div className="flex py-2">
<div className="flex pt-1">
<Icon name="info-link" className="w-4 text-primary-main" />
</div>
<div className="flex ml-4">
<span className="text-base text-common-light">
{isTracked ? (
<>
Series is
<span className="font-bold text-white"> tracked</span> and
can be viewed <br /> in the measurement panel
</>
) : (
<>
Measurements for
<span className="font-bold text-white"> untracked </span>
series <br /> will not be shown in the <br /> measurements
panel
</>
)}
</span>
</div>
</div>
}
>
<Icon name={trackedIcon} className="w-6 text-primary-light" />
</Tooltip>
</div>
);
}
export default TrackedCornerstoneViewport;

View File

@ -143,7 +143,6 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) {
ptDisplaySet.displaySetInstanceUID
);
}
return (
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
{

View File

@ -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 (
<div className="flex flex-col justify-between h-full">
<div className="flex flex-col">
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
<div className="flex mx-4 my-4 mb-4 space-x-4">
<Button
@ -203,17 +185,10 @@ export default function PanelRoiThresholdSegmentation({
});
});
}}
className={classnames(
'text-xs text-white border-b border-transparent'
)}
>
{labelmapLoading ? 'loading ...' : 'New Label'}
</Button>
<Button
color="primary"
onClick={handleROIThresholding}
className="text-xs text-white border-b border-transparent "
>
<Button color="primary" onClick={handleROIThresholding}>
Run
</Button>
</div>
@ -239,7 +214,6 @@ export default function PanelRoiThresholdSegmentation({
{segmentations?.length ? (
<SegmentationTable
title={t('Segmentations')}
amount={segmentations.length}
segmentations={segmentations}
activeSegmentationId={selectedSegmentationId}
onClick={id => {
@ -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({
/>
</div>
<div
className="opacity-50 hover:opacity-80 flex items-center justify-center text-blue-400 mb-4 cursor-pointer"
className="opacity-50 hover:opacity-80 flex items-center justify-center text-blue-400 mt-auto cursor-pointer mb-4"
onClick={() => {
// 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,

View File

@ -20,7 +20,7 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
<Select
label={t('Strategy')}
closeMenuOnSelect={true}
className="mr-2 bg-black border-primary-main "
className="mr-2 bg-black border-primary-main text-white "
options={options}
placeholder={
options.find(option => option.value === config.strategy)

View File

@ -10,7 +10,6 @@ function segmentationItemEditHandler({ id, servicesManager }) {
switch (action.id) {
case 'save': {
SegmentationService.addOrUpdateSegmentation(
id,
{
...segmentation,
...value,

View File

@ -12,6 +12,7 @@ import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructure
const metadataProvider = classes.MetadataProvider;
const RECTANGLE_ROI_THRESHOLD_MANUAL = 'RectangleROIStartEndThreshold';
const LABELMAP = csTools.Enums.SegmentationRepresentations.Labelmap;
const commandsModule = ({
servicesManager,
@ -25,6 +26,7 @@ const commandsModule = ({
HangingProtocolService,
ToolGroupService,
CornerstoneViewportService,
SegmentationService,
} = servicesManager.services;
const utilityModule = extensionManager.getModuleEntry(
@ -43,7 +45,8 @@ const commandsModule = ({
function _getMatchedViewportsToolGroupIds() {
const { viewportMatchDetails } = HangingProtocolService.getMatchDetails();
const toolGroupIds = [];
viewportMatchDetails.forEach(({ viewportOptions }) => {
viewportMatchDetails.forEach((value, key) => {
const { viewportOptions } = value;
const { toolGroupId } = viewportOptions;
if (toolGroupIds.indexOf(toolGroupId) === -1) {
toolGroupIds.push(toolGroupId);
@ -61,8 +64,8 @@ const commandsModule = ({
// corrected PT vs the non-attenuation correct PT)
let ptDisplaySet = null;
for (const matched of viewportMatchDetails) {
const { displaySetsInfo } = matched;
for (const [viewportIndex, viewportDetails] of viewportMatchDetails) {
const { displaySetsInfo } = viewportDetails;
const displaySets = displaySetsInfo.map(({ displaySetInstanceUID }) =>
DisplaySetService.getDisplaySetByUID(displaySetInstanceUID)
);
@ -128,22 +131,27 @@ const commandsModule = ({
return;
}
const segmentationId = await commandsManager.runCommand(
'createSegmentationForDisplaySet',
{
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
}
const segmentationId = await SegmentationService.createSegmentationForDisplaySet(
ptDisplaySet.displaySetInstanceUID
);
// Add Segmentation to all toolGroupIds in the viewer
const toolGroupIds = _getMatchedViewportsToolGroupIds();
const representationType =
csTools.Enums.SegmentationRepresentations.Labelmap;
const representationType = LABELMAP;
for (const toolGroupId of toolGroupIds) {
await commandsManager.runCommand(
'addSegmentationRepresentationToToolGroup',
{ segmentationId, toolGroupId: toolGroupId, representationType }
const hydrateSegmentation = true;
await SegmentationService.addSegmentationRepresentationToToolGroup(
toolGroupId,
segmentationId,
hydrateSegmentation,
representationType
);
SegmentationService.setActiveSegmentationForToolGroup(
segmentationId,
toolGroupId
);
}
@ -153,24 +161,10 @@ const commandsModule = ({
const toolGroupIds = _getMatchedViewportsToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations(
SegmentationService.setActiveSegmentationForToolGroup(
segmentationId,
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
);
csTools.segmentation.activeSegmentation.setActiveSegmentationRepresentation(
toolGroupId,
representation.segmentationRepresentationUID
);
});
},
thresholdSegmentationByRectangleROITool: ({ segmentationId, config }) => {
@ -179,9 +173,7 @@ const commandsModule = ({
);
const { representationData } = segmentation;
const { volumeId: segVolumeId } = representationData[
csTools.Enums.SegmentationRepresentations.Labelmap
];
const { volumeId: segVolumeId } = representationData[LABELMAP];
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
@ -228,36 +220,6 @@ const commandsModule = ({
configToUse
);
},
toggleSegmentationVisibility: ({ segmentationId }) => {
const toolGroupIds = _getMatchedViewportsToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const segmentationRepresentations = csTools.segmentation.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 = csTools.segmentation.config.visibility.getSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID
);
csTools.segmentation.config.visibility.setSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID,
!visibility
);
});
},
calculateSuvPeak: ({ labelmap }) => {
const { referencedVolumeId } = labelmap;
@ -326,9 +288,9 @@ const commandsModule = ({
};
},
calculateTMTV: ({ segmentations }) => {
const labelmaps = commandsManager.runCommand('getLabelmapVolumes', {
segmentations,
});
const labelmaps = segmentations.map(s =>
SegmentationService.getLabelmapVolume(s.id)
);
if (!labelmaps.length) {
return;
@ -351,9 +313,9 @@ const commandsModule = ({
createAndDownloadTMTVReport(segReport, additionalReportRows);
},
getTotalLesionGlycolysis: ({ segmentations }) => {
const labelmapVolumes = commandsManager.runCommand('getLabelmapVolumes', {
segmentations,
});
const labelmapVolumes = segmentations.map(s =>
SegmentationService.getLabelmapVolume(s.id)
);
let mergedLabelmap;
// merge labelmap will through an error if labels maps are not the same size
@ -408,7 +370,6 @@ const commandsModule = ({
1e-3
);
},
setStartSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const { focalPoint, viewPlaneNormal } = viewport.getCamera();
@ -493,7 +454,7 @@ const commandsModule = ({
let report = {};
for (const segmentation of segmentations) {
const { id, label, data } = segmentation;
const { id, label, cachedStats: data } = segmentation;
const segReport = { id, label };
@ -513,7 +474,7 @@ const commandsModule = ({
}
});
const labelmapVolume = cornerstone.cache.getVolume(id);
const labelmapVolume = SegmentationService.getLabelmapVolume(id);
if (!labelmapVolume) {
report[id] = segReport;
@ -523,7 +484,7 @@ const commandsModule = ({
const referencedVolumeId = labelmapVolume.referencedVolumeId;
segReport.referencedVolumeId = referencedVolumeId;
const referencedVolume = cornerstone.cache.getVolume(
const referencedVolume = SegmentationService.getLabelmapVolume(
referencedVolumeId
);
@ -637,11 +598,6 @@ const commandsModule = ({
storeContexts: [],
options: {},
},
toggleSegmentationVisibility: {
commandFn: actions.toggleSegmentationVisibility,
storeContexts: [],
options: {},
},
getTotalLesionGlycolysis: {
commandFn: actions.getTotalLesionGlycolysis,
storeContexts: [],

View File

@ -7,12 +7,6 @@ const ptCT = {
modifiedDate: '2022-10-04T19:22:08.894Z',
availableTo: {},
editableBy: {},
toolGroupIds: [
'ctToolGroup',
'ptToolGroup',
'fusionToolGroup',
'mipToolGroup',
],
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
@ -47,6 +41,16 @@ const ptCT = {
},
required: true,
},
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'SeriesDescription',
constraint: {
@ -70,6 +74,16 @@ const ptCT = {
},
required: true,
},
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'SeriesDescription',
constraint: {

View File

@ -34,14 +34,14 @@ function getPanelModule({
return [
{
name: 'petSUV',
iconName: 'edit-patient',
iconName: 'tab-patient-info',
iconLabel: 'PET SUV',
label: 'PET SUV',
component: wrappedPanelPetSuv,
},
{
name: 'ROIThresholdSeg',
iconName: 'tool-create-threshold',
iconName: 'tab-roi-threshold',
iconLabel: 'ROI Threshold',
label: 'ROI Threshold',
component: wrappedROIThresholdSeg,

View File

@ -10,6 +10,7 @@ const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG'];
const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
};
const tracked = {
@ -37,12 +38,20 @@ const dicompdf = {
viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf',
};
const dicomSeg = {
sopClassHandler:
'@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg',
viewport: '@ohif/extension-cornerstone-dicom-seg.viewportModule.dicom-seg',
panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation',
};
const extensionDependencies = {
// Can derive the versions at least process.env.from npm_package_version
'@ohif/extension-default': '^3.0.0',
'@ohif/extension-cornerstone': '^3.0.0',
'@ohif/extension-measurement-tracking': '^3.0.0',
'@ohif/extension-cornerstone-dicom-sr': '^3.0.0',
'@ohif/extension-cornerstone-dicom-seg': '^3.0.0',
'@ohif/extension-dicom-pdf': '^3.0.1',
'@ohif/extension-dicom-video': '^3.0.1',
};
@ -102,7 +111,8 @@ function modeFactory() {
'Pan',
'Capture',
'Layout',
// 'MPR',
'MPR',
'Crosshairs',
'MoreTools',
]);
},
@ -112,12 +122,18 @@ function modeFactory() {
SyncGroupService,
MeasurementService,
ToolBarService,
SegmentationService,
CornerstoneViewportService,
HangingProtocolService,
} = servicesManager.services;
ToolBarService.reset();
MeasurementService.clearMeasurements();
ToolGroupService.destroy();
SyncGroupService.destroy();
SegmentationService.destroy();
CornerstoneViewportService.destroy();
HangingProtocolService.reset();
},
validationTags: {
study: [],
@ -143,8 +159,8 @@ function modeFactory() {
id: ohif.layout,
props: {
leftPanels: [tracked.thumbnailList],
// TODO: Should be optional, or required to pass empty array for slots?
rightPanels: [tracked.measurements],
rightPanels: [dicomSeg.panel, tracked.measurements],
// rightPanelDefaultClosed: true, // optional prop to start with collapse panels
viewports: [
{
namespace: tracked.viewport,
@ -162,6 +178,10 @@ function modeFactory() {
namespace: dicompdf.viewport,
displaySetsToDisplay: [dicompdf.sopClassHandler],
},
{
namespace: dicomSeg.viewport,
displaySetsToDisplay: [dicomSeg.sopClassHandler],
},
],
},
};
@ -177,6 +197,7 @@ function modeFactory() {
// come first to remove video transfer syntax before ohif uses images
sopClassHandlers: [
dicomvideo.sopClassHandler,
dicomSeg.sopClassHandler,
ohif.sopClassHandler,
dicompdf.sopClassHandler,
dicomsr.sopClassHandler,

View File

@ -36,9 +36,11 @@ function initDefaultToolGroup(
{ toolName: toolNames.StackScroll },
{ toolName: toolNames.Angle },
{ toolName: toolNames.Magnify },
{ toolName: toolNames.SegmentationDisplay },
],
// enabled
// disabled
disabled: [{ toolName: toolNames.ReferenceLines }],
};
const toolsConfig = {
@ -139,6 +141,73 @@ function initSRToolGroup(extensionManager, ToolGroupService, commandsManager) {
ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig);
}
function initMPRToolGroup(extensionManager, ToolGroupService, commandsManager) {
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: [] },
],
passive: [
{ toolName: toolNames.Length },
{ toolName: toolNames.ArrowAnnotate },
{ toolName: toolNames.Bidirectional },
{ toolName: toolNames.DragProbe },
{ toolName: toolNames.EllipticalROI },
{ toolName: toolNames.RectangleROI },
{ toolName: toolNames.StackScroll },
{ toolName: toolNames.Angle },
{ toolName: toolNames.SegmentationDisplay },
],
disabled: [{ toolName: toolNames.Crosshairs }],
// enabled
// disabled
};
const toolsConfig = {
[toolNames.Crosshairs]: {
viewportIndicators: false,
autoPan: {
enabled: false,
panSize: 10,
},
},
[toolNames.ArrowAnnotate]: {
getTextCallback: (callback, eventDetails) =>
commandsManager.runCommand('arrowTextCallback', {
callback,
eventDetails,
}),
changeTextCallback: (data, eventDetails, callback) =>
commandsManager.runCommand('arrowTextCallback', {
callback,
data,
eventDetails,
}),
},
};
ToolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig);
}
function initToolGroups(extensionManager, ToolGroupService, commandsManager) {
initDefaultToolGroup(
extensionManager,
@ -147,12 +216,7 @@ function initToolGroups(extensionManager, ToolGroupService, commandsManager) {
'default'
);
initSRToolGroup(extensionManager, ToolGroupService, commandsManager);
initDefaultToolGroup(
extensionManager,
ToolGroupService,
commandsManager,
'mpr'
);
initMPRToolGroup(extensionManager, ToolGroupService, commandsManager);
}
export default initToolGroups;

View File

@ -26,6 +26,18 @@ function _createButton(type, id, icon, label, commands, tooltip) {
};
}
function _createCommands(commandName, toolName, toolGroupIds) {
return toolGroupIds.map(toolGroupId => ({
/* It's a command that is being run when the button is clicked. */
commandName,
commandOptions: {
toolName,
toolGroupId,
},
context: 'CORNERSTONE',
}));
}
const _createActionButton = _createButton.bind(null, 'action');
const _createToggleButton = _createButton.bind(null, 'toggle');
const _createToolButton = _createButton.bind(null, 'tool');
@ -290,23 +302,41 @@ const toolbarButtons = [
columns: 3,
},
},
// Todo: MPR not ready yet for SEG support, not activating it now
// {
// id: 'MPR',
// type: 'ohif.action',
// props: {
// icon: 'old-play',
// label: 'MPR',
// type: 'action',
// commands: [
// {
// commandName: 'setHangingProtocol',
// commandOptions: { protocolId: 'mpr' },
// context: 'CORNERSTONE',
// },
// ],
// },
// },
{
id: 'MPR',
type: 'ohif.action',
props: {
type: 'toggle',
icon: 'icon-mpr',
label: 'MPR',
commands: [
{
commandName: 'toggleMPR',
commandOptions: {},
context: 'CORNERSTONE',
},
],
},
},
{
id: 'Crosshairs',
type: 'ohif.radioGroup',
props: {
type: 'tool',
icon: 'tool-crosshair',
label: 'Crosshairs',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolGroupId: 'mpr',
toolName: 'Crosshairs',
},
context: 'CORNERSTONE',
},
],
},
},
// More...
{
id: 'MoreTools',
@ -373,6 +403,25 @@ const toolbarButtons = [
],
'Flip Horizontal'
),
_createToggleButton('StackImageSync', 'link', 'Stack Image Sync', [
{
commandName: 'toggleStackImageSync',
commandOptions: {},
context: 'CORNERSTONE',
},
]),
_createToggleButton(
'ReferenceLines',
'tool-referenceLines', // change this with the new icon
'Reference Lines',
[
{
commandName: 'toggleReferenceLines',
commandOptions: {},
context: 'CORNERSTONE',
},
]
),
_createToolButton(
'StackScroll',
'tool-stack-scroll',

View File

@ -140,6 +140,9 @@ function modeFactory({ modeConfiguration }) {
SyncGroupService,
MeasurementService,
ToolBarService,
SegmentationService,
CornerstoneViewportService,
HangingProtocolService,
} = servicesManager.services;
unsubscriptions.forEach(unsubscribe => unsubscribe());
@ -147,6 +150,9 @@ function modeFactory({ modeConfiguration }) {
MeasurementService.clearMeasurements();
ToolGroupService.destroy();
SyncGroupService.destroy();
SegmentationService.destroy();
CornerstoneViewportService.destroy();
HangingProtocolService.reset();
},
validationTags: {
study: [],
@ -173,8 +179,8 @@ function modeFactory({ modeConfiguration }) {
return {
id: ohif.layout,
props: {
leftPanels: [],
rightPanels: [tmtv.petSUV, tmtv.ROIThresholdPanel],
// leftPanels: [ohif.thumbnailList],
rightPanels: [tmtv.ROIThresholdPanel, tmtv.petSUV],
viewports: [
{
namespace: cs3d.viewport,

View File

@ -28,6 +28,7 @@ function initToolGroups(toolNames, Enums, ToolGroupService, commandsManager) {
{ toolName: toolNames.ArrowAnnotate },
{ toolName: toolNames.Bidirectional },
{ toolName: toolNames.DragProbe },
{ toolName: toolNames.Probe },
{ toolName: toolNames.EllipticalROI },
{ toolName: toolNames.RectangleROI },
{ toolName: toolNames.StackScroll },

View File

@ -228,11 +228,11 @@ const toolbarButtons = [
id: 'Crosshairs',
type: 'ohif.radioGroup',
props: {
type: 'toggle',
type: 'tool',
icon: 'tool-crosshair',
label: 'Crosshairs',
commands: [
..._createCommands('toggleCrosshairs', 'Crosshairs', [
..._createCommands('setToolActive', 'Crosshairs', [
toolGroupIds.CT,
toolGroupIds.PT,
toolGroupIds.Fusion,
@ -272,7 +272,8 @@ const toolbarButtons = [
commandName: 'displayNotification',
commandOptions: {
title: 'RectangleROI Threshold Tip',
text: 'RectangleROI Threshold tool should be used on PT Axial Viewport',
text:
'RectangleROI Threshold tool should be used on PT Axial Viewport',
type: 'info',
},
},

View File

@ -56,7 +56,7 @@
},
"dependencies": {
"@babel/runtime": "7.16.3",
"@kitware/vtk.js": "^24.18.7",
"@kitware/vtk.js": "25.9.0",
"core-js": "^3.2.1"
},
"peerDependencies": {

View File

@ -45,6 +45,7 @@
"moment": "^2.24.0",
"query-string": "^6.14.0",
"object-hash": "2.1.1",
"gl-matrix": "^3.4.3",
"validate.js": "^0.12.0"
},
"devDependencies": {

View File

@ -33,7 +33,6 @@ describe('Top level exports', () => {
'ToolBarService',
'Types',
'ViewportGridService',
'SegmentationService',
'HangingProtocolService',
'UserAuthenticationService',
'IWebApiDataSource',

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