Merge pull request #1 from OHIF/v3-stable

V3 stable
This commit is contained in:
Salim Kanoun 2022-11-24 17:01:48 +01:00 committed by GitHub
commit d5c347cecd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
404 changed files with 17968 additions and 5891 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 { server {
listen 80; listen ${PORT};
# listen 3000;
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; 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" ] if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
then 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

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ screenshots/
# autogenerated files # autogenerated files
platform/viewer/src/pluginImports.js platform/viewer/src/pluginImports.js
/Viewers.iml
platform/viewer/.recipes/Nginx-Dcm4Che/dcm4che/dcm4che-arc/*
platform/viewer/.recipes/OpenResty-Orthanc/logs/*

View File

@ -21,24 +21,23 @@
# Stage 1: Build the application # Stage 1: Build the application
# docker build -t ohif/viewer:latest . # docker build -t ohif/viewer:latest .
FROM node:14.3.0-slim as json-copier FROM node:16.15.0-slim as json-copier
RUN mkdir /usr/src/app RUN mkdir /usr/src/app
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ["package.json", "yarn.lock", "preinstall.js", "./"]
COPY ["package.json", "yarn.lock", "./"]
COPY extensions /usr/src/app/extensions COPY extensions /usr/src/app/extensions
COPY modes /usr/src/app/modes COPY modes /usr/src/app/modes
COPY platform /usr/src/app/platform COPY platform /usr/src/app/platform
# Find and remove non-package.json files # Find and remove non-package.json files
RUN find extensions \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf #RUN find extensions \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
RUN find modes \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf #RUN find modes \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
RUN find platform \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf #RUN find platform \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
# Copy Files # Copy Files
FROM node:14.3.0-slim as builder FROM node:16.15.0-slim as builder
RUN mkdir /usr/src/app RUN mkdir /usr/src/app
WORKDIR /usr/src/app WORKDIR /usr/src/app
@ -46,12 +45,12 @@ COPY --from=json-copier /usr/src/app .
# Run the install before copying the rest of the files # Run the install before copying the rest of the files
RUN yarn config set workspaces-experimental true RUN yarn config set workspaces-experimental true
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile --verbose
COPY . . COPY . .
# To restore workspaces symlinks # To restore workspaces symlinks
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile --verbose
ENV PATH /usr/src/app/node_modules/.bin:$PATH ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true ENV QUICK_BUILD true
@ -62,14 +61,14 @@ RUN yarn run build
# Stage 3: Bundle the built application into a Docker container # Stage 3: Bundle the built application into a Docker container
# which runs Nginx using Alpine Linux # which runs Nginx using Alpine Linux
FROM nginx:1.15.5-alpine FROM nginxinc/nginx-unprivileged:1.23.1-alpine as final
RUN apk add --no-cache bash #RUN apk add --no-cache bash
RUN rm -rf /etc/nginx/conf.d ENV PORT=3000
COPY .docker/Viewer-v2.x /etc/nginx/conf.d RUN rm /etc/nginx/conf.d/default.conf
COPY .docker/Viewer-v2.x/entrypoint.sh /usr/src/ 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 RUN chmod 777 /usr/src/entrypoint.sh
COPY --from=builder /usr/src/app/platform/viewer/dist /usr/share/nginx/html COPY --from=builder /usr/src/app/platform/viewer/dist /usr/share/nginx/html
EXPOSE 80
EXPOSE 443
ENTRYPOINT ["/usr/src/entrypoint.sh"] ENTRYPOINT ["/usr/src/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -8,8 +8,10 @@ module.exports = {
], ],
plugins: [ plugins: [
'inline-react-svg', 'inline-react-svg',
'@babel/plugin-proposal-class-properties', ['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-transform-typescript', '@babel/plugin-transform-typescript',
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
], ],
env: { env: {
test: { test: {

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,434 @@
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,
UINotificationService,
} = 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,
viewportId: viewportOptions.viewportId,
}}
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);
}
if (evt.overlappingSegments) {
UINotificationService.show({
title: 'Overlapping Segments',
message:
'Overlapping segments detected which is not currently supported',
type: 'warning',
});
}
}
);
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": { "peerDependencies": {
"@ohif/core": "^3.0.0", "@ohif/core": "^3.0.0",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0",
"@ohif/ui": "^2.0.0", "@ohif/ui": "^2.0.0",
"dcmjs": "^0.24.5", "dcmjs": "^0.28.3",
"dicom-parser": "^1.8.9", "dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react": "^17.0.2", "react": "^17.0.2"
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.16.3", "@babel/runtime": "7.16.3",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"@cornerstonejs/core": "^0.14.4", "@cornerstonejs/core": "^0.21.5",
"@cornerstonejs/tools": "^0.21.7" "@cornerstonejs/tools": "^0.29.8"
} }
} }

View File

@ -6,7 +6,7 @@ import { adapters } from 'dcmjs';
const { CodeScheme: Cornerstone3DCodeScheme } = adapters.Cornerstone3D; const { CodeScheme: Cornerstone3DCodeScheme } = adapters.Cornerstone3D;
const { ImageSet } = classes; const { ImageSet, MetadataProvider: metadataProvider } = classes;
// TODO -> // TODO ->
// Add SR thumbnail // Add SR thumbnail
// Make viewport // Make viewport
@ -16,6 +16,7 @@ const sopClassUids = [
'1.2.840.10008.5.1.4.1.1.88.11', //BASIC_TEXT_SR: '1.2.840.10008.5.1.4.1.1.88.11', //BASIC_TEXT_SR:
'1.2.840.10008.5.1.4.1.1.88.22', //ENHANCED_SR: '1.2.840.10008.5.1.4.1.1.88.22', //ENHANCED_SR:
'1.2.840.10008.5.1.4.1.1.88.33', //COMPREHENSIVE_SR: '1.2.840.10008.5.1.4.1.1.88.33', //COMPREHENSIVE_SR:
'1.2.840.10008.5.1.4.1.1.88.34', //COMPREHENSIVE_3D_SR:
]; ];
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools'; const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
@ -44,6 +45,7 @@ const CodingSchemeDesignators = {
const RELATIONSHIP_TYPE = { const RELATIONSHIP_TYPE = {
INFERRED_FROM: 'INFERRED FROM', INFERRED_FROM: 'INFERRED FROM',
CONTAINS: 'CONTAINS',
}; };
const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT'; const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';
@ -77,6 +79,7 @@ function _getDisplaySetsFromSeries(
SeriesNumber, SeriesNumber,
SeriesDate, SeriesDate,
ConceptNameCodeSequence, ConceptNameCodeSequence,
SOPClassUID,
} = instance; } = instance;
if ( if (
@ -101,6 +104,7 @@ function _getDisplaySetsFromSeries(
SeriesInstanceUID, SeriesInstanceUID,
StudyInstanceUID, StudyInstanceUID,
SOPClassHandlerId, SOPClassHandlerId,
SOPClassUID,
referencedImages: null, referencedImages: null,
measurements: null, measurements: null,
isDerivedDisplaySet: true, isDerivedDisplaySet: true,
@ -212,20 +216,26 @@ function _checkIfCanAddMeasurementsToDisplaySet(
newDisplaySet newDisplaySet
); );
for (let i = 0; i < images.length; i++) { for (const imageId of imageIdsForDisplaySet) {
if (!unloadedMeasurements.length) { if (!unloadedMeasurements.length) {
// All measurements loaded. // All measurements loaded.
break; return;
} }
const image = images[i]; const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(
const { SOPInstanceUID } = image; imageId
if (SOPInstanceUIDs.includes(SOPInstanceUID)) { );
const imageId = imageIdsForDisplaySet[i];
if (SOPInstanceUIDs.includes(SOPInstanceUID)) {
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) { for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
const measurement = unloadedMeasurements[j]; const measurement = unloadedMeasurements[j];
if (_measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID)) { if (
_measurementReferencesSOPInstanceUID(
measurement,
SOPInstanceUID,
frameNumber
)
) {
addMeasurement( addMeasurement(
measurement, measurement,
imageId, imageId,
@ -239,9 +249,23 @@ function _checkIfCanAddMeasurementsToDisplaySet(
} }
} }
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID) { function _measurementReferencesSOPInstanceUID(
measurement,
SOPInstanceUID,
frameNumber
) {
const { coords } = measurement; const { coords } = measurement;
// NOTE: The ReferencedFrameNumber can be multiple values according to the DICOM
// Standard. But for now, we will support only one ReferenceFrameNumber.
const ReferencedFrameNumber =
(measurement.coords[0].ReferencedSOPSequence &&
measurement.coords[0].ReferencedSOPSequence[0]?.ReferencedFrameNumber) ||
1;
if (frameNumber && Number(frameNumber) !== Number(ReferencedFrameNumber))
return false;
for (let j = 0; j < coords.length; j++) { for (let j = 0; j < coords.length; j++) {
const coord = coords[j]; const coord = coords[j];
const { ReferencedSOPInstanceUID } = coord.ReferencedSOPSequence; const { ReferencedSOPInstanceUID } = coord.ReferencedSOPSequence;
@ -376,7 +400,6 @@ function _processMeasurement(mergedContentSequence) {
function _processTID1410Measurement(mergedContentSequence) { function _processTID1410Measurement(mergedContentSequence) {
// Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level, // Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level,
// And non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D // And non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D
// TODO -> Look at RelationshipType => Contains means
const graphicItem = mergedContentSequence.find( const graphicItem = mergedContentSequence.find(
group => group.ValueType === 'SCOORD' group => group.ValueType === 'SCOORD'
@ -536,9 +559,14 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
function _getCoordsFromSCOORDOrSCOORD3D(item) { function _getCoordsFromSCOORDOrSCOORD3D(item) {
const { ValueType, RelationshipType, GraphicType, GraphicData } = item; const { ValueType, RelationshipType, GraphicType, GraphicData } = item;
if (RelationshipType !== RELATIONSHIP_TYPE.INFERRED_FROM) { if (
!(
RelationshipType == RELATIONSHIP_TYPE.INFERRED_FROM ||
RelationshipType == RELATIONSHIP_TYPE.CONTAINS
)
) {
console.warn( console.warn(
`Relationshiptype === ${RelationshipType}. Cannot deal with NON TID-1400 SCOORD group with RelationshipType !== "INFERRED FROM."` `Relationshiptype === ${RelationshipType}. Cannot deal with NON TID-1400 SCOORD group with RelationshipType !== "INFERRED FROM" or "CONTAINS"`
); );
return; return;
@ -597,12 +625,18 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => { _getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item; const { ReferencedSOPSequence } = item;
if (item.hasOwnProperty('ReferencedSOPClassUID')) {
const { const {
ReferencedSOPClassUID, ReferencedSOPClassUID,
ReferencedSOPInstanceUID, ReferencedSOPInstanceUID,
} = ReferencedSOPSequence; } = ReferencedSOPSequence;
referencedImages.push({ ReferencedSOPClassUID, ReferencedSOPInstanceUID }); referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
}
}); });
return referencedImages; return referencedImages;

View File

@ -99,7 +99,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
const annotation = filteredAnnotations[i]; const annotation = filteredAnnotations[i];
const annotationUID = annotation.annotationUID; const annotationUID = annotation.annotationUID;
const { renderableData } = annotation.data.cachedStats; const { renderableData } = annotation.data.cachedStats;
const { label, cachedStats } = annotation.data; const { cachedStats } = annotation.data;
const { referencedImageId } = annotation.metadata; const { referencedImageId } = annotation.metadata;
styleSpecifier.annotationUID = annotationUID; styleSpecifier.annotationUID = annotationUID;
@ -121,22 +121,30 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
const renderableDataForGraphicType = renderableData[GraphicType]; const renderableDataForGraphicType = renderableData[GraphicType];
let renderMethod; let renderMethod;
let renderTextBox;
let canvasCoordinatesAdapter;
switch (GraphicType) { switch (GraphicType) {
case SCOORD_TYPES.POINT: case SCOORD_TYPES.POINT:
renderMethod = this.renderPoint; renderMethod = this.renderPoint;
renderTextBox = this.renderTextBox;
break; break;
case SCOORD_TYPES.MULTIPOINT: case SCOORD_TYPES.MULTIPOINT:
renderMethod = this.renderMultipoint; renderMethod = this.renderMultipoint;
renderTextBox = this.renderTextBox;
break; break;
case SCOORD_TYPES.POLYLINE: case SCOORD_TYPES.POLYLINE:
renderMethod = this.renderPolyLine; renderMethod = this.renderPolyLine;
break; break;
case SCOORD_TYPES.CIRCLE: case SCOORD_TYPES.CIRCLE:
renderMethod = this.renderEllipse; renderMethod = this.renderEllipse;
renderTextBox = this.renderTextBox;
break; break;
case SCOORD_TYPES.ELLIPSE: case SCOORD_TYPES.ELLIPSE:
renderMethod = this.renderEllipse; renderMethod = this.renderEllipse;
renderTextBox = this.renderTextBox;
canvasCoordinatesAdapter =
utilities.math.ellipse.getCanvasEllipseCorners;
break; break;
default: default:
throw new Error(`Unsupported GraphicType: ${GraphicType}`); throw new Error(`Unsupported GraphicType: ${GraphicType}`);
@ -151,60 +159,18 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
options options
); );
if (!canvasCoordinates) { if (typeof renderTextBox === 'function') {
return; renderTextBox.call(
} this,
const textLines = this._getTextBoxLinesFromLabels(label);
let canvasCornersToUseForTextBox = canvasCoordinates;
if (GraphicType === SCOORD_TYPES.ELLIPSE) {
canvasCornersToUseForTextBox = utilities.math.ellipse.getCanvasEllipseCorners(
canvasCoordinates
);
}
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(
canvasCornersToUseForTextBox
);
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(
canvasTextBoxCoords
);
const textBoxPosition = viewport.worldToCanvas(
annotation.data.handles.textBox.worldPosition
);
const textBoxUID = '1';
const textBoxOptions = this.getLinkedTextBoxStyle(
styleSpecifier,
annotation
);
const boundingBox = drawing.drawLinkedTextBox(
svgDrawingHelper, svgDrawingHelper,
annotationUID, viewport,
textBoxUID,
textLines,
textBoxPosition,
canvasCoordinates, canvasCoordinates,
{}, canvasCoordinatesAdapter,
{ annotation,
...textBoxOptions, styleSpecifier,
color, options
}
); );
}
const { x: left, y: top, width, height } = boundingBox;
annotation.data.handles.textBox.worldBoundingBox = {
topLeft: viewport.canvasToWorld([left, top]),
topRight: viewport.canvasToWorld([left + width, top]),
bottomLeft: viewport.canvasToWorld([left, top + height]),
bottomRight: viewport.canvasToWorld([left + width, top + height]),
};
}); });
} }
}; };
@ -217,28 +183,32 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
referencedImageId, referencedImageId,
options options
) { ) {
// Todo: this needs to use the drawPolyLine from cs3D since it is implemented const drawingOptions = {
// now, before it was implemented with a loop over drawLine which is hacky color: options.color,
width: options.lineWidth,
};
let canvasCoordinates; let canvasCoordinates;
renderableData.map((data, index) => { renderableData.map((data, index) => {
canvasCoordinates = data.map(p => viewport.worldToCanvas(p)); canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
if (canvasCoordinates.length === 2) {
const lineUID = `${index}`; const lineUID = `${index}`;
if (canvasCoordinates.length === 2) {
drawing.drawLine( drawing.drawLine(
svgDrawingHelper, svgDrawingHelper,
annotationUID, annotationUID,
lineUID, lineUID,
canvasCoordinates[0], canvasCoordinates[0],
canvasCoordinates[1], canvasCoordinates[1],
{ drawingOptions
color: options.color,
width: options.lineWidth,
}
); );
} else { } else {
throw new Error('Drawing polyline for SR not yet implemented'); drawing.drawPolyline(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCoordinates,
drawingOptions
);
} }
}); });
@ -367,6 +337,71 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
return canvasCoordinates; return canvasCoordinates;
} }
renderTextBox(
svgDrawingHelper,
viewport,
canvasCoordinates,
canvasCoordinatesAdapter,
annotation,
styleSpecifier,
options = {}
) {
if (!canvasCoordinates || !annotation) {
return;
}
const { annotationUID, data = {} } = annotation;
const { label } = data;
const { color } = options;
let adaptedCanvasCoordinates = canvasCoordinates;
// adapt coordinates if there is an adapter
if (typeof canvasCoordinatesAdapter === 'function') {
adaptedCanvasCoordinates = canvasCoordinatesAdapter(canvasCoordinates);
}
const textLines = this._getTextBoxLinesFromLabels(label);
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(
adaptedCanvasCoordinates
);
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(
canvasTextBoxCoords
);
const textBoxPosition = viewport.worldToCanvas(
annotation.data.handles.textBox.worldPosition
);
const textBoxUID = '1';
const textBoxOptions = this.getLinkedTextBoxStyle(
styleSpecifier,
annotation
);
const boundingBox = drawing.drawLinkedTextBox(
svgDrawingHelper,
annotationUID,
textBoxUID,
textLines,
textBoxPosition,
canvasCoordinates,
{},
{
...textBoxOptions,
color,
}
);
const { x: left, y: top, width, height } = boundingBox;
annotation.data.handles.textBox.worldBoundingBox = {
topLeft: viewport.canvasToWorld([left, top]),
topRight: viewport.canvasToWorld([left + width, top]),
bottomLeft: viewport.canvasToWorld([left, top + height]),
bottomRight: viewport.canvasToWorld([left + width, top + height]),
};
}
} }
const SHORT_HAND_MAP = { const SHORT_HAND_MAP = {

View File

@ -45,6 +45,12 @@ export default function addMeasurement(
const annotationManager = annotation.state.getDefaultAnnotationManager(); const annotationManager = annotation.state.getDefaultAnnotationManager();
// Create Cornerstone3D Annotation from measurement
const frameNumber =
(measurement.coords[0].ReferencedSOPSequence &&
measurement.coords[0].ReferencedSOPSequence[0]?.ReferencedFrameNumber) ||
1;
const SRAnnotation: Types.Annotation = { const SRAnnotation: Types.Annotation = {
annotationUID: measurement.TrackingUniqueIdentifier, annotationUID: measurement.TrackingUniqueIdentifier,
metadata: { metadata: {
@ -61,6 +67,7 @@ export default function addMeasurement(
TrackingUniqueIdentifier: measurementData.TrackingUniqueIdentifier, TrackingUniqueIdentifier: measurementData.TrackingUniqueIdentifier,
renderableData: measurementData.renderableData, renderableData: measurementData.renderableData,
}, },
frameNumber: frameNumber,
}, },
}; };
@ -75,6 +82,7 @@ export default function addMeasurement(
// It'd be super weird if it didn't anyway as a SCOORD. // It'd be super weird if it didn't anyway as a SCOORD.
measurement.ReferencedSOPInstanceUID = measurement.ReferencedSOPInstanceUID =
measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID; measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID;
measurement.frameNumber = frameNumber;
delete measurement.coords; delete measurement.coords;
} }

View File

@ -1,13 +1,18 @@
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import OHIF, { utils } from '@ohif/core'; import OHIF, { utils } from '@ohif/core';
import { useTranslation } from 'react-i18next';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule'; import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import { import {
Notification, Notification,
ViewportActionBar, ViewportActionBar,
useViewportGrid, useViewportGrid,
useViewportDialog, useViewportDialog,
Tooltip,
Icon,
} from '@ohif/ui'; } from '@ohif/ui';
import classNames from 'classnames';
const { formatDate } = utils; const { formatDate } = utils;
@ -27,6 +32,8 @@ function OHIFCornerstoneSRViewport(props) {
extensionManager, extensionManager,
} = props; } = props;
const { t } = useTranslation('SRViewport');
const { const {
DisplaySetService, DisplaySetService,
CornerstoneViewportService, CornerstoneViewportService,
@ -318,8 +325,6 @@ function OHIFCornerstoneSRViewport(props) {
}); });
} }
const { Modality } = srDisplaySet;
const { const {
PatientID, PatientID,
PatientName, PatientName,
@ -341,24 +346,23 @@ function OHIFCornerstoneSRViewport(props) {
evt.stopPropagation(); evt.stopPropagation();
evt.preventDefault(); evt.preventDefault();
}} }}
onPillClick={() => { onArrowsClick={onMeasurementChange}
sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', { getStatusComponent={() =>
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID, _getStatusComponent({
srDisplaySet,
viewportIndex, viewportIndex,
}); isTracked: false,
}} isRehydratable: srDisplaySet.isRehydratable,
onSeriesChange={onMeasurementChange} isLocked,
sendTrackedMeasurementsEvent,
})
}
studyData={{ studyData={{
label: viewportLabel, label: viewportLabel,
useAltStyling: true, useAltStyling: true,
isTracked: false,
isLocked,
isRehydratable: srDisplaySet.isRehydratable,
isHydrated,
studyDate: formatDate(StudyDate), studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber, currentSeries: SeriesNumber,
seriesDescription: SeriesDescription, seriesDescription: SeriesDescription,
modality: Modality,
patientInformation: { patientInformation: {
patientName: PatientName patientName: PatientName
? OHIF.utils.formatPN(PatientName.Alphabetic) ? OHIF.utils.formatPN(PatientName.Alphabetic)
@ -439,6 +443,139 @@ async function _getViewportReferencedDisplaySetData(
return { referencedDisplaySetMetadata, referencedDisplaySet }; 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() { // function _onDoubleClick() {
// const cancelActiveManipulatorsForElement = cornerstoneTools.getModule( // const cancelActiveManipulatorsForElement = cornerstoneTools.getModule(
// 'manipulatorState' // 'manipulatorState'

View File

@ -30,7 +30,7 @@
"@ohif/core": "^3.0.0", "@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0", "@ohif/ui": "^2.0.0",
"cornerstone-wado-image-loader": "^4.2.1", "cornerstone-wado-image-loader": "^4.2.1",
"dcmjs": "^0.24.5", "dcmjs": "^0.28.3",
"dicom-parser": "^1.8.9", "dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
@ -43,11 +43,11 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.17.9", "@babel/runtime": "7.17.9",
"@cornerstonejs/core": "^0.14.4", "@cornerstonejs/core": "^0.21.5",
"@cornerstonejs/streaming-image-volume-loader": "^0.4.16", "@cornerstonejs/streaming-image-volume-loader": "^0.6.5",
"@cornerstonejs/tools": "^0.21.7", "@cornerstonejs/tools": "^0.29.8",
"@kitware/vtk.js": "^24.18.7", "@kitware/vtk.js": "25.9.0",
"dom-to-image": "^2.6.0", "html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"shader-loader": "^1.3.1", "shader-loader": "^1.3.1",

View File

@ -1,15 +1,25 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'; import React, { useEffect, useRef, useCallback, useState } from 'react';
import ReactResizeDetector from 'react-resize-detector'; import ReactResizeDetector from 'react-resize-detector';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui';
import * as cs3DTools from '@cornerstonejs/tools'; import * as cs3DTools from '@cornerstonejs/tools';
import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core'; import {
Enums,
eventTarget,
getEnabledElement,
StackViewport,
utilities as csUtils,
CONSTANTS,
} from '@cornerstonejs/core';
import { setEnabledElement } from '../state'; import { setEnabledElement } from '../state';
import CornerstoneCacheService from '../services/ViewportService/CornerstoneCacheService';
import './OHIFCornerstoneViewport.css'; import './OHIFCornerstoneViewport.css';
import CornerstoneOverlays from './Overlays/CornerstoneOverlays'; import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
import {
IStackViewport,
IVolumeViewport,
} from '@cornerstonejs/core/dist/esm/types';
import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
const STACK = 'stack'; const STACK = 'stack';
@ -22,24 +32,65 @@ function areEqual(prevProps, nextProps) {
return false; return false;
} }
const prevDisplaySets = prevProps.displaySets[0]; if (
const nextDisplaySets = nextProps.displaySets[0]; prevProps.viewportOptions.orientation !==
nextProps.viewportOptions.orientation
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
);
}
return false; return false;
}
if (
prevProps.viewportOptions.toolGroupId !==
nextProps.viewportOptions.toolGroupId
) {
return false;
}
if (
prevProps.viewportOptions.viewportType !==
nextProps.viewportOptions.viewportType
) {
return false;
}
const prevDisplaySets = prevProps.displaySets;
const nextDisplaySets = nextProps.displaySets;
if (prevDisplaySets.length !== nextDisplaySets.length) {
return false;
}
for (let i = 0; i < prevDisplaySets.length; i++) {
const prevDisplaySet = prevDisplaySets[i];
const foundDisplaySet = nextDisplaySets.find(
nextDisplaySet =>
nextDisplaySet.displaySetInstanceUID ===
prevDisplaySet.displaySetInstanceUID
);
if (!foundDisplaySet) {
return false;
}
// check they contain the same image
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
return false;
}
// check if their imageIds are the same
if (foundDisplaySet.images?.length) {
for (let j = 0; j < foundDisplaySet.images.length; j++) {
if (
foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId
) {
return false;
}
}
}
}
return true;
} }
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport // Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
@ -61,8 +112,6 @@ const OHIFCornerstoneViewport = React.memo(props => {
} = props; } = props;
const [scrollbarHeight, setScrollbarHeight] = useState('100px'); const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [viewportData, setViewportData] = useState(null);
const [_, viewportGridService] = useViewportGrid();
const elementRef = useRef(); const elementRef = useRef();
@ -73,6 +122,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
ToolGroupService, ToolGroupService,
SyncGroupService, SyncGroupService,
CornerstoneViewportService, CornerstoneViewportService,
CornerstoneCacheService,
ViewportGridService,
} = servicesManager.services; } = servicesManager.services;
// useCallback for scroll bar height calculation // useCallback for scroll bar height calculation
@ -89,6 +140,28 @@ const OHIFCornerstoneViewport = React.memo(props => {
} }
}, [elementRef]); }, [elementRef]);
const cleanUpServices = useCallback(() => {
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
if (!viewportInfo) {
return;
}
const viewportId = viewportInfo.getViewportId();
const renderingEngineId = viewportInfo.getRenderingEngineId();
const syncGroups = viewportInfo.getSyncGroups();
ToolGroupService.removeViewportFromToolGroup(viewportId, renderingEngineId);
SyncGroupService.removeViewportFromSyncGroup(
viewportId,
renderingEngineId,
syncGroups
);
}, [viewportIndex, viewportOptions.viewportId]);
const elementEnabledHandler = useCallback( const elementEnabledHandler = useCallback(
evt => { evt => {
// check this is this element reference and return early if doesn't match // check this is this element reference and return early if doesn't match
@ -129,7 +202,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
// disable the element upon unmounting // disable the element upon unmounting
useEffect(() => { useEffect(() => {
CornerstoneViewportService.enableElement( CornerstoneViewportService.enableViewport(
viewportIndex, viewportIndex,
viewportOptions, viewportOptions,
elementRef.current elementRef.current
@ -143,31 +216,22 @@ const OHIFCornerstoneViewport = React.memo(props => {
setImageScrollBarHeight(); setImageScrollBarHeight();
return () => { return () => {
cleanUpServices();
CornerstoneViewportService.disableElement(viewportIndex);
if (onElementDisabled) {
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex( const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex viewportIndex
); );
const viewportId = viewportInfo.getViewportId(); onElementDisabled(viewportInfo);
const renderingEngineId = viewportInfo.getRenderingEngineId(); }
const syncGroups = viewportInfo.getSyncGroups();
ToolGroupService.disable(viewportId, renderingEngineId);
SyncGroupService.removeViewportFromSyncGroup(
viewportId,
renderingEngineId,
syncGroups
);
CornerstoneViewportService.disableElement(viewportIndex);
eventTarget.removeEventListener( eventTarget.removeEventListener(
Enums.Events.ELEMENT_ENABLED, Enums.Events.ELEMENT_ENABLED,
elementEnabledHandler elementEnabledHandler
); );
if (onElementDisabled) {
onElementDisabled();
}
}; };
}, []); }, []);
@ -183,11 +247,12 @@ const OHIFCornerstoneViewport = React.memo(props => {
const { unsubscribe } = DisplaySetService.subscribe( const { unsubscribe } = DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED, DisplaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
async invalidatedDisplaySetInstanceUID => { async invalidatedDisplaySetInstanceUID => {
if ( const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportData.displaySetInstanceUIDs.includes( viewportIndex
invalidatedDisplaySetInstanceUID );
)
) { if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) {
const viewportData = viewportInfo.getViewportData();
const newViewportData = await CornerstoneCacheService.invalidateViewportData( const newViewportData = await CornerstoneCacheService.invalidateViewportData(
viewportData, viewportData,
invalidatedDisplaySetInstanceUID, invalidatedDisplaySetInstanceUID,
@ -201,15 +266,13 @@ const OHIFCornerstoneViewport = React.memo(props => {
newViewportData, newViewportData,
keepCamera keepCamera
); );
setViewportData(newViewportData);
} }
} }
); );
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [viewportData, viewportIndex]); }, [viewportIndex]);
useEffect(() => { useEffect(() => {
// handle the default viewportType to be stack // handle the default viewportType to be stack
@ -218,22 +281,19 @@ const OHIFCornerstoneViewport = React.memo(props => {
} }
const loadViewportData = async () => { const loadViewportData = async () => {
const viewportData = await CornerstoneCacheService.getViewportData( const viewportData = await CornerstoneCacheService.createViewportData(
viewportIndex,
displaySets, displaySets,
viewportOptions.viewportType, viewportOptions,
dataSource, dataSource,
initialImageIndex initialImageIndex
); );
CornerstoneViewportService.setViewportDisplaySets( CornerstoneViewportService.setViewportData(
viewportIndex, viewportIndex,
viewportData, viewportData,
viewportOptions, viewportOptions,
displaySetOptions displaySetOptions
); );
setViewportData(viewportData);
}; };
loadViewportData(); loadViewportData();
@ -256,7 +316,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
elementRef, elementRef,
viewportIndex, viewportIndex,
displaySets, displaySets,
viewportGridService ViewportGridService,
CornerstoneViewportService
); );
_checkForCachedJumpToMeasurementEvents( _checkForCachedJumpToMeasurementEvents(
@ -265,13 +326,14 @@ const OHIFCornerstoneViewport = React.memo(props => {
elementRef, elementRef,
viewportIndex, viewportIndex,
displaySets, displaySets,
viewportGridService ViewportGridService,
CornerstoneViewportService
); );
return () => { return () => {
unsubscribeFromJumpToMeasurementEvents(); unsubscribeFromJumpToMeasurementEvents();
}; };
}, [displaySets, elementRef, viewportIndex, viewportData]); }, [displaySets, elementRef, viewportIndex]);
return ( return (
<div className="viewport-wrapper"> <div className="viewport-wrapper">
@ -280,7 +342,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
handleHeight handleHeight
skipOnMount={true} // Todo: make these configurable skipOnMount={true} // Todo: make these configurable
refreshMode={'debounce'} refreshMode={'debounce'}
refreshRate={100} refreshRate={200} // transition amount in side panel
onResize={onResize} onResize={onResize}
targetRef={elementRef.current} targetRef={elementRef.current}
/> />
@ -308,7 +370,8 @@ function _subscribeToJumpToMeasurementEvents(
elementRef, elementRef,
viewportIndex, viewportIndex,
displaySets, displaySets,
viewportGridService ViewportGridService,
CornerstoneViewportService
) { ) {
const displaysUIDs = displaySets.map( const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID displaySet => displaySet.displaySetInstanceUID
@ -326,7 +389,8 @@ function _subscribeToJumpToMeasurementEvents(
viewportIndex, viewportIndex,
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
viewportGridService ViewportGridService,
CornerstoneViewportService
); );
} }
} }
@ -342,7 +406,8 @@ function _checkForCachedJumpToMeasurementEvents(
elementRef, elementRef,
viewportIndex, viewportIndex,
displaySets, displaySets,
viewportGridService ViewportGridService,
CornerstoneViewportService
) { ) {
const displaysUIDs = displaySets.map( const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID displaySet => displaySet.displaySetInstanceUID
@ -365,7 +430,8 @@ function _checkForCachedJumpToMeasurementEvents(
viewportIndex, viewportIndex,
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
viewportGridService ViewportGridService,
CornerstoneViewportService
); );
} }
} }
@ -377,10 +443,11 @@ function _jumpToMeasurement(
viewportIndex, viewportIndex,
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
viewportGridService ViewportGridService,
CornerstoneViewportService
) { ) {
const targetElement = targetElementRef.current; const targetElement = targetElementRef.current;
const { displaySetInstanceUID, SOPInstanceUID } = measurement; const { displaySetInstanceUID, SOPInstanceUID, frameNumber } = measurement;
if (!SOPInstanceUID) { if (!SOPInstanceUID) {
console.warn('cannot jump in a non-acquisition plane measurements yet'); console.warn('cannot jump in a non-acquisition plane measurements yet');
@ -390,17 +457,63 @@ function _jumpToMeasurement(
displaySetInstanceUID displaySetInstanceUID
); );
const imageIdIndex = referencedDisplaySet.images.findIndex(
i => i.SOPInstanceUID === SOPInstanceUID
);
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager // Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly // to set it properly
// setCornerstoneMeasurementActive(measurement); // 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();
imageIdIndex = imageIds.findIndex(imageId => {
const {
SOPInstanceUID: aSOPInstanceUID,
frameNumber: aFrameNumber,
} = getSOPInstanceAttributes(imageId);
return (
aSOPInstanceUID === SOPInstanceUID &&
(!frameNumber || frameNumber === aFrameNumber)
);
});
} 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;
}
if (getEnabledElement(targetElement)) {
cs3DTools.utilities.jumpToSlice(targetElement, { cs3DTools.utilities.jumpToSlice(targetElement, {
imageIndex: imageIdIndex, imageIndex: imageIdIndex,
}); });
@ -419,7 +532,7 @@ OHIFCornerstoneViewport.propTypes = {
displaySets: PropTypes.array.isRequired, displaySets: PropTypes.array.isRequired,
dataSource: PropTypes.object.isRequired, dataSource: PropTypes.object.isRequired,
viewportOptions: PropTypes.object, viewportOptions: PropTypes.object,
displaySetOptions: PropTypes.arrayOf(PropTypes.object), displaySetOptions: PropTypes.arrayOf(PropTypes.any),
servicesManager: PropTypes.object.isRequired, servicesManager: PropTypes.object.isRequired,
onElementEnabled: PropTypes.func, onElementEnabled: PropTypes.func,
// Note: you SHOULD NOT use the initialImageIdOrIndex for manipulation // Note: you SHOULD NOT use the initialImageIdOrIndex for manipulation

View File

@ -3,8 +3,7 @@ import React, { useEffect, useState } from 'react';
import ViewportImageScrollbar from './ViewportImageScrollbar'; import ViewportImageScrollbar from './ViewportImageScrollbar';
import ViewportOverlay from './ViewportOverlay'; import ViewportOverlay from './ViewportOverlay';
import ViewportOrientationMarkers from './ViewportOrientationMarkers'; import ViewportOrientationMarkers from './ViewportOrientationMarkers';
import ViewportLoadingIndicator from './ViewportLoadingIndicator'; import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
import CornerstoneCacheService from '../../services/ViewportService/CornerstoneCacheService';
function CornerstoneOverlays(props) { function CornerstoneOverlays(props) {
const { viewportIndex, element, scrollbarHeight, servicesManager } = props; const { viewportIndex, element, scrollbarHeight, servicesManager } = props;
@ -16,8 +15,8 @@ function CornerstoneOverlays(props) {
const [viewportData, setViewportData] = useState(null); const [viewportData, setViewportData] = useState(null);
useEffect(() => { useEffect(() => {
const { unsubscribe } = CornerstoneCacheService.subscribe( const { unsubscribe } = CornerstoneViewportService.subscribe(
CornerstoneCacheService.EVENTS.VIEWPORT_DATA_CHANGED, CornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
props => { props => {
if (props.viewportIndex !== viewportIndex) { if (props.viewportIndex !== viewportIndex) {
return; return;
@ -47,7 +46,7 @@ function CornerstoneOverlays(props) {
} }
return ( return (
<div> <div className="noselect">
<ViewportImageScrollbar <ViewportImageScrollbar
viewportIndex={viewportIndex} viewportIndex={viewportIndex}
viewportData={viewportData} viewportData={viewportData}
@ -64,11 +63,15 @@ function CornerstoneOverlays(props) {
servicesManager={servicesManager} servicesManager={servicesManager}
element={element} element={element}
/> />
<ViewportLoadingIndicator viewportData={viewportData} element={element} /> <ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers <ViewportOrientationMarkers
imageSliceData={imageSliceData} imageSliceData={imageSliceData}
element={element} element={element}
viewportData={viewportData} viewportData={viewportData}
servicesManager={servicesManager}
viewportIndex={viewportIndex} viewportIndex={viewportIndex}
/> />
</div> </div>

View File

@ -25,9 +25,13 @@ function CornerstoneImageScrollbar({
viewportId viewportId
); );
const { isCineEnabled } = CineService.getState();
if (isCineEnabled) {
// on image scrollbar change, stop the CINE if it is playing // on image scrollbar change, stop the CINE if it is playing
CineService.stopClip(element); CineService.stopClip(element);
CineService.setCine({ id: viewportIndex, isPlaying: false }); CineService.setCine({ id: viewportIndex, isPlaying: false });
}
csToolsUtils.jumpToSlice(viewport.element, { csToolsUtils.jumpToSlice(viewport.element, {
imageIndex, imageIndex,
@ -53,7 +57,7 @@ function CornerstoneImageScrollbar({
setImageSliceData({ setImageSliceData({
imageIndex: imageIndex, imageIndex: imageIndex,
numberOfSlices: viewportData.imageIds.length, numberOfSlices: viewportData.data.imageIds.length,
}); });
return; return;
@ -83,7 +87,7 @@ function CornerstoneImageScrollbar({
// find the index of imageId in the imageIds // find the index of imageId in the imageIds
setImageSliceData({ setImageSliceData({
imageIndex: newImageIdIndex, 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 PropTypes from 'prop-types';
import { Enums } from '@cornerstonejs/core'; import { Enums } from '@cornerstonejs/core';
function ViewportLoadingIndicator({ viewportData, element }) { function ViewportImageSliceLoadingIndicator({ viewportData, element }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -89,15 +89,15 @@ function ViewportLoadingIndicator({ viewportData, element }) {
return null; return null;
} }
ViewportLoadingIndicator.propTypes = { ViewportImageSliceLoadingIndicator.propTypes = {
percentComplete: PropTypes.number, percentComplete: PropTypes.number,
error: PropTypes.object, error: PropTypes.object,
element: PropTypes.object, element: PropTypes.object,
}; };
ViewportLoadingIndicator.defaultProps = { ViewportImageSliceLoadingIndicator.defaultProps = {
percentComplete: 0, percentComplete: 0,
error: null, error: null,
}; };
export default ViewportLoadingIndicator; export default ViewportImageSliceLoadingIndicator;

View File

@ -4,14 +4,13 @@
--scrollbar-width: 20px; --scrollbar-width: 20px;
pointer-events: none; pointer-events: none;
font-size: 15px; font-size: 15px;
color: #ccc;
line-height: 18px; line-height: 18px;
} }
.ViewportOrientationMarkers .orientation-marker { .ViewportOrientationMarkers .orientation-marker {
position: absolute; position: absolute;
} }
.ViewportOrientationMarkers .top-mid { .ViewportOrientationMarkers .top-mid {
top: 5px; top: 0.6rem;
left: 50%; left: 50%;
} }
.ViewportOrientationMarkers .left-mid { .ViewportOrientationMarkers .left-mid {
@ -23,7 +22,7 @@
left: calc(100% - var(--marker-width) - var(--scrollbar-width)); left: calc(100% - var(--marker-width) - var(--scrollbar-width));
} }
.ViewportOrientationMarkers .bottom-mid { .ViewportOrientationMarkers .bottom-mid {
top: calc(100% - var(--marker-height) - 5px); top: calc(100% - var(--marker-height) - 0.6rem);
left: 47%; left: 47%;
} }
.ViewportOrientationMarkers .right-mid .orientation-marker-value { .ViewportOrientationMarkers .right-mid .orientation-marker-value {

View File

@ -1,10 +1,164 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import PropTypes from 'prop-types'; import classNames from 'classnames';
import { metaData, Enums, Types } from '@cornerstonejs/core'; import {
metaData,
Enums,
Types,
getEnabledElement,
utilities as csUtils,
} from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools'; import { utilities } from '@cornerstonejs/tools';
import PropTypes from 'prop-types';
import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css'; 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;
// Todo: probably this can be done in a better way in which we identify bright
// background
const isLight = backgroundColor
? csUtils.isEqual(backgroundColor, [1, 1, 1])
: false;
return orientationMarkers.map((m, index) => (
<div
className={classNames(
`${m}-mid orientation-marker`,
isLight ? 'text-[#726F7E]' : 'text-[#ccc]'
)}
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 +170,13 @@ import { getEnabledElement } from '../../state';
* @param {*} rotation in degrees * @param {*} rotation in degrees
* @returns * @returns
*/ */
function getOrientationMarkers( function _getOrientationMarkers(
rowCosines, rowCosines,
columnCosines, columnCosines,
rotation, rotation,
flipVertical, flipVertical,
flipHorizontal flipHorizontal
) { ) {
const {
getOrientationStringLPS,
invertOrientationStringLPS,
} = utilities.orientation;
const rowString = getOrientationStringLPS(rowCosines); const rowString = getOrientationStringLPS(rowCosines);
const columnString = getOrientationStringLPS(columnCosines); const columnString = getOrientationStringLPS(columnCosines);
const oppositeRowString = invertOrientationStringLPS(rowString); const oppositeRowString = invertOrientationStringLPS(rowString);
@ -79,117 +229,4 @@ function getOrientationMarkers(
return markers; 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; export default ViewportOrientationMarkers;

View File

@ -28,6 +28,25 @@ function CornerstoneViewportOverlay({
setActiveTools(ToolBarService.getActiveTools()); 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 * Updating the VOI when the viewport changes its voi
*/ */
@ -100,23 +119,6 @@ function CornerstoneViewportOverlay({
}; };
}, [viewportIndex, viewportData]); }, [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 getTopLeftContent = useCallback(() => {
const { windowWidth, windowCenter } = voi; const { windowWidth, windowCenter } = voi;
@ -186,22 +188,33 @@ function CornerstoneViewportOverlay({
return null; return null;
} }
if (viewportData.imageIds.length === 0) { const ohifViewport = CornerstoneViewportService.getViewportInfoByIndex(
throw new Error( viewportIndex
'ViewportOverlay: only viewports with imageIds is supported at this time'
); );
if (!ohifViewport) {
return null;
} }
const backgroundColor = ohifViewport.getViewportOptions().background;
// Todo: probably this can be done in a better way in which we identify bright
// background
const isLight = backgroundColor
? utilities.isEqual(backgroundColor, [1, 1, 1])
: false;
return ( return (
<ViewportOverlay <ViewportOverlay
topLeft={getTopLeftContent()} topLeft={getTopLeftContent()}
topRight={getTopRightContent()} topRight={getTopRightContent()}
color={isLight && 'text-[#0944B3]'}
/> />
); );
} }
function _getInstanceNumberFromStack(viewportData, imageIndex) { function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.imageIds; const imageIds = viewportData.data.imageIds;
const imageId = imageIds[imageIndex]; const imageId = imageIds[imageIndex];
if (!imageId) { if (!imageId) {
@ -258,8 +271,14 @@ function _getInstanceNumberFromVolume(
const isAcquisitionPlane = vec3.length(cross) < EPSILON; const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) { if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } = const { instanceNumber } =
metaData.get('generalImageModule', imageIds[imageIndex]) || {}; metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber); return parseInt(instanceNumber);
} }
} }

View File

@ -1,21 +1,21 @@
import { import {
getEnabledElement, getEnabledElement,
StackViewport, StackViewport,
volumeLoader,
cache,
utilities as csUtils, utilities as csUtils,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { import {
ToolGroupManager, ToolGroupManager,
Enums, Enums,
segmentation, utilities as cstUtils,
utilities as csToolsUtils, ReferenceLinesTool,
} from '@cornerstonejs/tools'; } from '@cornerstonejs/tools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { getEnabledElement as OHIFgetEnabledElement } from './state'; import { getEnabledElement as OHIFgetEnabledElement } from './state';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import callInputDialog from './utils/callInputDialog'; import callInputDialog from './utils/callInputDialog';
import { setColormap } from './utils/colormap/transferFunctionHelpers'; import { setColormap } from './utils/colormap/transferFunctionHelpers';
import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol';
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
const commandsModule = ({ servicesManager }) => { const commandsModule = ({ servicesManager }) => {
const { const {
@ -25,7 +25,8 @@ const commandsModule = ({ servicesManager }) => {
ToolBarService, ToolBarService,
UIDialogService, UIDialogService,
CornerstoneViewportService, CornerstoneViewportService,
SegmentationService, HangingProtocolService,
UINotificationService,
} = servicesManager.services; } = servicesManager.services;
function _getActiveViewportEnabledElement() { function _getActiveViewportEnabledElement() {
@ -128,76 +129,68 @@ const commandsModule = ({ servicesManager }) => {
}); });
viewport.render(); 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 }) => { setToolActive: ({ toolName, toolGroupId = null }) => {
const toolGroup = _getToolGroup(toolGroupId); if (toolName === 'Crosshairs') {
const activeViewportToolGroup = _getToolGroup(null);
if (!toolGroup) { if (!activeViewportToolGroup._toolInstances.Crosshairs) {
console.warn('No tool group found for toolGroupId:', toolGroupId); UINotificationService.show({
return; 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');
}
} }
// Todo: we need to check if the viewports of the toolGroup is actually
// parts of the ViewportGrid's viewports, if not we return
const { viewports } = ViewportGridService.getState() || { const { viewports } = ViewportGridService.getState() || {
viewports: [], viewports: [],
}; };
// iterate over all viewports and set the tool active for the const toolGroup = _getToolGroup(toolGroupId);
// viewports that belong to the toolGroup const toolGroupViewportIds = toolGroup.getViewportIds();
for (let index = 0; index < viewports.length; index++) {
const ohifEnabledElement = OHIFgetEnabledElement(index);
if (!ohifEnabledElement) {
continue;
}
const viewport = getEnabledElement(ohifEnabledElement.element);
if (!viewport) {
continue;
}
// Find the current active tool and set it to be passive
const activeTool = toolGroup.getActivePrimaryMouseButtonTool();
if (activeTool) {
toolGroup.setToolPassive(activeTool);
}
// Set the new toolName to be active
toolGroup.setToolActive(toolName, {
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
});
// if toolGroup has been destroyed, or its viewports have been removed
if (!toolGroupViewportIds || !toolGroupViewportIds.length) {
return; return;
} }
const filteredViewports = viewports.filter(viewport => {
if (!viewport.viewportOptions) {
return false;
}
return toolGroupViewportIds.includes(
viewport.viewportOptions.viewportId
);
});
if (!filteredViewports.length) {
return;
}
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
if (activeToolName) {
// Todo: this is a hack to prevent the crosshairs to stick around
// after another tool is selected. We should find a better way to do this
if (activeToolName === 'Crosshairs') {
toolGroup.setToolDisabled(activeToolName);
} else {
toolGroup.setToolPassive(activeToolName);
}
}
// Set the new toolName to be active
toolGroup.setToolActive(toolName, {
bindings: [
{
mouseButton: Enums.MouseBindings.Primary,
},
],
});
}, },
showDownloadViewportModal: () => { showDownloadViewportModal: () => {
const { activeViewportIndex } = ViewportGridService.getState(); const { activeViewportIndex } = ViewportGridService.getState();
@ -293,8 +286,12 @@ const commandsModule = ({ servicesManager }) => {
if (viewport instanceof StackViewport) { if (viewport instanceof StackViewport) {
viewport.resetProperties(); viewport.resetProperties();
viewport.resetCamera(); viewport.resetCamera();
viewport.render(); } else {
// Todo: add reset properties for volume viewport
viewport.resetCamera();
} }
viewport.render();
}, },
scaleViewport: ({ direction }) => { scaleViewport: ({ direction }) => {
const enabledElement = _getActiveViewportEnabledElement(); const enabledElement = _getActiveViewportEnabledElement();
@ -326,59 +323,7 @@ const commandsModule = ({ servicesManager }) => {
const { viewport } = enabledElement; const { viewport } = enabledElement;
const options = { delta: direction }; const options = { delta: direction };
csToolsUtils.scroll(viewport, options); cstUtils.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;
}, },
setViewportColormap: ({ setViewportColormap: ({
viewportIndex, viewportIndex,
@ -393,7 +338,7 @@ const commandsModule = ({ servicesManager }) => {
const actorEntries = viewport.getActors(); const actorEntries = viewport.getActors();
const actorEntry = actorEntries.find(actorEntry => { const actorEntry = actorEntries.find(actorEntry => {
return actorEntry.uid === displaySetInstanceUID; return actorEntry.uid.includes(displaySetInstanceUID);
}); });
const { actor: volumeActor } = actorEntry; const { actor: volumeActor } = actorEntry;
@ -404,6 +349,56 @@ const commandsModule = ({ servicesManager }) => {
viewport.render(); viewport.render();
} }
}, },
incrementActiveViewport: () => {
const { activeViewportIndex, viewports } = ViewportGridService.getState();
const nextViewportIndex = (activeViewportIndex + 1) % viewports.length;
ViewportGridService.setActiveViewportIndex(nextViewportIndex);
},
decrementActiveViewport: () => {
const { activeViewportIndex, viewports } = ViewportGridService.getState();
const nextViewportIndex =
(activeViewportIndex - 1 + viewports.length) % viewports.length;
ViewportGridService.setActiveViewportIndex(nextViewportIndex);
},
setHangingProtocol: ({ protocolId }) => {
HangingProtocolService.setProtocol(protocolId);
},
toggleMPR: ({ toggledState }) => {
toggleMPRHangingProtocol({
toggledState,
servicesManager,
getToolGroup: _getToolGroup,
});
},
toggleStackImageSync: ({ toggledState }) => {
toggleStackImageSync({
getEnabledElement,
servicesManager,
toggledState,
});
},
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 = { const definitions = {
@ -417,11 +412,6 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [], storeContexts: [],
options: {}, options: {},
}, },
toggleCrosshairs: {
commandFn: actions.toggleCrosshairs,
storeContexts: [],
options: {},
},
rotateViewportCW: { rotateViewportCW: {
commandFn: actions.rotateViewport, commandFn: actions.rotateViewport,
storeContexts: [], storeContexts: [],
@ -432,6 +422,14 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [], storeContexts: [],
options: { rotation: -90 }, options: { rotation: -90 },
}, },
incrementActiveViewport: {
commandFn: actions.incrementActiveViewport,
storeContexts: [],
},
decrementActiveViewport: {
commandFn: actions.decrementActiveViewport,
storeContexts: [],
},
flipViewportHorizontal: { flipViewportHorizontal: {
commandFn: actions.flipViewportHorizontal, commandFn: actions.flipViewportHorizontal,
storeContexts: [], storeContexts: [],
@ -497,27 +495,31 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [], storeContexts: [],
options: {}, options: {},
}, },
createSegmentationForDisplaySet: {
commandFn: actions.createSegmentationForDisplaySet,
storeContexts: [],
options: {},
},
addSegmentationRepresentationToToolGroup: {
commandFn: actions.addSegmentationRepresentationToToolGroup,
storeContexts: [],
options: {},
},
getLabelmapVolumes: {
commandFn: actions.getLabelmapVolumes,
storeContexts: [],
options: {},
},
setViewportColormap: { setViewportColormap: {
commandFn: actions.setViewportColormap, commandFn: actions.setViewportColormap,
storeContexts: [], storeContexts: [],
options: {}, options: {},
}, },
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
toggleMPR: {
commandFn: actions.toggleMPR,
storeContexts: [],
options: {},
},
toggleStackImageSync: {
commandFn: actions.toggleStackImageSync,
storeContexts: [],
options: {},
},
toggleReferenceLines: {
commandFn: actions.toggleReferenceLines,
storeContexts: [],
options: {},
},
}; };
return { return {

View File

@ -0,0 +1,142 @@
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,
},
],
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
},
],
},
{
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: mpr,
},
];
}
export default getHangingProtocolModule;

View File

@ -3,22 +3,26 @@ import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools'; import * as cornerstoneTools from '@cornerstonejs/tools';
import { import {
Enums as cs3DEnums, Enums as cs3DEnums,
CONSTANTS,
imageLoadPoolManager, imageLoadPoolManager,
imageRetrievalPoolManager, imageRetrievalPoolManager,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import init from './init.js'; import init from './init';
import commandsModule from './commandsModule'; import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import ToolGroupService from './services/ToolGroupService'; import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService'; import SyncGroupService from './services/SyncGroupService';
import SegmentationService from './services/SegmentationService';
import CornerstoneCacheService from './services/CornerstoneCacheService';
import { toolNames } from './initCornerstoneTools'; import { toolNames } from './initCornerstoneTools';
import { getEnabledElement } from './state'; import { getEnabledElement, reset as enabledElementReset } from './state';
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService'; import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
import dicomLoaderService from './utils/dicomLoaderService'; import dicomLoaderService from './utils/dicomLoaderService';
import { registerColormap } from './utils/colormap/transferFunctionHelpers'; import { registerColormap } from './utils/colormap/transferFunctionHelpers';
import { id } from './id'; import { id } from './id';
import * as csWADOImageLoader from './initWADOImageLoader.js';
const Component = React.lazy(() => { const Component = React.lazy(() => {
return import( return import(
@ -50,6 +54,9 @@ const cornerstoneExtension = {
imageLoadPoolManager.clearRequestStack(type); imageLoadPoolManager.clearRequestStack(type);
imageRetrievalPoolManager.clearRequestStack(type); imageRetrievalPoolManager.clearRequestStack(type);
}); });
csWADOImageLoader.destroy();
enabledElementReset();
}, },
/** /**
@ -69,8 +76,12 @@ const cornerstoneExtension = {
); );
servicesManager.registerService(ToolGroupService(servicesManager)); servicesManager.registerService(ToolGroupService(servicesManager));
servicesManager.registerService(SyncGroupService(servicesManager)); servicesManager.registerService(SyncGroupService(servicesManager));
servicesManager.registerService(SegmentationService(servicesManager));
servicesManager.registerService(CornerstoneCacheService(servicesManager));
await init({ servicesManager, commandsManager, configuration, appConfig }); await init({ servicesManager, commandsManager, configuration, appConfig });
}, },
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) { getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => { const ExtendedOHIFCornerstoneViewport = props => {
// const onNewImageHandler = jumpData => { // const onNewImageHandler = jumpData => {
@ -119,7 +130,6 @@ const cornerstoneExtension = {
name: 'core', name: 'core',
exports: { exports: {
Enums: cs3DEnums, Enums: cs3DEnums,
CONSTANTS,
}, },
}, },
{ {

View File

@ -1,4 +1,5 @@
import OHIF from '@ohif/core'; import OHIF from '@ohif/core';
import React from 'react';
import { ContextMenuMeasurements } from '@ohif/ui'; import { ContextMenuMeasurements } from '@ohif/ui';
import * as cornerstone from '@cornerstonejs/core'; import * as cornerstone from '@cornerstonejs/core';
@ -13,7 +14,7 @@ import {
imageLoadPoolManager, imageLoadPoolManager,
Settings, Settings,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { Enums, utilities } from '@cornerstonejs/tools'; import { Enums, utilities, ReferenceLinesTool } from '@cornerstonejs/tools';
import { import {
cornerstoneStreamingImageVolumeLoader, cornerstoneStreamingImageVolumeLoader,
sharedArrayBufferImageLoader, sharedArrayBufferImageLoader,
@ -27,7 +28,6 @@ import callInputDialog from './utils/callInputDialog';
import initCineService from './initCineService'; import initCineService from './initCineService';
import interleaveCenterLoader from './utils/interleaveCenterLoader'; import interleaveCenterLoader from './utils/interleaveCenterLoader';
import interleaveTopToBottom from './utils/interleaveTopToBottom'; import interleaveTopToBottom from './utils/interleaveTopToBottom';
import initSegmentationService from './initSegmentationService';
const cs3DToolsEvents = Enums.Events; const cs3DToolsEvents = Enums.Events;
@ -48,34 +48,56 @@ export default async function init({
await cs3DInit(); await cs3DInit();
// For debugging e2e tests that are failing on CI // For debugging e2e tests that are failing on CI
//cornerstone.setUseCPURendering(true); cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
// For debugging large datasets // For debugging large datasets
//cornerstone.cache.setMaxCacheSize(3000000000); const MAX_CACHE_SIZE_1GB = 1073741824;
const maxCacheSize = appConfig.maxCacheSize;
cornerstone.cache.setMaxCacheSize(
maxCacheSize ? maxCacheSize : MAX_CACHE_SIZE_1GB
);
initCornerstoneTools(); initCornerstoneTools();
// Don't use cursors in viewports Settings.getRuntimeSettings().set(
// Todo: this should come from extension/app configuration 'useCursors',
Settings.getRuntimeSettings().set('useCursors', false); Boolean(appConfig.useCursors)
);
const { const {
UserAuthenticationService, UserAuthenticationService,
ToolGroupService,
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
UIDialogService, UIDialogService,
UIModalService,
CineService, CineService,
CornerstoneViewportService, CornerstoneViewportService,
HangingProtocolService, HangingProtocolService,
SegmentationService, ToolGroupService,
ViewportGridService,
} = servicesManager.services; } = servicesManager.services;
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; const metadataProvider = OHIF.classes.MetadataProvider;
volumeLoader.registerUnknownVolumeLoader(
cornerstoneStreamingImageVolumeLoader
);
volumeLoader.registerVolumeLoader( volumeLoader.registerVolumeLoader(
'cornerstoneStreamingImageVolume', 'cornerstoneStreamingImageVolume',
cornerstoneStreamingImageVolumeLoader cornerstoneStreamingImageVolumeLoader
@ -112,8 +134,6 @@ export default async function init({
CornerstoneViewportService CornerstoneViewportService
); );
initSegmentationService(SegmentationService, CornerstoneViewportService);
initCineService(CineService); initCineService(CineService);
const _getDefaultPosition = event => ({ const _getDefaultPosition = event => ({
@ -135,8 +155,8 @@ export default async function init({
currentPoints.canvas currentPoints.canvas
); );
let menuItems = []; const menuItems = [];
if (nearbyToolData) { if (nearbyToolData && nearbyToolData.metadata.toolName !== 'Crosshairs') {
defaultMenuItems.forEach(item => { defaultMenuItems.forEach(item => {
item.value = nearbyToolData; item.value = nearbyToolData;
item.element = element; item.element = element;
@ -259,6 +279,37 @@ export default async function init({
// } // }
// }; // };
const newStackCallback = evt => {
const { element } = evt.detail;
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) { function elementEnabledHandler(evt) {
const { element } = evt.detail; const { element } = evt.detail;
@ -267,27 +318,29 @@ export default async function init({
contextMenuHandleClick contextMenuHandleClick
); );
eventTarget.addEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => { element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
const { element } = evt.detail;
utilities.stackPrefetch.enable(element); eventTarget.addEventListener(
}); EVENTS.STACK_VIEWPORT_NEW_STACK,
newStackCallback
);
} }
function elementDisabledHandler(evt) { function elementDisabledHandler(evt) {
const { viewportId, element } = evt.detail; const { element } = evt.detail;
const viewportInfo = CornerstoneViewportService.getViewportInfo(viewportId);
ToolGroupService.disable(viewportInfo);
element.removeEventListener( element.removeEventListener(
cs3DToolsEvents.MOUSE_CLICK, cs3DToolsEvents.MOUSE_CLICK,
contextMenuHandleClick contextMenuHandleClick
); );
eventTarget.removeEventListener(EVENTS.STACK_VIEWPORT_NEW_STACK, evt => { element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
const { element } = evt.detail;
utilities.stackPrefetch.disable(element); // TODO - consider removing the callback when all elements are gone
}); // eventTarget.removeEventListener(
// EVENTS.STACK_VIEWPORT_NEW_STACK,
// newStackCallback
// );
} }
eventTarget.addEventListener( eventTarget.addEventListener(
@ -299,4 +352,74 @@ export default async function init({
EVENTS.ELEMENT_DISABLED, EVENTS.ELEMENT_DISABLED,
elementDisabledHandler.bind(null) elementDisabledHandler.bind(null)
); );
ViewportGridService.subscribe(
ViewportGridService.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED,
({ viewportIndex }) => {
const viewportId = `viewport-${viewportIndex}`;
const toolGroup = ToolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup || !toolGroup._toolInstances?.['ReferenceLines']) {
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, BidirectionalTool,
ArrowAnnotateTool, ArrowAnnotateTool,
DragProbeTool, DragProbeTool,
ProbeTool,
AngleTool, AngleTool,
MagnifyTool, MagnifyTool,
CrosshairsTool, CrosshairsTool,
@ -19,6 +20,7 @@ import {
init, init,
addTool, addTool,
annotation, annotation,
ReferenceLinesTool,
} from '@cornerstonejs/tools'; } from '@cornerstonejs/tools';
export default function initCornerstoneTools(configuration = {}) { export default function initCornerstoneTools(configuration = {}) {
@ -28,6 +30,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(StackScrollMouseWheelTool); addTool(StackScrollMouseWheelTool);
addTool(StackScrollTool); addTool(StackScrollTool);
addTool(ZoomTool); addTool(ZoomTool);
addTool(ProbeTool);
addTool(VolumeRotateMouseWheelTool); addTool(VolumeRotateMouseWheelTool);
addTool(MIPJumpToClickTool); addTool(MIPJumpToClickTool);
addTool(LengthTool); addTool(LengthTool);
@ -40,6 +43,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(MagnifyTool); addTool(MagnifyTool);
addTool(CrosshairsTool); addTool(CrosshairsTool);
addTool(SegmentationDisplayTool); addTool(SegmentationDisplayTool);
addTool(ReferenceLinesTool);
// Modify annotation tools to use dashed lines on SR // Modify annotation tools to use dashed lines on SR
const annotationStyle = { const annotationStyle = {
@ -67,6 +71,7 @@ const toolNames = {
MipJumpToClick: MIPJumpToClickTool.toolName, MipJumpToClick: MIPJumpToClickTool.toolName,
Length: LengthTool.toolName, Length: LengthTool.toolName,
DragProbe: DragProbeTool.toolName, DragProbe: DragProbeTool.toolName,
Probe: ProbeTool.toolName,
RectangleROI: RectangleROITool.toolName, RectangleROI: RectangleROITool.toolName,
EllipticalROI: EllipticalROITool.toolName, EllipticalROI: EllipticalROITool.toolName,
Bidirectional: BidirectionalTool.toolName, Bidirectional: BidirectionalTool.toolName,
@ -74,6 +79,7 @@ const toolNames = {
Magnify: MagnifyTool.toolName, Magnify: MagnifyTool.toolName,
Crosshairs: CrosshairsTool.toolName, Crosshairs: CrosshairsTool.toolName,
SegmentationDisplay: SegmentationDisplayTool.toolName, SegmentationDisplay: SegmentationDisplayTool.toolName,
ReferenceLines: ReferenceLinesTool.toolName,
}; };
export { toolNames }; export { toolNames };

View File

@ -3,6 +3,7 @@ import { Enums, annotation } from '@cornerstonejs/tools';
import { DicomMetadataStore } from '@ohif/core'; import { DicomMetadataStore } from '@ohif/core';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
const { removeAnnotation } = annotation.state; const { removeAnnotation } = annotation.state;
@ -252,7 +253,18 @@ const connectMeasurementServiceToTools = (
SOPInstanceUID SOPInstanceUID
); );
const imageId = dataSource.getImageIdsForInstance({ instance }); let imageId;
let frameNumber = 1;
if (measurement?.metadata?.referencedImageId) {
imageId = measurement.metadata.referencedImageId;
frameNumber = getSOPInstanceAttributes(
measurement.metadata.referencedImageId
).frameNumber;
} else {
imageId = dataSource.getImageIdsForInstance({ instance });
}
const annotationManager = annotation.state.getDefaultAnnotationManager(); const annotationManager = annotation.state.getDefaultAnnotationManager();
annotationManager.addAnnotation({ annotationManager.addAnnotation({
annotationUID: measurement.uid, annotationUID: measurement.uid,
@ -269,6 +281,7 @@ const connectMeasurementServiceToTools = (
handles: { ...data.annotation.data.handles }, handles: { ...data.annotation.data.handles },
cachedStats: { ...data.annotation.data.cachedStats }, cachedStats: { ...data.annotation.data.cachedStats },
label: data.annotation.data.label, label: data.annotation.data.label,
frameNumber: frameNumber,
}, },
}); });
} }

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 * as cornerstone from '@cornerstonejs/core';
import { volumeLoader } from '@cornerstonejs/core'; import { volumeLoader } from '@cornerstonejs/core';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader'; 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 dicomParser from 'dicom-parser';
import { errorHandler } from '@ohif/core'; import { errorHandler } from '@ohif/core';
@ -61,7 +63,9 @@ export default function initWADOImageLoader(
// For now we use image/jls and image/x-jls because some servers still use the old type // For now we use image/jls and image/x-jls because some servers still use the old type
// http://dicom.nema.org/medical/dicom/current/output/html/part18.html // http://dicom.nema.org/medical/dicom/current/output/html/part18.html
const xhrRequestHeaders = { const xhrRequestHeaders = {
Accept: 'multipart/related; type=application/octet-stream', Accept: appConfig.omitQuotationForMultipartRequest
? 'multipart/related; type=application/octet-stream'
: 'multipart/related; type="application/octet-stream"',
// 'multipart/related; type="image/x-jls", multipart/related; type="image/jls"; transfer-syntax="1.2.840.10008.1.2.4.80", multipart/related; type="image/x-jls", multipart/related; type="application/octet-stream"; transfer-syntax=*', // 'multipart/related; type="image/x-jls", multipart/related; type="image/jls"; transfer-syntax="1.2.840.10008.1.2.4.80", multipart/related; type="image/x-jls", multipart/related; type="application/octet-stream"; transfer-syntax=*',
}; };
@ -78,3 +82,13 @@ export default function initWADOImageLoader(
initWebWorkers(appConfig); 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

@ -1,4 +1,8 @@
import { synchronizers, SynchronizerManager } from '@cornerstonejs/tools'; import {
synchronizers,
SynchronizerManager,
Synchronizer,
} from '@cornerstonejs/tools';
import { pubSubServiceInterface } from '@ohif/core'; import { pubSubServiceInterface } from '@ohif/core';
@ -6,20 +10,42 @@ const EVENTS = {
TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated', TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated',
}; };
/**
* @params options - are an optional set of options associated with the first
* sync group declared.
*/
export type SyncCreator = (
type: string,
options?: Record<string, unknown>
) => Synchronizer;
export type SyncGroup = { export type SyncGroup = {
type: string; type: string;
id: string; id?: string;
source: boolean; // Source and target default to true if not specified
target: boolean; source?: boolean;
target?: boolean;
options?: Record<string, unknown>;
}; };
const POSITION = 'cameraposition'; const POSITION = 'cameraposition';
const VOI = 'voi'; const VOI = 'voi';
const ZOOMPAN = 'zoompan';
const STACKIMAGE = 'stackimage';
const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup =>
typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup;
export default class SyncGroupService { export default class SyncGroupService {
serviceManager: any; serviceManager: any;
listeners: { [key: string]: (...args: any[]) => void } = {}; listeners: { [key: string]: (...args: any[]) => void } = {};
EVENTS: { [key: string]: string }; EVENTS: { [key: string]: string };
synchronizerCreators: Record<string, SyncCreator> = {
[POSITION]: synchronizers.createCameraPositionSynchronizer,
[VOI]: synchronizers.createVOISynchronizer,
[ZOOMPAN]: synchronizers.createZoomPanSynchronizer,
[STACKIMAGE]: synchronizers.createStackImageSynchronizer,
};
constructor(serviceManager) { constructor(serviceManager) {
this.serviceManager = serviceManager; this.serviceManager = serviceManager;
@ -29,64 +55,95 @@ export default class SyncGroupService {
Object.assign(this, pubSubServiceInterface); Object.assign(this, pubSubServiceInterface);
} }
private _createSynchronizer(type: string, id: string) { private _createSynchronizer(
type = type.toLowerCase(); type: string,
if (type === POSITION) { id: string,
return synchronizers.createCameraPositionSynchronizer(id); options
} else if (type === VOI) { ): Synchronizer | undefined {
return synchronizers.createVOISynchronizer(id); const syncCreator = this.synchronizerCreators[type.toLowerCase()];
if (syncCreator) {
return syncCreator(id, options);
} else {
console.warn('Unknown synchronizer type', type, id);
} }
} }
/**
* Creates a synchronizer type.
* @param type is the type of the synchronizer to create
* @param creator
*/
public setSynchronizer(type: string, creator: SyncCreator): void {
this.synchronizerCreators[type.toLowerCase()] = creator;
}
protected _getOrCreateSynchronizer(
type: string,
id: string,
options: Record<string, unknown>
): Synchronizer | undefined {
let synchronizer = SynchronizerManager.getSynchronizer(id);
if (!synchronizer) {
synchronizer = this._createSynchronizer(type, id, options);
}
return synchronizer;
}
public addViewportToSyncGroup( public addViewportToSyncGroup(
viewportId: string, viewportId: string,
renderingEngineId: string, renderingEngineId: string,
syncGroups?: SyncGroup[] syncGroups?: SyncGroup | string | SyncGroup[] | string[]
): void { ): void {
if (!syncGroups || !syncGroups.length) { if (!syncGroups) {
return; return;
} }
syncGroups.forEach(syncGroup => { const syncGroupsArray = Array.isArray(syncGroups)
const { type, id, target, source } = syncGroup; ? syncGroups
: [syncGroups];
let synchronizer = SynchronizerManager.getSynchronizer(id); syncGroupsArray.forEach(syncGroup => {
const syncGroupObj = asSyncGroup(syncGroup);
const {
type,
target = true,
source = true,
options = {},
id = type,
} = syncGroupObj;
if (!synchronizer) { const synchronizer = this._getOrCreateSynchronizer(type, id, options);
synchronizer = this._createSynchronizer(type, id); synchronizer.setOptions(viewportId, options);
}
const viewportInfo = { viewportId, renderingEngineId };
if (target && source) { if (target && source) {
synchronizer.add({ synchronizer.add(viewportInfo);
viewportId,
renderingEngineId,
});
return; return;
} else if (source) { } else if (source) {
synchronizer.addSource({ synchronizer.addSource(viewportInfo);
viewportId,
renderingEngineId,
});
} else if (target) { } else if (target) {
synchronizer.addTarget({ synchronizer.addTarget(viewportInfo);
viewportId,
renderingEngineId,
});
} }
}); });
} }
public destroy() { public destroy(): void {
SynchronizerManager.destroy(); SynchronizerManager.destroy();
} }
public removeViewportFromSyncGroup( public removeViewportFromSyncGroup(
viewportId: string, viewportId: string,
renderingEngineId: string renderingEngineId: string,
syncGroupId?: string
): void { ): void {
const synchronizers = SynchronizerManager.getAllSynchronizers(); const synchronizers = SynchronizerManager.getAllSynchronizers();
synchronizers.forEach(synchronizer => { const filteredSynchronizers = syncGroupId
? synchronizers.filter(s => s.id === syncGroupId)
: synchronizers;
filteredSynchronizers.forEach(synchronizer => {
if (!synchronizer) { if (!synchronizer) {
return; return;
} }

View File

@ -72,7 +72,16 @@ export default class ToolGroupService {
this.toolGroupIds = new Set(); 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( const toolGroup = ToolGroupManager.getToolGroupForViewport(
viewportId, viewportId,
renderingEngineId renderingEngineId
@ -85,9 +94,10 @@ export default class ToolGroupService {
toolGroup.removeViewports(renderingEngineId, viewportId); toolGroup.removeViewports(renderingEngineId, viewportId);
const viewportIds = toolGroup.getViewportIds(); const viewportIds = toolGroup.getViewportIds();
// if (viewportIds.length === 0) {
// ToolGroupManager.destroyToolGroup(toolGroup.id); if (viewportIds.length === 0 && deleteToolGroupIfEmpty) {
// } ToolGroupManager.destroyToolGroup(toolGroup.id);
}
} }
public addViewportToToolGroup( public addViewportToToolGroup(
@ -110,7 +120,10 @@ export default class ToolGroupService {
toolGroup.addViewport(viewportId, renderingEngineId); toolGroup.addViewport(viewportId, renderingEngineId);
} }
this._broadcastEvent(EVENTS.VIEWPORT_ADDED, { viewportId, toolGroupId }); this._broadcastEvent(EVENTS.VIEWPORT_ADDED, {
viewportId,
toolGroupId,
});
} }
public createToolGroup(toolGroupId: string): Types.IToolGroup { public createToolGroup(toolGroupId: string): Types.IToolGroup {
@ -122,7 +135,9 @@ export default class ToolGroupService {
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
this.toolGroupIds.add(toolGroupId); this.toolGroupIds.add(toolGroupId);
this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, { toolGroupId }); this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, {
toolGroupId,
});
return toolGroup; return toolGroup;
} }
@ -195,9 +210,11 @@ export default class ToolGroupService {
private _getToolNames(toolGroupTools: Tools): string[] { private _getToolNames(toolGroupTools: Tools): string[] {
const toolNames = []; const toolNames = [];
if (toolGroupTools.active) {
toolGroupTools.active.forEach(tool => { toolGroupTools.active.forEach(tool => {
toolNames.push(tool.toolName); toolNames.push(tool.toolName);
}); });
}
if (toolGroupTools.passive) { if (toolGroupTools.passive) {
toolGroupTools.passive.forEach(tool => { toolGroupTools.passive.forEach(tool => {
toolNames.push(tool.toolName); toolNames.push(tool.toolName);
@ -221,9 +238,12 @@ export default class ToolGroupService {
private _setToolsMode(toolGroup, tools) { private _setToolsMode(toolGroup, tools) {
const { active, passive, enabled, disabled } = tools; const { active, passive, enabled, disabled } = tools;
if (active) {
active.forEach(({ toolName, bindings }) => { active.forEach(({ toolName, bindings }) => {
toolGroup.setToolActive(toolName, { bindings }); toolGroup.setToolActive(toolName, { bindings });
}); });
}
if (passive) { if (passive) {
passive.forEach(({ toolName }) => { passive.forEach(({ toolName }) => {

View File

@ -1,227 +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',
};
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,
initialImageIndex?: number
): Promise<StackData | VolumeData> {
const cs3DViewportType = getCornerstoneViewportType(viewportType);
let viewportData: StackData | VolumeData;
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;
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 { import {
RenderingEngine, RenderingEngine,
StackViewport, StackViewport,
Enums,
Types, Types,
getRenderingEngine, getRenderingEngine,
utilities as csUtils, utilities as csUtils,
@ -18,7 +17,10 @@ import ViewportInfo, {
DisplaySetOptions, DisplaySetOptions,
PublicViewportOptions, PublicViewportOptions,
} from './Viewport'; } from './Viewport';
import { StackData, VolumeData } from './CornerstoneCacheService'; import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
import { import {
setColormap, setColormap,
setLowerUpperColorTransferFunction, setLowerUpperColorTransferFunction,
@ -27,8 +29,8 @@ import {
import JumpPresets from '../../utils/JumpPresets'; import JumpPresets from '../../utils/JumpPresets';
const EVENTS = { const EVENTS = {
VIEWPORT_INFO_CREATED: VIEWPORT_DATA_CHANGED:
'event::cornerstone::viewportservice:viewportinfocreated', 'event::cornerstoneViewportService:viewportDataChanged',
}; };
/** /**
@ -39,6 +41,7 @@ class CornerstoneViewportService implements IViewportService {
renderingEngine: Types.IRenderingEngine | null; renderingEngine: Types.IRenderingEngine | null;
viewportsInfo: Map<number, ViewportInfo>; viewportsInfo: Map<number, ViewportInfo>;
viewportGridResizeObserver: ResizeObserver | null; viewportGridResizeObserver: ResizeObserver | null;
viewportsDisplaySets: Map<string, string[]> = new Map();
/** /**
* Service-specific * Service-specific
@ -50,6 +53,7 @@ class CornerstoneViewportService implements IViewportService {
enableResizeDetector: true; enableResizeDetector: true;
resizeRefreshRateMs: 200; resizeRefreshRateMs: 200;
resizeRefreshMode: 'debounce'; resizeRefreshMode: 'debounce';
servicesManager = null;
constructor(servicesManager) { constructor(servicesManager) {
this.renderingEngine = null; this.renderingEngine = null;
@ -58,8 +62,7 @@ class CornerstoneViewportService implements IViewportService {
// //
this.listeners = {}; this.listeners = {};
this.EVENTS = EVENTS; this.EVENTS = EVENTS;
const { HangingProtocolService } = servicesManager.services; this.servicesManager = servicesManager;
this.HangingProtocolService = HangingProtocolService;
Object.assign(this, pubSubServiceInterface); Object.assign(this, pubSubServiceInterface);
// //
} }
@ -69,18 +72,29 @@ class CornerstoneViewportService implements IViewportService {
* @param {*} viewportIndex * @param {*} viewportIndex
* @param {*} elementRef * @param {*} elementRef
*/ */
public enableElement( public enableViewport(
viewportIndex: number, viewportIndex: number,
viewportOptions: PublicViewportOptions, viewportOptions: PublicViewportOptions,
elementRef: HTMLDivElement elementRef: HTMLDivElement
) { ) {
const viewportId = const viewportInfo = new ViewportInfo(
viewportOptions.viewportId || this.getViewportId(viewportIndex); viewportIndex,
const viewportInfo = new ViewportInfo(viewportIndex, viewportId); this.getViewportId(viewportIndex)
);
viewportInfo.setElement(elementRef); viewportInfo.setElement(elementRef);
this.viewportsInfo.set(viewportIndex, viewportInfo); this.viewportsInfo.set(viewportIndex, viewportInfo);
} }
public getViewportIds(): string[] {
const viewportIds = [];
this.viewportsInfo.forEach(viewportInfo => {
viewportIds.push(viewportInfo.getViewportId());
});
return viewportIds;
}
public getViewportId(viewportIndex: number): string { public getViewportId(viewportIndex: number): string {
return `viewport-${viewportIndex}`; return `viewport-${viewportIndex}`;
} }
@ -110,10 +124,9 @@ class CornerstoneViewportService implements IViewportService {
*/ */
public resize() { public resize() {
const immediate = true; const immediate = true;
const resetPan = false; const keepCamera = true;
const resetZoom = false;
this.renderingEngine.resize(immediate, resetPan, resetZoom); this.renderingEngine.resize(immediate, keepCamera);
this.renderingEngine.render(); this.renderingEngine.render();
} }
@ -124,8 +137,9 @@ class CornerstoneViewportService implements IViewportService {
this._removeResizeObserver(); this._removeResizeObserver();
this.viewportGridResizeObserver = null; this.viewportGridResizeObserver = null;
this.renderingEngine.destroy(); this.renderingEngine.destroy();
this.viewportsDisplaySets.clear();
this.renderingEngine = null; this.renderingEngine = null;
cache.purgeVolumeCache(); cache.purgeCache();
} }
/** /**
@ -140,12 +154,11 @@ class CornerstoneViewportService implements IViewportService {
} }
const viewportId = viewportInfo.getViewportId(); const viewportId = viewportInfo.getViewportId();
this.renderingEngine.disableElement(viewportId);
this.viewportsInfo.delete(viewportIndex);
if (this.viewportsInfo.size === 0) { this.renderingEngine && this.renderingEngine.disableElement(viewportId);
this.destroy();
} this.viewportsInfo.get(viewportIndex).destroy();
this.viewportsInfo.delete(viewportIndex);
} }
/** /**
@ -156,18 +169,41 @@ class CornerstoneViewportService implements IViewportService {
* @param {*} dataSource * @param {*} dataSource
* @returns * @returns
*/ */
public setViewportDisplaySets( public setViewportData(
viewportIndex: number, viewportIndex: number,
viewportData: StackData | VolumeData, viewportData: StackViewportData | VolumeViewportData,
publicViewportOptions: PublicViewportOptions, publicViewportOptions: PublicViewportOptions,
publicDisplaySetOptions: DisplaySetOptions[] publicDisplaySetOptions: DisplaySetOptions[]
): void { ): void {
const renderingEngine = this.getRenderingEngine(); const renderingEngine = this.getRenderingEngine();
const viewportInfo = this.viewportsInfo.get(viewportIndex); const viewportInfo = this.viewportsInfo.get(viewportIndex);
if (!publicViewportOptions.viewportId) {
publicViewportOptions.viewportId = this.getViewportId(viewportIndex);
}
let viewportId = viewportInfo.getViewportId();
// if currently there is a viewport with the viewportId, but it is not the same
// as the one we are trying to set, we need to disable the old one
// and enable the new one, we could ideally change the name of the viewportId
// but the viewportId is an integral part in renderers map, tools svg cache
// etc. which would require a lot of refactoring, for now we will just disable
// the old one and enable the new one at the end of this function
let newViewportId = null;
if (publicViewportOptions?.viewportId !== viewportId) {
newViewportId = publicViewportOptions.viewportId;
viewportInfo.setViewportId(newViewportId);
renderingEngine.disableElement(viewportId);
}
viewportInfo.setRenderingEngineId(renderingEngine.id); viewportInfo.setRenderingEngineId(renderingEngine.id);
const { viewportOptions, displaySetOptions } = const {
this._getViewportAndDisplaySetOptions( viewportOptions,
displaySetOptions,
} = this._getViewportAndDisplaySetOptions(
publicViewportOptions, publicViewportOptions,
publicDisplaySetOptions, publicDisplaySetOptions,
viewportInfo viewportInfo
@ -175,10 +211,14 @@ class CornerstoneViewportService implements IViewportService {
viewportInfo.setViewportOptions(viewportOptions); viewportInfo.setViewportOptions(viewportOptions);
viewportInfo.setDisplaySetOptions(displaySetOptions); 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(); viewportId = viewportInfo.getViewportId();
const element = viewportInfo.getElement(); const element = viewportInfo.getElement();
const type = viewportInfo.getViewportType(); const type = viewportInfo.getViewportType();
const background = viewportInfo.getBackground(); const background = viewportInfo.getBackground();
@ -199,7 +239,9 @@ class CornerstoneViewportService implements IViewportService {
// renderingEngine is designed to be used like this. This will trigger // renderingEngine is designed to be used like this. This will trigger
// ENABLED_ELEMENT again and again, which will run onEnableElement callbacks // ENABLED_ELEMENT again and again, which will run onEnableElement callbacks
renderingEngine.enableElement(viewportInput); renderingEngine.enableElement(viewportInput);
this._setDisplaySets(viewportId, viewportData, viewportInfo);
const viewport = renderingEngine.getViewport(viewportId);
this._setDisplaySets(viewport, viewportData, viewportInfo);
} }
public getCornerstoneViewport( public getCornerstoneViewport(
@ -261,12 +303,18 @@ class CornerstoneViewportService implements IViewportService {
_setStackViewport( _setStackViewport(
viewport: Types.IStackViewport, viewport: Types.IStackViewport,
viewportData: StackData, viewportData: StackViewportData,
viewportInfo: ViewportInfo viewportInfo: ViewportInfo
) { ) {
const displaySetOptions = viewportInfo.getDisplaySetOptions(); const displaySetOptions = viewportInfo.getDisplaySetOptions();
const { imageIds, initialImageIndex } = viewportData; const {
imageIds,
initialImageIndex,
displaySetInstanceUID,
} = viewportData.data;
this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]);
let initialImageIndexToUse = initialImageIndex; let initialImageIndexToUse = initialImageIndex;
@ -328,7 +376,15 @@ class CornerstoneViewportService implements IViewportService {
} }
if (preset === JumpPresets.Middle) { 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; return 0;
@ -336,7 +392,7 @@ class CornerstoneViewportService implements IViewportService {
async _setVolumeViewport( async _setVolumeViewport(
viewport: Types.IVolumeViewport, viewport: Types.IVolumeViewport,
viewportData: VolumeData, viewportData: VolumeViewportData,
viewportInfo: ViewportInfo viewportInfo: ViewportInfo
): Promise<void> { ): Promise<void> {
// TODO: We need to overhaul the way data sources work so requests can be made // TODO: We need to overhaul the way data sources work so requests can be made
@ -352,21 +408,24 @@ class CornerstoneViewportService implements IViewportService {
// (This call may or may not create sub-requests for series metadata) // (This call may or may not create sub-requests for series metadata)
const volumeInputArray = []; const volumeInputArray = [];
const displaySetOptionsArray = viewportInfo.getDisplaySetOptions(); const displaySetOptionsArray = viewportInfo.getDisplaySetOptions();
const { HangingProtocolService } = this; const { HangingProtocolService } = this.servicesManager.services;
for (let i = 0; i < viewportData.imageIds.length; i++) { const volumeToLoad = [];
const imageIds = viewportData.imageIds[i]; const displaySetInstanceUIDs = [];
const displaySetInstanceUID = viewportData.displaySetInstanceUIDs[i];
const displaySetOptions = displaySetOptionsArray[i];
const volumeId = displaySetInstanceUID; for (const [index, data] of viewportData.data.entries()) {
const { volume, imageIds, displaySetInstanceUID } = data;
// if (displaySet.needsRerendering) { displaySetInstanceUIDs.push(displaySetInstanceUID);
// console.warn('Removing volume from cache', volumeId);
// cache.removeVolumeLoadObject(volumeId); if (!volume) {
// displaySet.needsRerendering = false; continue;
// this.displaySetsNeedRerendering.add(displaySet.displaySetInstanceUID); }
// }
volumeToLoad.push(volume);
const displaySetOptions = displaySetOptionsArray[index];
const { volumeId } = volume;
const voiCallbacks = this._getVOICallbacks(volumeId, displaySetOptions); const voiCallbacks = this._getVOICallbacks(volumeId, displaySetOptions);
@ -383,6 +442,8 @@ class CornerstoneViewportService implements IViewportService {
}); });
} }
this.viewportsDisplaySets.set(viewport.id, displaySetInstanceUIDs);
if ( if (
HangingProtocolService.hasCustomImageLoadStrategy() && HangingProtocolService.hasCustomImageLoadStrategy() &&
!HangingProtocolService.customImageLoadPerformed !HangingProtocolService.customImageLoadPerformed
@ -394,7 +455,7 @@ class CornerstoneViewportService implements IViewportService {
}); });
} }
viewportData.volumes.forEach(volume => { volumeToLoad.forEach(volume => {
volume.load(); volume.load();
}); });
@ -403,9 +464,98 @@ class CornerstoneViewportService implements IViewportService {
} }
public async setVolumesForViewport(viewport, volumeInputArray) { public async setVolumesForViewport(viewport, volumeInputArray) {
const {
DisplaySetService,
SegmentationService,
ToolGroupService,
} = this.servicesManager.services;
await viewport.setVolumes(volumeInputArray); 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 viewportInfo = this.getViewportInfo(viewport.id);
const toolGroup = ToolGroupService.getToolGroupForViewport(viewport.id);
csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
const initialImageOptions = viewportInfo.getInitialImageOptions(); const initialImageOptions = viewportInfo.getInitialImageOptions();
if ( if (
@ -415,8 +565,9 @@ class CornerstoneViewportService implements IViewportService {
) { ) {
const { index, preset } = initialImageOptions; const { index, preset } = initialImageOptions;
const { numberOfSlices } = const { numberOfSlices } = csUtils.getImageSliceDataForVolumeViewport(
csUtils.getImageSliceDataForVolumeViewport(viewport); viewport
);
const imageIndex = this._getInitialImageIndex( const imageIndex = this._getInitialImageIndex(
numberOfSlices, numberOfSlices,
@ -488,18 +639,20 @@ class CornerstoneViewportService implements IViewportService {
} }
_setDisplaySets( _setDisplaySets(
viewportId: string, viewport: StackViewport | VolumeViewport,
viewportData: StackData | VolumeData, viewportData: StackViewportData | VolumeViewportData,
viewportInfo: ViewportInfo viewportInfo: ViewportInfo
): void { ): void {
const viewport = this.getCornerstoneViewport(viewportId);
if (viewport instanceof StackViewport) { if (viewport instanceof StackViewport) {
this._setStackViewport(viewport, viewportData as StackData, viewportInfo); this._setStackViewport(
viewport,
viewportData as StackViewportData,
viewportInfo
);
} else if (viewport instanceof VolumeViewport) { } else if (viewport instanceof VolumeViewport) {
this._setVolumeViewport( this._setVolumeViewport(
viewport, viewport,
viewportData as VolumeData, viewportData as VolumeViewportData,
viewportInfo viewportInfo
); );
} else { } else {
@ -574,6 +727,31 @@ class CornerstoneViewportService implements IViewportService {
displaySetOptions: newDisplaySetOptions, 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) { export default function ExtendedCornerstoneViewportService(serviceManager) {

View File

@ -1,5 +1,5 @@
import { Types } from '@cornerstonejs/core'; import { Types } from '@cornerstonejs/core';
import { StackData, VolumeData } from './CornerstoneCacheService'; import { StackData, VolumeData } from '../../types/CornerstoneCacheService';
import { import {
DisplaySetOptions, DisplaySetOptions,
PublicViewportOptions, PublicViewportOptions,
@ -31,7 +31,7 @@ export interface IViewportService {
* @param {*} viewportIndex * @param {*} viewportIndex
* @param {*} elementRef * @param {*} elementRef
*/ */
enableElement( enableViewport(
viewportIndex: number, viewportIndex: number,
viewportOptions: ViewportOptions, viewportOptions: ViewportOptions,
elementRef: HTMLDivElement elementRef: HTMLDivElement
@ -65,7 +65,7 @@ export interface IViewportService {
* @param {*} dataSource * @param {*} dataSource
* @returns * @returns
*/ */
setViewportDisplaySets( setViewportData(
viewportIndex: number, viewportIndex: number,
viewportData: StackData | VolumeData, viewportData: StackData | VolumeData,
publicViewportOptions: PublicViewportOptions, publicViewportOptions: PublicViewportOptions,

View File

@ -1,9 +1,13 @@
import { Types, Enums, CONSTANTS } from '@cornerstonejs/core'; import { Types, Enums } from '@cornerstonejs/core';
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode'; import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation'; import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import JumpPresets from '../../utils/JumpPresets'; import JumpPresets from '../../utils/JumpPresets';
import { SyncGroup } from '../SyncGroupService/SyncGroupService'; import { SyncGroup } from '../SyncGroupService/SyncGroupService';
import {
StackViewportData,
VolumeViewportData,
} from './CornerstoneCacheService';
export type InitialImageOptions = { export type InitialImageOptions = {
index?: number; index?: number;
@ -16,7 +20,6 @@ export type ViewportOptions = {
viewportId: string; viewportId: string;
orientation?: Types.Orientation; orientation?: Types.Orientation;
background?: Types.Point3; background?: Types.Point3;
initialView?: string;
syncGroups?: SyncGroup[]; syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions; initialImageOptions?: InitialImageOptions;
customViewportProps?: Record<string, unknown>; customViewportProps?: Record<string, unknown>;
@ -28,7 +31,6 @@ export type PublicViewportOptions = {
viewportId?: string; viewportId?: string;
orientation?: string; orientation?: string;
background?: Types.Point3; background?: Types.Point3;
initialView?: string;
syncGroups?: SyncGroup[]; syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions; initialImageOptions?: InitialImageOptions;
customViewportProps?: Record<string, unknown>; customViewportProps?: Record<string, unknown>;
@ -69,6 +71,7 @@ class ViewportInfo {
private element: HTMLDivElement; private element: HTMLDivElement;
private viewportOptions: ViewportOptions; private viewportOptions: ViewportOptions;
private displaySetOptions: Array<DisplaySetOptions>; private displaySetOptions: Array<DisplaySetOptions>;
private viewportData: StackViewportData | VolumeViewportData;
private renderingEngineId: string; private renderingEngineId: string;
constructor(viewportIndex: number, viewportId: string) { constructor(viewportIndex: number, viewportId: string) {
@ -78,6 +81,13 @@ class ViewportInfo {
this.setPublicDisplaySetOptions([{}]); this.setPublicDisplaySetOptions([{}]);
} }
public destroy = (): void => {
this.element = null;
this.viewportData = null;
this.viewportOptions = null;
this.displaySetOptions = null;
};
public setRenderingEngineId(renderingEngineId: string): void { public setRenderingEngineId(renderingEngineId: string): void {
this.renderingEngineId = renderingEngineId; this.renderingEngineId = renderingEngineId;
} }
@ -97,6 +107,16 @@ class ViewportInfo {
this.element = element; this.element = element;
} }
public setViewportData(
viewportData: StackViewportData | VolumeViewportData
): void {
this.viewportData = viewportData;
}
public getViewportData(): StackViewportData | VolumeViewportData {
return this.viewportData;
}
public getViewportIndex(): number { public getViewportIndex(): number {
return this.viewportIndex; return this.viewportIndex;
} }
@ -120,6 +140,23 @@ class ViewportInfo {
this.setDisplaySetOptions(displaySetOptions); 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( public setPublicViewportOptions(
viewportOptionsEntry: PublicViewportOptions viewportOptionsEntry: PublicViewportOptions
): void { ): void {
@ -139,7 +176,7 @@ class ViewportInfo {
if (viewportOptionsEntry.viewportType?.toLowerCase() === VOLUME) { if (viewportOptionsEntry.viewportType?.toLowerCase() === VOLUME) {
orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation); orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
} else { } else {
orientation = CONSTANTS.ORIENTATION.AXIAL; orientation = Enums.OrientationAxis.AXIAL;
} }
if (!toolGroupId) { if (!toolGroupId) {
@ -203,12 +240,21 @@ class ViewportInfo {
const displaySetOptions: Array<DisplaySetOptions> = []; const displaySetOptions: Array<DisplaySetOptions> = [];
publicDisplaySetOptions.forEach(option => { publicDisplaySetOptions.forEach(option => {
if (!option) {
option = {
blendMode: undefined,
slabThickness: undefined,
colormap: undefined,
voi: {},
voiInverted: false,
};
}
const blendMode = getCornerstoneBlendMode(option.blendMode); const blendMode = getCornerstoneBlendMode(option.blendMode);
displaySetOptions.push({ displaySetOptions.push({
voi: option.voi || ({} as VOI), voi: option.voi,
voiInverted: option.voiInverted || false, voiInverted: option.voiInverted,
colormap: option.colormap || undefined, colormap: option.colormap,
slabThickness: option.slabThickness, slabThickness: option.slabThickness,
blendMode, blendMode,
}); });

View File

@ -31,4 +31,8 @@ const getEnabledElement = viewportIndex => {
return state.enabledElements[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 React, { useEffect, useState } from 'react';
import domtoimage from 'dom-to-image'; import html2canvas from 'html2canvas';
import { import {
Enums, Enums,
getEnabledElement, getEnabledElement,
getOrCreateCanvas, getOrCreateCanvas,
StackViewport, StackViewport,
VolumeViewport,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { ToolGroupManager } from '@cornerstonejs/tools'; import { ToolGroupManager } from '@cornerstonejs/tools';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -17,7 +17,6 @@ const MINIMUM_SIZE = 100;
const DEFAULT_SIZE = 512; const DEFAULT_SIZE = 512;
const MAX_TEXTURE_SIZE = 10000; const MAX_TEXTURE_SIZE = 10000;
const VIEWPORT_ID = 'cornerstone-viewport-download-form'; const VIEWPORT_ID = 'cornerstone-viewport-download-form';
const TOOLGROUP_ID = 'cornerstone-viewport-download-form-toolgroup';
const CornerstoneViewportDownloadForm = ({ const CornerstoneViewportDownloadForm = ({
onClose, onClose,
@ -26,15 +25,57 @@ const CornerstoneViewportDownloadForm = ({
}) => { }) => {
const enabledElement = OHIFgetEnabledElement(activeViewportIndex); const enabledElement = OHIFgetEnabledElement(activeViewportIndex);
const activeViewportElement = enabledElement?.element; 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 => { const enableViewport = viewportElement => {
if (viewportElement) { if (viewportElement) {
const renderingEngine = CornerstoneViewportService.getRenderingEngine(); const { renderingEngine, viewport } = getEnabledElement(
activeViewportElement
);
const viewportInput = { const viewportInput = {
viewportId: VIEWPORT_ID, viewportId: VIEWPORT_ID,
element: viewportElement, element: viewportElement,
type: Enums.ViewportType.STACK, type: viewport.type,
defaultOptions: {
background: viewport.defaultOptions.background,
orientation: viewport.defaultOptions.orientation,
},
}; };
renderingEngine.enableElement(viewportInput); renderingEngine.enableElement(viewportInput);
@ -43,11 +84,9 @@ const CornerstoneViewportDownloadForm = ({
const disableViewport = viewportElement => { const disableViewport = viewportElement => {
if (viewportElement) { if (viewportElement) {
const renderingEngine = CornerstoneViewportService.getRenderingEngine(); const { renderingEngine } = getEnabledElement(viewportElement);
return new Promise(resolve => { return new Promise(resolve => {
renderingEngine.disableElement(VIEWPORT_ID); renderingEngine.disableElement(VIEWPORT_ID);
ToolGroupManager.destroyToolGroup(TOOLGROUP_ID);
}); });
} }
}; };
@ -115,26 +154,39 @@ const CornerstoneViewportDownloadForm = ({
const { viewport } = activeViewportEnabledElement; const { viewport } = activeViewportEnabledElement;
if (!(viewport instanceof StackViewport)) {
throw new Error('Viewport is not a StackViewport');
}
const imageId = viewport.getCurrentImageId();
const renderingEngine = CornerstoneViewportService.getRenderingEngine(); const renderingEngine = CornerstoneViewportService.getRenderingEngine();
const downloadViewport = renderingEngine.getViewport( const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
VIEWPORT_ID
) as StackViewport; if (downloadViewport instanceof StackViewport) {
const imageId = viewport.getCurrentImageId();
const properties = viewport.getProperties();
downloadViewport.setStack([imageId]).then(() => { downloadViewport.setStack([imageId]).then(() => {
const properties = viewport.getProperties();
downloadViewport.setProperties(properties); 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 newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE);
const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE);
resolve({ width: newWidth, height: newHeight }); resolve({ width: newWidth, height: newHeight });
}); }
} }
}); });
@ -164,31 +216,20 @@ const CornerstoneViewportDownloadForm = ({
renderingEngineId renderingEngineId
); );
let downloadToolGroup = ToolGroupManager.getToolGroupForViewport(
downloadViewportId,
renderingEngineId
);
if (downloadToolGroup === undefined) {
downloadToolGroup = ToolGroupManager.createToolGroup(TOOLGROUP_ID);
// what tools were in the active viewport?
// make them all enabled instances so that they can not be interacted
// with in the download viewport
Object.values(toolGroup._toolInstances).forEach(tool => {
downloadToolGroup.addTool(tool.getToolName());
});
// add the viewport to the toolGroup // add the viewport to the toolGroup
downloadToolGroup.addViewport(downloadViewportId); toolGroup.addViewport(downloadViewportId);
}
Object.values(downloadToolGroup._toolInstances).forEach(tool => { Object.keys(toolGroup._toolInstances).forEach(toolName => {
const toolName = tool.getToolName(); // make all tools Enabled so that they can not be interacted with
if (toggle) { // in the download viewport
downloadToolGroup.setToolEnabled(toolName); if (toggle && toolName !== 'Crosshairs') {
try {
toolGroup.setToolEnabled(toolName);
} catch (e) {
console.log(e);
}
} else { } else {
downloadToolGroup.setToolDisabled(toolName); toolGroup.setToolDisabled(toolName);
} }
}); });
}; };
@ -199,10 +240,10 @@ const CornerstoneViewportDownloadForm = ({
`div[data-viewport-uid="${VIEWPORT_ID}"]` `div[data-viewport-uid="${VIEWPORT_ID}"]`
); );
domtoimage.toPng(divForDownloadViewport).then(dataUrl => { html2canvas(divForDownloadViewport).then(canvas => {
const link = document.createElement('a'); const link = document.createElement('a');
link.download = file; link.download = file;
link.href = dataUrl; link.href = canvas.toDataURL(fileType, 1.0);
link.click(); link.click();
}); });
}; };

View File

@ -38,6 +38,10 @@ const getImageInstance = dataset => {
return dataset && dataset.images && dataset.images[0]; return dataset && dataset.images && dataset.images[0];
}; };
const getNonImageInstance = dataset => {
return dataset && dataset.instance;
};
const getImageInstanceId = imageInstance => { const getImageInstanceId = imageInstance => {
return getImageId(imageInstance); return getImageId(imageInstance);
}; };
@ -89,10 +93,20 @@ const getImageLoaderType = imageId => {
class DicomLoaderService { class DicomLoaderService {
getLocalData(dataset, studies) { getLocalData(dataset, studies) {
if (dataset && dataset.localFile) {
// Use referenced imageInstance // Use referenced imageInstance
const imageInstance = getImageInstance(dataset); const imageInstance = getImageInstance(dataset);
let imageId = getImageInstanceId(imageInstance); const nonImageInstance = getNonImageInstance(dataset);
if (
(!imageInstance && !nonImageInstance) ||
!nonImageInstance.imageId.startsWith('dicomfile')
) {
return;
}
const instance = imageInstance || nonImageInstance;
let imageId = getImageInstanceId(instance);
// or Try to get it from studies // or Try to get it from studies
if (someInvalidStrings(imageId)) { if (someInvalidStrings(imageId)) {
@ -103,7 +117,6 @@ class DicomLoaderService {
return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId); return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId);
} }
} }
}
getDataByImageType(dataset) { getDataByImageType(dataset) {
const imageInstance = getImageInstance(dataset); const imageInstance = getImageInstance(dataset);
@ -182,13 +195,14 @@ class DicomLoaderService {
} }
} }
*getLoaderIterator(dataset, studies) { *getLoaderIterator(dataset, studies, headers) {
yield this.getLocalData(dataset, studies); yield this.getLocalData(dataset, studies);
yield this.getDataByImageType(dataset); yield this.getDataByImageType(dataset);
yield this.getDataByDatasetType(dataset); yield this.getDataByDatasetType(dataset);
} }
findDicomDataPromise(dataset, studies) { findDicomDataPromise(dataset, studies, headers) {
dataset.authorizationHeaders = headers;
const loaderIterator = this.getLoaderIterator(dataset, studies); const loaderIterator = this.getLoaderIterator(dataset, studies);
// it returns first valid retriever method. // it returns first valid retriever method.
for (const loader of loaderIterator) { for (const loader of loaderIterator) {

View File

@ -1,4 +1,4 @@
import { CONSTANTS } from '@cornerstonejs/core'; import { Enums } from '@cornerstonejs/core';
import { log } from '@ohif/core'; import { log } from '@ohif/core';
const AXIAL = 'axial'; const AXIAL = 'axial';
@ -7,16 +7,20 @@ const CORONAL = 'coronal';
export default function getCornerstoneOrientation( export default function getCornerstoneOrientation(
orientation: string orientation: string
): CONSTANTS.ORIENTATION { ): Enums.OrientationAxis {
if (orientation) {
switch (orientation.toLowerCase()) { switch (orientation.toLowerCase()) {
case AXIAL: case AXIAL:
return CONSTANTS.ORIENTATION.AXIAL; return Enums.OrientationAxis.AXIAL;
case SAGITTAL: case SAGITTAL:
return CONSTANTS.ORIENTATION.SAGITTAL; return Enums.OrientationAxis.SAGITTAL;
case CORONAL: case CORONAL:
return CONSTANTS.ORIENTATION.CORONAL; return Enums.OrientationAxis.CORONAL;
default: default:
log.wanr('Choosing default orientation: axial'); log.wanr('Choosing acquisition plane orientation');
return CONSTANTS.ORIENTATION.AXIAL; return Enums.OrientationAxis.ACQUISITION;
} }
}
return Enums.OrientationAxis.ACQUISITION;
} }

View File

@ -8,8 +8,8 @@ const viewportIdVolumeInputArrayMap = new Map<string, unknown[]>();
/** /**
* This function caches the volumeUIDs until all the volumes inside the * This function caches the volumeUIDs until all the volumes inside the
* hangging protocol are initialized. Then it goes through the imageIds * hanging protocol are initialized. Then it goes through the imageIds
* of the volumes, and interleav them, in order for the volumes to be loaded * of the volumes, and interleave them, in order for the volumes to be loaded
* together from middle to the start and the end. * together from middle to the start and the end.
* @param {Object} props image loading properties from Cornerstone ViewportService * @param {Object} props image loading properties from Cornerstone ViewportService
* @returns * @returns
@ -17,7 +17,7 @@ const viewportIdVolumeInputArrayMap = new Map<string, unknown[]>();
export default function interleaveCenterLoader({ export default function interleaveCenterLoader({
data: { viewportId, volumeInputArray }, data: { viewportId, volumeInputArray },
displaySetsMatchDetails, displaySetsMatchDetails,
matchDetails, viewportMatchDetails: matchDetails,
}) { }) {
viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray); viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray);
@ -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 * listen to it and as the other viewports are created we can set the volumes for them
* since volumes are already started loading. * since volumes are already started loading.
*/ */
if (matchDetails.length !== viewportIdVolumeInputArrayMap.size) { if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) {
return; return;
} }

View File

@ -16,7 +16,7 @@ const viewportIdVolumeInputArrayMap = new Map<string, unknown[]>();
export default function interleaveTopToBottom({ export default function interleaveTopToBottom({
data: { viewportId, volumeInputArray }, data: { viewportId, volumeInputArray },
displaySetsMatchDetails, displaySetsMatchDetails,
matchDetails, viewportMatchDetails: matchDetails,
}) { }) {
viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray); viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray);
@ -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 * listen to it and as the other viewports are created we can set the volumes for them
* since volumes are already started loading. * since volumes are already started loading.
*/ */
if (matchDetails.length !== viewportIdVolumeInputArrayMap.size) { if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) {
return; return;
} }

View File

@ -69,6 +69,7 @@ const Length = {
metadata, metadata,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName, toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.text, label: data.text,
@ -90,13 +91,16 @@ function getMappedAnnotations(annotation, DisplaySetService) {
const annotations = []; const annotations = [];
const { SOPInstanceUID, SeriesInstanceUID } = getSOPInstanceAttributes( const {
referencedImageId SOPInstanceUID,
); SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID( const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID, SOPInstanceUID,
SeriesInstanceUID SeriesInstanceUID,
frameNumber
); );
const { SeriesNumber } = displaySet; const { SeriesNumber } = displaySet;
@ -105,6 +109,7 @@ function getMappedAnnotations(annotation, DisplaySetService) {
SeriesInstanceUID, SeriesInstanceUID,
SOPInstanceUID, SOPInstanceUID,
SeriesNumber, SeriesNumber,
frameNumber,
text, text,
}); });
@ -119,7 +124,7 @@ function getDisplayText(mappedAnnotations, displaySet) {
const displayText = []; const displayText = [];
// Area is the same for all series // Area is the same for all series
const { SeriesNumber, SOPInstanceUID } = mappedAnnotations[0]; const { SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0];
const instance = displaySet.images.find( const instance = displaySet.images.find(
image => image.SOPInstanceUID === SOPInstanceUID image => image.SOPInstanceUID === SOPInstanceUID
@ -130,11 +135,10 @@ function getDisplayText(mappedAnnotations, displaySet) {
InstanceNumber = instance.InstanceNumber; InstanceNumber = instance.InstanceNumber;
} }
displayText.push( const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
InstanceNumber const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
? `(S: ${SeriesNumber} I: ${InstanceNumber})`
: `(S: ${SeriesNumber})` displayText.push(`(S: ${SeriesNumber}${instanceText}${frameText})`);
);
return displayText; return displayText;
} }

View File

@ -67,6 +67,7 @@ const Bidirectional = {
metadata, metadata,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName, toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.label, label: data.label,
@ -92,30 +93,33 @@ function getMappedAnnotations(annotation, DisplaySetService) {
Object.keys(cachedStats).forEach(targetId => { Object.keys(cachedStats).forEach(targetId => {
const targetStats = cachedStats[targetId]; const targetStats = cachedStats[targetId];
let displaySet; if (!referencedImageId) {
if (referencedImageId) {
const { SOPInstanceUID, SeriesInstanceUID } = getSOPInstanceAttributes(
referencedImageId
);
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
throw new Error( throw new Error(
'Non-acquisition plane measurement mapping not supported' 'Non-acquisition plane measurement mapping not supported'
); );
} }
const { SeriesNumber, SeriesInstanceUID } = displaySet; const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID,
frameNumber
);
const { SeriesNumber } = displaySet;
const { length, width } = targetStats; const { length, width } = targetStats;
const unit = 'mm'; const unit = 'mm';
annotations.push({ annotations.push({
SeriesInstanceUID, SeriesInstanceUID,
SOPInstanceUID,
SeriesNumber, SeriesNumber,
frameNumber,
unit, unit,
length, length,
width, width,
@ -171,7 +175,13 @@ function getDisplayText(mappedAnnotations, displaySet) {
const displayText = []; const displayText = [];
// Area is the same for all series // Area is the same for all series
const { length, width, SeriesNumber, SOPInstanceUID } = mappedAnnotations[0]; const {
length,
width,
SeriesNumber,
SOPInstanceUID,
frameNumber,
} = mappedAnnotations[0];
const roundedLength = utils.roundNumber(length, 2); const roundedLength = utils.roundNumber(length, 2);
const roundedWidth = utils.roundNumber(width, 2); const roundedWidth = utils.roundNumber(width, 2);
@ -184,10 +194,11 @@ function getDisplayText(mappedAnnotations, displaySet) {
InstanceNumber = instance.InstanceNumber; InstanceNumber = instance.InstanceNumber;
} }
const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
displayText.push( displayText.push(
InstanceNumber `L: ${roundedLength} mm (S: ${SeriesNumber}${instanceText}${frameText})`
? `L: ${roundedLength} mm (S: ${SeriesNumber} I: ${InstanceNumber})`
: `L: ${roundedLength} mm (S: ${SeriesNumber})`
); );
displayText.push(`W: ${roundedWidth} mm`); displayText.push(`W: ${roundedWidth} mm`);

View File

@ -66,6 +66,7 @@ const EllipticalROI = {
metadata, metadata,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName, toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.label, label: data.label,
@ -91,31 +92,34 @@ function getMappedAnnotations(annotation, DisplaySetService) {
Object.keys(cachedStats).forEach(targetId => { Object.keys(cachedStats).forEach(targetId => {
const targetStats = cachedStats[targetId]; const targetStats = cachedStats[targetId];
let displaySet; if (!referencedImageId) {
if (referencedImageId) {
const { SOPInstanceUID, SeriesInstanceUID } = getSOPInstanceAttributes(
referencedImageId
);
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
// Todo: Non-acquisition plane measurement mapping not supported yet // Todo: Non-acquisition plane measurement mapping not supported yet
throw new Error( throw new Error(
'Non-acquisition plane measurement mapping not supported' 'Non-acquisition plane measurement mapping not supported'
); );
} }
const { SeriesNumber, SeriesInstanceUID } = displaySet; const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID,
frameNumber
);
const { SeriesNumber } = displaySet;
const { mean, stdDev, max, area, Modality } = targetStats; const { mean, stdDev, max, area, Modality } = targetStats;
const unit = getModalityUnit(Modality); const unit = getModalityUnit(Modality);
annotations.push({ annotations.push({
SeriesInstanceUID, SeriesInstanceUID,
SOPInstanceUID,
SeriesNumber, SeriesNumber,
frameNumber,
Modality, Modality,
unit, unit,
mean, mean,
@ -184,7 +188,7 @@ function getDisplayText(mappedAnnotations, displaySet) {
const displayText = []; const displayText = [];
// Area is the same for all series // Area is the same for all series
const { area, SOPInstanceUID } = mappedAnnotations[0]; const { area, SOPInstanceUID, frameNumber } = mappedAnnotations[0];
const instance = displaySet.images.find( const instance = displaySet.images.find(
image => image.SOPInstanceUID === SOPInstanceUID image => image.SOPInstanceUID === SOPInstanceUID
@ -195,21 +199,26 @@ function getDisplayText(mappedAnnotations, displaySet) {
InstanceNumber = instance.InstanceNumber; InstanceNumber = instance.InstanceNumber;
} }
const roundedArea = utils.roundNumber(area, 2); const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
// Area sometimes becomes undefined if `preventHandleOutsideImage` is off.
const roundedArea = utils.roundNumber(area || 0, 2);
displayText.push(`${roundedArea} mm<sup>2</sup>`); displayText.push(`${roundedArea} mm<sup>2</sup>`);
// Todo: we need a better UI for displaying all these information // Todo: we need a better UI for displaying all these information
mappedAnnotations.forEach(mappedAnnotation => { mappedAnnotations.forEach(mappedAnnotation => {
const { unit, max, SeriesNumber } = mappedAnnotation; const { unit, max, SeriesNumber } = mappedAnnotation;
let maxStr = '';
if (max) { if (max) {
const roundedMax = utils.roundNumber(max, 2); const roundedMax = utils.roundNumber(max, 2);
maxStr = `Max: ${roundedMax} <small>${unit}</small> `;
}
displayText.push( const str = `${maxStr}(S:${SeriesNumber}${instanceText}${frameText})`;
InstanceNumber if (!displayText.includes(str)) {
? `Max: ${roundedMax} <small>${unit}</small> (S:${SeriesNumber} I:${InstanceNumber})` displayText.push(str);
: `Max: ${roundedMax} <small>${unit}</small> (S:${SeriesNumber})`
);
} }
}); });

View File

@ -72,6 +72,7 @@ const Length = {
metadata, metadata,
referenceSeriesUID: SeriesInstanceUID, referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID, referenceStudyUID: StudyInstanceUID,
frameNumber: mappedAnnotations[0]?.frameNumber || 1,
toolName: metadata.toolName, toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID, displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: data.label, label: data.label,
@ -97,30 +98,33 @@ function getMappedAnnotations(annotation, DisplaySetService) {
Object.keys(cachedStats).forEach(targetId => { Object.keys(cachedStats).forEach(targetId => {
const targetStats = cachedStats[targetId]; const targetStats = cachedStats[targetId];
let displaySet; if (!referencedImageId) {
if (referencedImageId) {
const { SOPInstanceUID, SeriesInstanceUID } = getSOPInstanceAttributes(
referencedImageId
);
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
throw new Error( throw new Error(
'Non-acquisition plane measurement mapping not supported' 'Non-acquisition plane measurement mapping not supported'
); );
} }
const { SeriesNumber, SeriesInstanceUID } = displaySet; const {
SOPInstanceUID,
SeriesInstanceUID,
frameNumber,
} = getSOPInstanceAttributes(referencedImageId);
const displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID,
frameNumber
);
const { SeriesNumber } = displaySet;
const { length } = targetStats; const { length } = targetStats;
const unit = 'mm'; const unit = 'mm';
annotations.push({ annotations.push({
SeriesInstanceUID, SeriesInstanceUID,
SOPInstanceUID,
SeriesNumber, SeriesNumber,
frameNumber,
unit, unit,
length, length,
}); });
@ -175,7 +179,12 @@ function getDisplayText(mappedAnnotations, displaySet) {
const displayText = []; const displayText = [];
// Area is the same for all series // Area is the same for all series
const { length, SeriesNumber, SOPInstanceUID } = mappedAnnotations[0]; const {
length,
SeriesNumber,
SOPInstanceUID,
frameNumber,
} = mappedAnnotations[0];
const instance = displaySet.images.find( const instance = displaySet.images.find(
image => image.SOPInstanceUID === SOPInstanceUID image => image.SOPInstanceUID === SOPInstanceUID
@ -186,11 +195,12 @@ function getDisplayText(mappedAnnotations, displaySet) {
InstanceNumber = instance.InstanceNumber; InstanceNumber = instance.InstanceNumber;
} }
const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : '';
const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : '';
const roundedLength = utils.roundNumber(length, 2); const roundedLength = utils.roundNumber(length, 2);
displayText.push( displayText.push(
InstanceNumber `${roundedLength} mm (S: ${SeriesNumber}${instanceText}${frameText})`
? `${roundedLength} mm (S: ${SeriesNumber} I: ${InstanceNumber})`
: `${roundedLength} mm (S: ${SeriesNumber})`
); );
return displayText; return displayText;

View File

@ -45,6 +45,7 @@ function _getUIDFromImageID(imageId) {
SOPInstanceUID: instance.SOPInstanceUID, SOPInstanceUID: instance.SOPInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID, SeriesInstanceUID: instance.SeriesInstanceUID,
StudyInstanceUID: instance.StudyInstanceUID, StudyInstanceUID: instance.StudyInstanceUID,
frameNumber: instance.frameNumber || 1,
}; };
} }

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

@ -0,0 +1,285 @@
import { Enums } from '@cornerstonejs/tools';
import removeToolGroupSegmentationRepresentations from '../removeToolGroupSegmentationRepresentations';
const MPR_TOOLGROUP_ID = 'mpr';
const cachedState = {
protocol: null,
stage: null,
viewportMatchDetails: null,
viewportStructure: null,
toolOptions: null,
};
const setCachedState = (
protocol,
stage,
viewportMatchDetails,
viewportStructure,
toolOptions
) => {
cachedState.protocol = protocol;
cachedState.stage = stage;
cachedState.viewportMatchDetails = viewportMatchDetails;
cachedState.viewportStructure = viewportStructure;
cachedState.toolOptions = JSON.parse(JSON.stringify(toolOptions));
};
const resetCachedState = () => {
cachedState.protocol = null;
cachedState.stage = null;
cachedState.viewportMatchDetails = null;
cachedState.viewportStructure = null;
cachedState.toolOptions = null;
};
export default function toggleMPRHangingProtocol({
toggledState,
servicesManager,
getToolGroup,
}) {
const {
UINotificationService,
HangingProtocolService,
ViewportGridService,
ToolBarService,
} = servicesManager.services;
const {
activeViewportIndex,
viewports,
numRows,
numCols,
} = 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,
});
};
// What is the current active protocol and stage number to restore later
const { protocol, stage } = HangingProtocolService.getActiveProtocol();
if (toggledState) {
resetCachedState();
const {
viewportMatchDetails,
viewportStructure,
toolOptions,
} = _getViewportsInfo({
protocol,
stage,
viewports,
servicesManager,
});
setCachedState(
protocol,
stage,
viewportMatchDetails,
viewportStructure,
toolOptions
);
const matchDetails = {
displaySetInstanceUIDs: viewportDisplaySetInstanceUIDs,
};
_disableCrosshairs(
toolOptions.map(({ toolGroupId }) => toolGroupId),
getToolGroup
);
HangingProtocolService.setProtocol(
MPR_TOOLGROUP_ID,
matchDetails,
errorCallback
);
return;
}
const restoreErrorCallback = error => {
UINotificationService.show({
title: 'Multiplanar reconstruction (MPR) ',
message:
'Something went wrong while trying to restore the previous layout.',
type: 'info',
duration: 3000,
});
};
_disableCrosshairs([MPR_TOOLGROUP_ID], getToolGroup);
const { layoutType, properties } = cachedState.viewportStructure;
const { viewportMatchDetails } = cachedState;
// The reason we split the flow here is that we don't allow viewport grid
// change in the non default hanging protocol, so we can just apply the
// cached protocol and stage. However, for the default protocol, we need
// to also apply the layout type and properties.
if (cachedState.protocol.id !== 'default') {
HangingProtocolService.setProtocol(
cachedState.protocol.id,
viewportMatchDetails,
restoreErrorCallback
);
return;
}
HangingProtocolService.setProtocol(
'default',
viewportMatchDetails,
restoreErrorCallback
);
if (numRows !== properties.rows || numCols !== properties.columns) {
ViewportGridService.setLayout({
numRows: properties.rows,
numCols: properties.columns,
layoutType,
layoutOptions: properties.layoutOptions,
});
}
const numViewports =
properties.layoutOptions.length || properties.rows * properties.columns;
// loop inside viewportMatchDetails map
// and set the viewportOptions for each viewport
[...Array(numViewports).keys()].forEach(viewportIndex => {
const viewportMatchDetailsForViewport = viewportMatchDetails.get(
viewportIndex
);
if (viewportMatchDetailsForViewport) {
const {
viewportOptions,
displaySetsInfo,
} = viewportMatchDetailsForViewport;
ViewportGridService.setDisplaySetsForViewport({
viewportIndex,
displaySetInstanceUIDs: displaySetsInfo.map(
displaySetInfo => displaySetInfo.displaySetInstanceUID
),
viewportOptions,
});
} else {
ViewportGridService.setDisplaySetsForViewport({
viewportIndex,
displaySetInstanceUIDs: [],
viewportOptions: {},
});
}
});
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);
}
function _disableCrosshairs(toolGroupIds, getToolGroup) {
toolGroupIds.forEach(toolGroupId => {
const toolGroup = getToolGroup(toolGroupId);
if (
toolGroup.getToolInstance('Crosshairs')?.mode === Enums.ToolModes.Active
) {
toolGroup.setToolDisabled('Crosshairs');
}
});
}
function _getViewportsInfo({ protocol, stage, viewports, servicesManager }) {
// here we need to use the viewports and try to map it into the
// viewportMatchDetails and displaySetMatch that HangingProtocolService
// expects
const {
ViewportGridService,
HangingProtocolService,
ToolGroupService,
} = servicesManager.services;
const { numRows, numCols } = ViewportGridService.getState();
let viewportMatchDetails = new Map();
const viewportStructure = {
layoutType: 'grid',
properties: {
rows: numRows,
columns: numCols,
layoutOptions: [],
},
};
viewports.forEach((viewport, viewportIndex) => {
viewportStructure.properties.layoutOptions.push({
x: viewport.x,
y: viewport.y,
width: viewport.width,
height: viewport.height,
});
});
if (protocol.id === 'default') {
viewports.forEach((viewport, viewportIndex) => {
if (viewport.displaySetInstanceUIDs) {
viewportMatchDetails.set(viewportIndex, {
displaySetsInfo: viewport.displaySetInstanceUIDs.map(
displaySetInstanceUID => {
return { displaySetInstanceUID };
}
),
viewportOptions: viewport.viewportOptions,
});
}
});
} else {
({ viewportMatchDetails } = HangingProtocolService.getMatchDetails());
}
// get the toolGroup state for viewports
let toolOptions = [];
const viewportIds = viewports
.map(
viewport =>
viewport.displaySetInstanceUIDs &&
viewport.displaySetInstanceUIDs.length > 0 &&
viewport.viewportOptions?.viewportId
)
.filter(Boolean);
if (viewportIds.length) {
toolOptions = viewportIds.map(viewportId => {
const toolGroup = ToolGroupService.getToolGroupForViewport(viewportId);
return {
toolGroupId: toolGroup.id,
toolOptions: toolGroup.toolOptions,
};
});
}
return { viewportMatchDetails, viewportStructure, toolOptions };
}

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

@ -0,0 +1,115 @@
import calculateViewportRegistrations from './calculateViewportRegistrations';
// [ {
// synchronizerId: string,
// viewports: [ { viewportId: number, renderingEngineId: string, index: number } , ...]
// ]}
let STACK_IMAGE_SYNC_GROUPS_INFO = [];
export default function toggleStackImageSync({
toggledState,
servicesManager,
getEnabledElement,
}) {
const {
SyncGroupService,
ViewportGridService,
DisplaySetService,
CornerstoneViewportService,
} = servicesManager.services;
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,
});
});
}

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

@ -32,7 +32,7 @@
"peerDependencies": { "peerDependencies": {
"@ohif/core": "^3.0.0", "@ohif/core": "^3.0.0",
"@ohif/i18n": "^1.0.0", "@ohif/i18n": "^1.0.0",
"dcmjs": "^0.24.5", "dcmjs": "^0.28.3",
"dicomweb-client": "^0.6.0", "dicomweb-client": "^0.6.0",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react": "^17.0.2", "react": "^17.0.2",
@ -43,6 +43,6 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.16.3", "@babel/runtime": "7.16.3",
"@cornerstonejs/calculate-suv": "1.0.2" "@cornerstonejs/calculate-suv": "^1.0.3"
} }
} }

View File

@ -2,6 +2,7 @@ import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import OHIF from '@ohif/core'; import OHIF from '@ohif/core';
import getImageId from '../DicomWebDataSource/utils/getImageId'; import getImageId from '../DicomWebDataSource/utils/getImageId';
import getDirectURL from '../utils/getDirectURL';
const metadataProvider = OHIF.classes.MetadataProvider; const metadataProvider = OHIF.classes.MetadataProvider;
@ -40,7 +41,7 @@ const findStudies = (key, value) => {
}; };
function createDicomJSONApi(dicomJsonConfig) { function createDicomJSONApi(dicomJsonConfig) {
const { name } = dicomJsonConfig; const { name, wadoRoot } = dicomJsonConfig;
const implementation = { const implementation = {
initialize: async ({ params, query, url }) => { initialize: async ({ params, query, url }) => {
@ -93,7 +94,7 @@ function createDicomJSONApi(dicomJsonConfig) {
}, },
query: { query: {
studies: { studies: {
mapParams: () => {}, mapParams: () => { },
search: async param => { search: async param => {
const [key, value] = Object.entries(param)[0]; const [key, value] = Object.entries(param)[0];
const mappedParam = mappings[key]; const mappedParam = mappings[key];
@ -133,6 +134,22 @@ function createDicomJSONApi(dicomJsonConfig) {
}, },
}, },
retrieve: { retrieve: {
/**
* Generates a URL that can be used for direct retrieve of the bulkdata
*
* @param {object} params
* @param {string} params.tag is the tag name of the URL to retrieve
* @param {string} params.defaultPath path for the pixel data url
* @param {object} params.instance is the instance object that the tag is in
* @param {string} params.defaultType is the mime type of the response
* @param {string} params.singlepart is the type of the part to retrieve
* @param {string} params.fetchPart unknown?
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
directURL: params => {
return getDirectURL(wadoRoot, params);
},
series: { series: {
metadata: ({ metadata: ({
StudyInstanceUID, StudyInstanceUID,

View File

@ -25,7 +25,7 @@ function createDicomLocalApi(dicomLocalConfig) {
const implementation = { const implementation = {
initialize: ({ params, query }) => { initialize: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params; const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.get('StudyInstanceUIDs'); const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const StudyInstanceUIDs = const StudyInstanceUIDs =
queryStudyInstanceUIDs || paramsStudyInstanceUIDs; queryStudyInstanceUIDs || paramsStudyInstanceUIDs;
@ -166,7 +166,6 @@ function createDicomLocalApi(dicomLocalConfig) {
displaySet.images.forEach(instance => { displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames; const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) { if (NumberOfFrames > 1) {
for (let i = 0; i < NumberOfFrames; i++) { for (let i = 0; i < NumberOfFrames; i++) {
const imageId = this.getImageIdsForInstance({ const imageId = this.getImageIdsForInstance({
@ -190,9 +189,14 @@ function createDicomLocalApi(dicomLocalConfig) {
SeriesInstanceUID, SeriesInstanceUID,
SOPInstanceUID SOPInstanceUID
); );
if (storedInstance.url) {
return storedInstance.url; let imageId = storedInstance.url;
if (frame !== undefined) {
imageId += `&frame=${frame}`;
} }
return imageId;
}, },
deleteStudyMetadataPromise() { deleteStudyMetadataPromise() {
console.log('deleteStudyMetadataPromise not implemented'); console.log('deleteStudyMetadataPromise not implemented');

View File

@ -0,0 +1,53 @@
.dicom-tag-browser-table {
margin-right: auto;
margin-left: auto;
}
.dicom-tag-browser-table-wrapper {
/* height: 500px;*/
/*overflow-y: scroll;*/
overflow-x: scroll;
}
.dicom-tag-browser-table tr {
padding-left: 10px;
padding-right: 10px;
color: #ffffff;
border-top: 1px solid #ddd;
white-space: nowrap;
}
.stick {
position: sticky;
overflow: clip;
}
.dicom-tag-browser-content {
overflow: hidden;
width: 100%;
padding-bottom: 50px;
/*height: 500px;*/
}
.dicom-tag-browser-instance-range .range {
height: 20px;
}
.dicom-tag-browser-instance-range {
padding: 20px 0 20px 0;
}
.dicom-tag-browser-table td.dicom-tag-browser-table-center {
text-align: center;
}
.dicom-tag-browser-table th {
padding-left: 10px;
padding-right: 10px;
text-align: center;
color: "#20A5D6";
}
.dicom-tag-browser-table th.dicom-tag-browser-table-left {
text-align: left;
}

View File

@ -0,0 +1,327 @@
import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo } from 'react';
import { classes } from '@ohif/core';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
import { InputRange, Select, Typography } from '@ohif/ui';
const { ImageSet } = classes;
const { DicomMetaDictionary } = dcmjs.data;
const { nameMap } = DicomMetaDictionary;
const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
const [
selectedDisplaySetInstanceUID,
setSelectedDisplaySetInstanceUID,
] = useState(displaySetInstanceUID);
const [instanceNumber, setInstanceNumber] = useState(1);
const onSelectChange = value => {
setSelectedDisplaySetInstanceUID(value.value);
setInstanceNumber(1);
};
const activeDisplaySet = displaySets.find(
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
);
const isImageStack = _isImageStack(activeDisplaySet);
const showInstanceList = isImageStack && activeDisplaySet.images.length > 1;
const displaySetList = useMemo(() => {
displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber);
return displaySets.map(displaySet => {
const {
displaySetInstanceUID,
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesDescription,
Modality,
} = displaySet;
/* Map to display representation */
const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0];
const date = moment(dateStr, 'YYYYMMDD:HHmmss');
const displayDate = date.format('ddd, MMM Do YYYY');
return {
value: displaySetInstanceUID,
label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
description: displayDate,
};
});
}, [displaySets]);
return (
<div className="dicom-tag-browser-content">
<div className="flex flex-row items-center mb-2">
<Typography variant="subtitle" className="w-1/2 mr-8">
Series
</Typography>
{showInstanceList && (
<Typography variant="subtitle" className="w-1/2">
Instance Number
</Typography>
)}
</div>
<div className="flex flex-row items-center mb-6">
<div className="w-1/2 mr-8">
<Select
isClearable={false}
onChange={onSelectChange}
options={displaySetList}
value={displaySetList.find(
ds => ds.value === selectedDisplaySetInstanceUID
)}
className="text-white"
/>
</div>
{showInstanceList ? (
<div className="w-1/2">
<InputRange
value={instanceNumber}
onChange={value => {
setInstanceNumber(parseInt(value));
}}
minValue={1}
maxValue={activeDisplaySet.images.length}
step={1}
/>
</div>
) : null}
</div>
<DicomTagTable
rows={getFormattedRowsFromTags(activeDisplaySet, instanceNumber)}
></DicomTagTable>
</div>
);
};
function getFormattedRowsFromTags(displaySet, instanceNumber) {
const isImageStack = _isImageStack(displaySet);
let metadata;
if (isImageStack) {
metadata = displaySet.images[instanceNumber - 1];
} else {
metadata = displaySet;
}
const tags = getSortedTags(metadata);
const rows = [];
tags.forEach(tagInfo => {
if (tagInfo.vr === 'SQ') {
rows.push([
`${tagInfo.tagIndent}${tagInfo.tag}`,
tagInfo.vr,
tagInfo.keyword,
'',
]);
const { values } = tagInfo;
values.forEach((item, index) => {
const formatedRowsFromTags = getFormattedRowsFromTags(item);
rows.push([
`${item[0].tagIndent}(FFFE,E000)`,
'',
`Item #${index}`,
'',
]);
rows.push(...formatedRowsFromTags);
});
} else {
if (tagInfo.vr === 'xs') {
try {
/* const dataset = metadataProvider.getStudyDataset(
meta.StudyInstanceUID
);*/
// console.log(dataset);
// const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString();
// const originalTagInfo = dataset[tag];
// tagInfo.vr = originalTagInfo.vr;
} catch (error) {
console.error(
`Failed to parse value representation for tag '${tagInfo.keyword}'`
);
}
}
if (tagInfo.vr === 'PN') {
rows.push([
`${tagInfo.tagIndent}${tagInfo.tag}`,
tagInfo.vr,
tagInfo.keyword,
tagInfo.value,
]);
} else {
rows.push([
`${tagInfo.tagIndent}${tagInfo.tag}`,
tagInfo.vr,
tagInfo.keyword,
tagInfo.value,
]);
}
}
});
return rows;
}
function getSortedTags(metadata) {
const tagList = getRows(metadata);
// Sort top level tags, sequence groups are sorted when created.
_sortTagList(tagList);
return tagList;
}
function getRows(metadata, depth = 0) {
// Tag, Type, Value, Keyword
const keywords = Object.keys(metadata);
let tagIndent = '';
for (let i = 0; i < depth; i++) {
tagIndent += '>';
}
if (depth > 0) {
tagIndent += ' '; // If indented, add a space after the indents.
}
const rows = [];
for (let i = 0; i < keywords.length; i++) {
let keyword = keywords[i];
if (keyword === '_vrMap') {
continue;
}
const tagInfo = nameMap[keyword];
let value = metadata[keyword];
if (tagInfo && tagInfo.vr === 'SQ') {
const sequenceAsArray = toArray(value);
// Push line defining the sequence
const sequence = {
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
values: [],
};
rows.push(sequence);
if (value === null) {
// Type 2 Sequence
continue;
}
sequenceAsArray.forEach(item => {
const sequenceRows = getRows(item, depth + 1);
if (sequenceRows.length) {
// Sort the sequence group.
_sortTagList(sequenceRows);
sequence.values.push(sequenceRows);
}
});
continue;
}
if (Array.isArray(value)) {
if (value.length > 0 && typeof value[0] != 'object') {
value = value.join('\\');
}
}
if (typeof value === 'number') {
value = value.toString();
}
if (typeof value !== 'string') {
if (value === null) {
value = ' ';
} else {
if (typeof value === 'object') {
if (value.InlineBinary) {
value = 'Inline Binary';
} else if (value.BulkDataURI) {
value = `Bulk Data URI`; //: ${value.BulkDataURI}`;
} else if (value.Alphabetic) {
value = value.Alphabetic;
} else {
console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
console.warn(value);
value = ' ';
}
} else {
console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
value = ' ';
}
}
}
// tag / vr/ keyword/ value
// Remove retired tags
keyword = keyword.replace('RETIRED_', '');
if (tagInfo) {
rows.push({
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
value,
});
} else {
// skip properties without hex tag numbers
const regex = /[0-9A-Fa-f]{6}/g;
if (keyword.match(regex)) {
const tag = `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`;
rows.push({
tag,
tagIndent,
vr: '',
keyword: 'Private Tag',
value,
});
}
}
}
return rows;
}
function _isImageStack(displaySet) {
return displaySet instanceof ImageSet;
}
function toArray(objectOrArray) {
return Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray];
}
function _sortTagList(tagList) {
tagList.sort((a, b) => {
if (a.tag < b.tag) {
return -1;
}
return 1;
});
}
export default DicomTagBrowser;

View File

@ -0,0 +1,100 @@
import React from 'react';
function ColumnHeaders() {
return (
<div className="m-auto flex flex-col py-2 bg-secondary-light">
<div className="flex flex-row w-full">
<div className="px-3 w-5/24">
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
<span className="flex flex-row items-center focus:outline-none">
Tag
</span>
</label>
</div>
<div className="px-3 w-4/24">
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
<span className="flex flex-row items-center focus:outline-none">
VR
</span>
</label>
</div>
<div className="px-3 w-6/24">
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
<span className="flex flex-row items-center focus:outline-none">
Keyword
</span>
</label>
</div>
<div className="px-3 w-5/24">
<label className="flex flex-col flex-1 text-white text-lg pl-1 select-none">
<span className="flex flex-row items-center focus:outline-none">
Value
</span>
</label>
</div>
</div>
</div>
);
}
function DicomTagTable({ rows }) {
return (
<div>
{ColumnHeaders()}
<div
className="m-auto relative border-2 border-secondary-light overflow-hidden ohif-scrollbar"
style={{ height: '32rem' }}
>
<table className="w-full text-white">
<tbody>
{rows.map((row, index) => {
const className = row.className ? row.className : null;
return (
<tr
className="hover:bg-secondary-main transition duration-300 bg-primary-dark"
key={`DICOMTagRow-${index}`}
>
<td
style={{ maxWidth: '0px' }}
className="px-4 py-2 text-base break-all border-b border-secondary-light w-5/24"
>
<div className="flex">
<div className="inline-flex max-w-full">{row[0]}</div>
</div>
</td>
<td
style={{ maxWidth: '0px' }}
className="px-4 py-2 text-base break-all border-b border-secondary-light w-4/24"
>
<div className="flex">
<div className="inline-flex max-w-full">{row[1]}</div>
</div>
</td>
<td
style={{ maxWidth: '0px' }}
className="px-4 py-2 text-base break-all border-b border-secondary-light w-6/24"
>
<div className="flex">
<div className="inline-flex max-w-full">{row[2]}</div>
</div>
</td>
<td
style={{ maxWidth: '0px' }}
className="px-4 py-2 text-base break-all border-b border-secondary-light w-14/24"
>
<div className="flex">
<div className="inline-flex max-w-full">{row[3]}</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default DicomTagTable;

View File

@ -23,6 +23,7 @@ import {
deleteStudyMetadataPromise, deleteStudyMetadataPromise,
} from './retrieveStudyMetadata.js'; } from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient.js'; import StaticWadoClient from './utils/StaticWadoClient.js';
import getDirectURL from '../utils/getDirectURL.js';
const { DicomMetaDictionary, DicomDict } = dcmjs.data; const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -70,6 +71,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const wadoConfig = { const wadoConfig = {
url: wadoRoot, url: wadoRoot,
staticWado,
singlepart, singlepart,
headers: UserAuthenticationService.getAuthorizationHeader(), headers: UserAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(), errorInterceptor: errorHandler.getHTTPErrorHandler(),
@ -80,15 +82,19 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const qidoDicomWebClient = staticWado const qidoDicomWebClient = staticWado
? new StaticWadoClient(qidoConfig) ? new StaticWadoClient(qidoConfig)
: new api.DICOMwebClient(qidoConfig); : new api.DICOMwebClient(qidoConfig);
const wadoDicomWebClient = new api.DICOMwebClient(wadoConfig);
const wadoDicomWebClient = staticWado
? new StaticWadoClient(wadoConfig)
: new api.DICOMwebClient(wadoConfig);
const implementation = { const implementation = {
initialize: ({ params, query }) => { initialize: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params; const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.get('StudyInstanceUIDs'); const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const StudyInstanceUIDs = const StudyInstanceUIDs =
queryStudyInstanceUIDs || paramsStudyInstanceUIDs; (queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) ||
paramsStudyInstanceUIDs;
const StudyInstanceUIDsAsArray = const StudyInstanceUIDsAsArray =
StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs) StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs)
? StudyInstanceUIDs ? StudyInstanceUIDs
@ -98,7 +104,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
query: { query: {
studies: { studies: {
mapParams: mapParams.bind(), mapParams: mapParams.bind(),
search: async function(origParams) { search: async function (origParams) {
const headers = UserAuthenticationService.getAuthorizationHeader(); const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) { if (headers) {
qidoDicomWebClient.headers = headers; qidoDicomWebClient.headers = headers;
@ -123,7 +129,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
}, },
series: { series: {
// mapParams: mapParams.bind(), // mapParams: mapParams.bind(),
search: async function(studyInstanceUid) { search: async function (studyInstanceUid) {
const headers = UserAuthenticationService.getAuthorizationHeader(); const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) { if (headers) {
qidoDicomWebClient.headers = headers; qidoDicomWebClient.headers = headers;
@ -168,65 +174,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI * or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/ */
directURL: params => { directURL: params => {
const { return getDirectURL(wadoRoot, params);
instance,
tag = 'PixelData',
defaultPath = '/pixeldata',
defaultType = 'video/mp4',
singlepart: fetchPart = 'video',
} = params;
const value = instance[tag];
if (!value) return undefined;
if (value.DirectRetrieveURL) return value.DirectRetrieveURL;
if (value.InlineBinary) {
const blob = utils.b64toBlob(value.InlineBinary, defaultType);
value.DirectRetrieveURL = URL.createObjectURL(blob);
return value.DirectRetrieveURL;
}
if (
!singlepart ||
(singlepart !== true && singlepart.indexOf(fetchPart) === -1)
) {
if (value.retrieveBulkData) {
return value.retrieveBulkData().then(arr => {
value.DirectRetrieveURL = URL.createObjectURL(
new Blob([arr], { type: defaultType })
);
return value.DirectRetrieveURL;
});
}
console.warn('Unable to retrieve', tag, 'from', instance);
return undefined;
}
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
} = instance;
const BulkDataURI =
(value && value.BulkDataURI) ||
`series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`;
const hasQuery = BulkDataURI.indexOf('?') != -1;
const hasAccept = BulkDataURI.indexOf('accept=') != -1;
const acceptUri =
BulkDataURI +
(hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`);
if (BulkDataURI.indexOf('http') === 0) return acceptUri;
if (BulkDataURI.indexOf('/') === 0) {
return wadoRoot + acceptUri;
}
if (BulkDataURI.indexOf('series/') == 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
}
if (BulkDataURI.indexOf('instances/') === 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`;
}
if (BulkDataURI.indexOf('bulkdata/') === 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
}
throw new Error('BulkDataURI in unknown format:' + BulkDataURI);
}, },
series: { series: {
metadata: async ({ metadata: async ({
@ -418,6 +366,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
// any implementation that stores static copies of the metadata // any implementation that stores static copies of the metadata
StudyInstanceUID: naturalized.StudyInstanceUID, StudyInstanceUID: naturalized.StudyInstanceUID,
}; };
// Todo: this needs to be from wado dicom web client
return qidoDicomWebClient.retrieveBulkData(options).then(val => { return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined; const ret = (val && val[0]) || undefined;
value.Value = ret; value.Value = ret;
@ -435,6 +384,9 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
// Adding instanceMetadata to OHIF MetadataProvider // Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach((instance, index) => { naturalizedInstances.forEach((instance, index) => {
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
const imageId = implementation.getImageIdsForInstance({ const imageId = implementation.getImageIdsForInstance({
instance, instance,
}); });
@ -473,12 +425,13 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
const numberOfSeries = seriesPromises.length; const seriesDeliveredPromises = seriesPromises.map(promise =>
seriesPromises.forEach(async (seriesPromise, index) => { promise.then(instances => {
const instances = await seriesPromise;
storeInstances(instances); storeInstances(instances);
if (index === numberOfSeries - 1) setSuccessFlag(); })
}); );
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
}, },
deleteStudyMetadataPromise, deleteStudyMetadataPromise,
getImageIdsForDisplaySet(displaySet) { getImageIdsForDisplaySet(displaySet) {
@ -493,10 +446,10 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const NumberOfFrames = instance.NumberOfFrames; const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) { if (NumberOfFrames > 1) {
for (let i = 0; i < NumberOfFrames; i++) { for (let frame = 1; frame <= NumberOfFrames; frame++) {
const imageId = this.getImageIdsForInstance({ const imageId = this.getImageIdsForInstance({
instance, instance,
frame: i, frame,
}); });
imageIds.push(imageId); imageIds.push(imageId);
} }

View File

@ -171,7 +171,7 @@ function mapParams(params, options = {}) {
// Named // Named
PatientName: withWildcard(params.patientName), PatientName: withWildcard(params.patientName),
//PatientID: withWildcard(params.patientId), //PatientID: withWildcard(params.patientId),
"00100020": withWildcard(params.patientId), // Temporarily to make the tests pass with dicomweb-server.. Apparently it's broken? '00100020': withWildcard(params.patientId), // Temporarily to make the tests pass with dicomweb-server.. Apparently it's broken?
AccessionNumber: withWildcard(params.accessionNumber), AccessionNumber: withWildcard(params.accessionNumber),
StudyDescription: withWildcard(params.studyDescription), StudyDescription: withWildcard(params.studyDescription),
ModalitiesInStudy: params.modalitiesInStudy, ModalitiesInStudy: params.modalitiesInStudy,

View File

@ -8,14 +8,19 @@ import { api } from 'dicomweb-client';
* by manually implementing a query option. * by manually implementing a query option.
*/ */
export default class StaticWadoClient extends api.DICOMwebClient { export default class StaticWadoClient extends api.DICOMwebClient {
static filterKeys = { static studyFilterKeys = {
"StudyInstanceUID": "0020000D", StudyInstanceUID: '0020000D',
"PatientName": "00100010", PatientName: '00100010',
"00100020": "mrn", '00100020': 'mrn',
"StudyDescription": "00081030", StudyDescription: '00081030',
"StudyDate": "00080020", StudyDate: '00080020',
"ModalitiesInStudy": "00080061", ModalitiesInStudy: '00080061',
AccessionNumber: "00080050", AccessionNumber: '00080050',
};
static seriesFilterKeys = {
SeriesInstanceUID: '0020000E',
SeriesNumber: '00200011',
}; };
constructor(qidoConfig) { constructor(qidoConfig) {
@ -36,14 +41,48 @@ export default class StaticWadoClient extends api.DICOMwebClient {
const { queryParams } = options; const { queryParams } = options;
if (!queryParams) return searchResult; if (!queryParams) return searchResult;
const filtered = searchResult.filter(study => { const filtered = searchResult.filter(study => {
for (const key of Object.keys(StaticWadoClient.filterKeys)) { for (const key of Object.keys(StaticWadoClient.studyFilterKeys)) {
if (!this.filterItem(key, queryParams, study)) return false; if (
!this.filterItem(
key,
queryParams,
study,
StaticWadoClient.studyFilterKeys
)
) {
return false;
}
} }
return true; return true;
}); });
return filtered; return filtered;
} }
async searchForSeries(options) {
if (!this.staticWado) return super.searchForSeries(options);
let searchResult = await super.searchForSeries(options);
const { queryParams } = options;
if (!queryParams) return searchResult;
const filtered = searchResult.filter(study => {
for (const key of Object.keys(StaticWadoClient.seriesFilterKeys)) {
if (
!this.filterItem(
key,
queryParams,
study,
StaticWadoClient.seriesFilterKeys
)
) {
return false;
}
}
return true;
});
return filtered;
}
/** /**
* Compares values, matching any instance of desired to any instance of * Compares values, matching any instance of desired to any instance of
* actual by recursively go through the paired set of values. That is, * actual by recursively go through the paired set of values. That is,
@ -66,7 +105,7 @@ export default class StaticWadoClient extends api.DICOMwebClient {
if (actual?.Alphabetic) { if (actual?.Alphabetic) {
actual = actual.Alphabetic; actual = actual.Alphabetic;
} }
if (typeof (actual) == 'string') { if (typeof actual == 'string') {
if (actual.length === 0) return true; if (actual.length === 0) return true;
if (desired.length === 0 || desired === '*') return true; if (desired.length === 0 || desired === '*') return true;
if (desired[0] === '*' && desired[desired.length - 1] === '*') { if (desired[0] === '*' && desired[desired.length - 1] === '*') {
@ -75,7 +114,10 @@ export default class StaticWadoClient extends api.DICOMwebClient {
} else if (desired[desired.length - 1] === '*') { } else if (desired[desired.length - 1] === '*') {
return actual.indexOf(desired.substring(0, desired.length - 1)) != -1; return actual.indexOf(desired.substring(0, desired.length - 1)) != -1;
} else if (desired[0] === '*') { } else if (desired[0] === '*') {
return actual.indexOf(desired.substring(1)) === actual.length - desired.length + 1; return (
actual.indexOf(desired.substring(1)) ===
actual.length - desired.length + 1
);
} }
} }
return desired === actual; return desired === actual;
@ -88,8 +130,7 @@ export default class StaticWadoClient extends api.DICOMwebClient {
if (dash === -1) return this.compareValues(range, value); if (dash === -1) return this.compareValues(range, value);
const start = range.substring(0, dash); const start = range.substring(0, dash);
const end = range.substring(dash + 1); const end = range.substring(dash + 1);
return (!start || value >= start) && return (!start || value >= start) && (!end || value <= end);
(!end || value <= end);
} }
/** /**
@ -98,16 +139,19 @@ export default class StaticWadoClient extends api.DICOMwebClient {
* @param {*} key * @param {*} key
* @param {*} queryParams * @param {*} queryParams
* @param {*} study * @param {*} study
* @param {*} sourceFilterMap
* @returns * @returns
*/ */
filterItem(key, queryParams, study) { filterItem(key, queryParams, study, sourceFilterMap) {
const altKey = StaticWadoClient.filterKeys[key] || key; const altKey = sourceFilterMap[key] || key;
if (!queryParams) return true; if (!queryParams) return true;
const testValue = queryParams[key] || queryParams[altKey]; const testValue = queryParams[key] || queryParams[altKey];
if (!testValue) return true; if (!testValue) return true;
const valueElem = study[key] || study[altKey]; const valueElem = study[key] || study[altKey];
if (!valueElem) return false; if (!valueElem) return false;
if (valueElem.vr == 'DA') return this.compareDateRange(testValue, valueElem.Value[0]); if (valueElem.vr == 'DA') {
return this.compareDateRange(testValue, valueElem.Value[0]);
}
const value = valueElem.Value; const value = valueElem.Value;
return this.compareValues(testValue, value) && true; return this.compareValues(testValue, value) && true;
} }

View File

@ -8,14 +8,6 @@ import { utils } from '@ohif/core';
const { downloadCSVReport } = utils; const { downloadCSVReport } = utils;
// tools with measurements to display inside the panel
const MEASUREMENT_TOOLS = [
'EllipticalROI',
'RectangleROI',
'Length',
'Bidirectional',
];
export default function PanelMeasurementTable({ export default function PanelMeasurementTable({
servicesManager, servicesManager,
commandsManager, commandsManager,
@ -155,7 +147,6 @@ export default function PanelMeasurementTable({
> >
<MeasurementTable <MeasurementTable
title="Measurements" title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements} data={displayMeasurements}
onClick={jumpToImage} onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler} onEdit={onMeasurementItemEditHandler}
@ -186,12 +177,8 @@ PanelMeasurementTable.propTypes = {
function _getMappedMeasurements(MeasurementService) { function _getMappedMeasurements(MeasurementService) {
const measurements = MeasurementService.getMeasurements(); const measurements = MeasurementService.getMeasurements();
// filter out measurements whose toolName is not in MEASUREMENT_TOOLS
const measurementTools = measurements.filter(measurement =>
MEASUREMENT_TOOLS.includes(measurement.toolName)
);
const mappedMeasurements = measurementTools.map((m, index) => const mappedMeasurements = measurements.map((m, index) =>
_mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES) _mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES)
); );

View File

@ -10,12 +10,17 @@ const { formatDate } = utils;
* @param {*} param0 * @param {*} param0
*/ */
function PanelStudyBrowser({ function PanelStudyBrowser({
DisplaySetService, servicesManager,
getImageSrc, getImageSrc,
getStudiesForPatientByStudyInstanceUID, getStudiesForPatientByStudyInstanceUID,
requestDisplaySetCreationForStudy, requestDisplaySetCreationForStudy,
dataSource, dataSource,
}) { }) {
const {
HangingProtocolService,
DisplaySetService,
UINotificationService,
} = servicesManager.services;
// Normally you nest the components so the tree isn't so deep, and the data // 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. // doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> Studies --> DisplaySets --> Thumbnails // Tabs --> Studies --> DisplaySets --> Thumbnails
@ -34,10 +39,25 @@ function PanelStudyBrowser({
const isMounted = useRef(true); const isMounted = useRef(true);
const onDoubleClickThumbnailHandler = displaySetInstanceUID => { const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
viewportGridService.setDisplaySetsForViewport({ let updatedViewports = [];
viewportIndex: activeViewportIndex, const viewportIndex = activeViewportIndex;
displaySetInstanceUIDs: [displaySetInstanceUID], 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 // ~~ studyDisplayList
@ -61,7 +81,19 @@ function PanelStudyBrowser({
}; };
}); });
if (isMounted.current) { if (isMounted.current) {
setStudyDisplayList(actuallyMappedStudies); setStudyDisplayList(prevArray => {
const ret = [...prevArray];
for (const study of actuallyMappedStudies) {
if (
!prevArray.find(
it => it.studyInstanceUid === study.studyInstanceUid
)
) {
ret.push(study);
}
}
return ret;
});
} }
} }
@ -215,12 +247,7 @@ function PanelStudyBrowser({
} }
PanelStudyBrowser.propTypes = { PanelStudyBrowser.propTypes = {
DisplaySetService: PropTypes.shape({ servicesManager: PropTypes.object.isRequired,
EVENTS: PropTypes.object.isRequired,
activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired,
getDisplaySetByUID: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
}).isRequired,
dataSource: PropTypes.shape({ dataSource: PropTypes.shape({
getImageIdsForDisplaySet: PropTypes.func.isRequired, getImageIdsForDisplaySet: PropTypes.func.isRequired,
}).isRequired, }).isRequired,

View File

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

@ -2,6 +2,6 @@ import React from 'react';
export default function ToolbarDivider() { export default function ToolbarDivider() {
return ( return (
<span className="self-center w-1 h-8 mx-2 border-l border-common-dark" /> <span className="self-center w-4 h-8 mx-2 border-l border-common-dark" />
); );
} }

View File

@ -1,20 +1,43 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { import {
LayoutSelector as OHIFLayoutSelector, LayoutSelector as OHIFLayoutSelector,
ToolbarButton, ToolbarButton,
useViewportGrid, useViewportGrid,
} from '@ohif/ui'; } from '@ohif/ui';
function LayoutSelector() { function LayoutSelector({ rows, columns, servicesManager }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [disableSelector, setDisableSelector] = useState(false);
const [viewportGridState, viewportGridService] = useViewportGrid(); const [viewportGridState, viewportGridService] = useViewportGrid();
const { HangingProtocolService } = servicesManager.services;
const closeOnOutsideClick = () => { const closeOnOutsideClick = () => {
if (isOpen) { if (isOpen) {
setIsOpen(false); 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(() => { useEffect(() => {
window.addEventListener('click', closeOnOutsideClick); window.addEventListener('click', closeOnOutsideClick);
return () => { return () => {
@ -41,16 +64,30 @@ function LayoutSelector() {
dropdownContent={ dropdownContent={
DropdownContent !== null && ( DropdownContent !== null && (
<DropdownContent <DropdownContent
rows={rows}
columns={columns}
onSelection={({ numRows, numCols }) => { onSelection={({ numRows, numCols }) => {
viewportGridService.setLayout({ numCols, numRows }); viewportGridService.setLayout({ numRows, numCols });
}} }}
/> />
) )
} }
isActive={isOpen} isActive={disableSelector ? false : isOpen}
type="toggle" type="toggle"
/> />
); );
} }
LayoutSelector.propTypes = {
rows: PropTypes.number,
columns: PropTypes.number,
onLayoutChange: PropTypes.func,
};
LayoutSelector.defaultProps = {
rows: 3,
columns: 3,
onLayoutChange: () => {},
};
export default LayoutSelector; export default LayoutSelector;

View File

@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { import {
SidePanel, SidePanel,
ErrorBoundary, ErrorBoundary,
@ -9,74 +11,15 @@ import {
AboutModal, AboutModal,
Header, Header,
useModal, useModal,
LoadingIndicatorProgress,
} from '@ohif/ui'; } from '@ohif/ui';
import i18n from '@ohif/i18n'; import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core'; import { hotkeys } from '@ohif/core';
import { useAppConfig } from '@state'; import { useAppConfig } from '@state';
import Toolbar from '../Toolbar/Toolbar';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n; 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({ function ViewerLayout({
// From Extension Module Params // From Extension Module Params
extensionManager, extensionManager,
@ -84,21 +27,39 @@ function ViewerLayout({
hotkeysManager, hotkeysManager,
commandsManager, commandsManager,
// From Modes // From Modes
leftPanels,
rightPanels,
viewports, viewports,
ViewportGridComp, ViewportGridComp,
leftPanels = [],
rightPanels = [],
leftPanelDefaultClosed = false,
rightPanelDefaultClosed = false,
}) { }) {
const [appConfig] = useAppConfig(); const [appConfig] = useAppConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const onClickReturnButton = () => { const onClickReturnButton = () => {
navigate('/'); const { pathname } = location;
const dataSourceIdx = pathname.indexOf('/', 1);
const search =
dataSourceIdx === -1
? undefined
: `datasourcename=${pathname.substring(dataSourceIdx + 1)}`;
navigate({
pathname: '/',
search,
});
}; };
const { t } = useTranslation(); const { t } = useTranslation();
const { show, hide } = useModal(); const { show, hide } = useModal();
const [showLoadingIndicator, setShowLoadingIndicator] = useState(
appConfig.showLoadingIndicator
);
const { HangingProtocolService } = servicesManager.services;
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager; const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const versionNumber = process.env.VERSION_NUMBER; const versionNumber = process.env.VERSION_NUMBER;
const buildNumber = process.env.BUILD_NUM; const buildNumber = process.env.BUILD_NUM;
@ -146,6 +107,18 @@ function ViewerLayout({
}, },
]; ];
if (appConfig.oidc) {
menuOptions.push({
title: t('Header:Logout'),
icon: 'power-off',
onClick: async () => {
navigate(
`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`
);
},
});
}
/** /**
* Set body classes (tailwindcss) that don't allow vertical * Set body classes (tailwindcss) that don't allow vertical
* or horizontal overflow (no scrolling). Also guarantee window * or horizontal overflow (no scrolling). Also guarantee window
@ -160,10 +133,29 @@ function ViewerLayout({
}; };
}, []); }, []);
const getPanelData = id => { const getComponent = id => {
const entry = extensionManager.getModuleEntry(id); const entry = extensionManager.getModuleEntry(id);
// TODO, not sure why sidepanel content has to be JSX, and not a children prop?
const content = entry.component; if (!entry) {
throw new Error(
`${id} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.`
);
}
let content;
if (entry && entry.component) {
content = entry.component;
} else {
throw new Error(
`No component found from extension ${id}. Check the reference string to the extension in your Mode configuration`
);
}
return { entry, content };
};
const getPanelData = id => {
const { content, entry } = getComponent(id);
return { return {
iconName: entry.iconName, iconName: entry.iconName,
@ -174,8 +166,27 @@ 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 getViewportComponentData = viewportComponent => {
const entry = extensionManager.getModuleEntry(viewportComponent.namespace); const { entry } = getComponent(viewportComponent.namespace);
return { return {
component: entry.component, component: entry.component,
@ -201,22 +212,26 @@ function ViewerLayout({
</ErrorBoundary> </ErrorBoundary>
</Header> </Header>
<div <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' }} style={{ height: 'calc(100vh - 52px' }}
> >
<React.Fragment>
{showLoadingIndicator && (
<LoadingIndicatorProgress className="h-full w-full bg-black" />
)}
{/* LEFT SIDEPANELS */} {/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? ( {leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel"> <ErrorBoundary context="Left Panel">
<SidePanel <SidePanel
side="left" side="left"
defaultComponentOpen={leftPanelComponents[0].name} activeTabIndex={leftPanelDefaultClosed ? null : 0}
childComponents={leftPanelComponents} tabs={leftPanelComponents}
/> />
</ErrorBoundary> </ErrorBoundary>
) : null} ) : null}
{/* TOOLBAR + GRID */} {/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full"> <div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black"> <div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black relative">
<ErrorBoundary context="Grid"> <ErrorBoundary context="Grid">
<ViewportGridComp <ViewportGridComp
servicesManager={servicesManager} servicesManager={servicesManager}
@ -230,11 +245,12 @@ function ViewerLayout({
<ErrorBoundary context="Right Panel"> <ErrorBoundary context="Right Panel">
<SidePanel <SidePanel
side="right" side="right"
defaultComponentOpen={rightPanelComponents[0].name} activeTabIndex={rightPanelDefaultClosed ? null : 0}
childComponents={rightPanelComponents} tabs={rightPanelComponents}
/> />
</ErrorBoundary> </ErrorBoundary>
) : null} ) : null}
</React.Fragment>
</div> </div>
</div> </div>
); );
@ -249,13 +265,10 @@ ViewerLayout.propTypes = {
// From modes // From modes
leftPanels: PropTypes.array, leftPanels: PropTypes.array,
rightPanels: PropTypes.array, rightPanels: PropTypes.array,
leftPanelDefaultClosed: PropTypes.bool.isRequired,
rightPanelDefaultClosed: PropTypes.bool.isRequired,
/** Responsible for rendering our grid of viewports; provided by consuming application */ /** Responsible for rendering our grid of viewports; provided by consuming application */
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
}; };
ViewerLayout.defaultProps = {
leftPanels: [],
rightPanels: [],
};
export default ViewerLayout; export default ViewerLayout;

View File

@ -1,8 +1,13 @@
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import React from 'react';
const commandsModule = ({ servicesManager, commandsManager }) => { const commandsModule = ({ servicesManager, commandsManager }) => {
const { const {
MeasurementService, MeasurementService,
HangingProtocolService, HangingProtocolService,
UINotificationService, UINotificationService,
ViewportGridService,
DisplaySetService,
} = servicesManager.services; } = servicesManager.services;
const actions = { const actions = {
@ -23,6 +28,25 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
previousStage: () => { previousStage: () => {
HangingProtocolService.previousProtocolStage(); HangingProtocolService.previousProtocolStage();
}, },
openDICOMTagViewer() {
const { activeViewportIndex, viewports } = ViewportGridService.getState();
const activeViewportSpecificData = viewports[activeViewportIndex];
const { displaySetInstanceUIDs } = activeViewportSpecificData;
const displaySets = DisplaySetService.activeDisplaySets;
const { UIModalService } = servicesManager.services;
const displaySetInstanceUID = displaySetInstanceUIDs[0];
UIModalService.show({
content: DicomTagBrowser,
contentProps: {
displaySets,
displaySetInstanceUID,
onClose: UIModalService.hide,
},
title: 'DICOM Tag Browser',
});
},
}; };
const definitions = { const definitions = {
@ -46,6 +70,9 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
storeContexts: [], storeContexts: [],
options: {}, options: {},
}, },
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
}; };
return { return {

View File

@ -9,25 +9,26 @@ const defaultProtocol = {
editableBy: {}, editableBy: {},
protocolMatchingRules: [], protocolMatchingRules: [],
toolGroupIds: ['default'], toolGroupIds: ['default'],
displaySetSelectors: {
defaultDisplaySetId: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [],
studyMatchingRules: [],
},
},
stages: [ stages: [
{ {
id: 'hYbmMy3b7pz7GLiaT', id: 'hYbmMy3b7pz7GLiaT',
name: 'default', name: 'default',
viewportStructure: { viewportStructure: {
type: 'grid', layoutType: 'grid',
properties: { properties: {
rows: 1, rows: 1,
columns: 1, columns: 1,
}, },
}, },
displaySets: [
{
id: 'displaySet',
imageMatchingRules: [],
seriesMatchingRules: [],
studyMatchingRules: [],
},
],
viewports: [ viewports: [
{ {
viewportOptions: { viewportOptions: {
@ -40,7 +41,7 @@ const defaultProtocol = {
displaySets: [ displaySets: [
{ {
options: [], options: [],
id: 'displaySet', id: 'defaultDisplaySetId',
}, },
], ],
}, },
@ -54,8 +55,8 @@ const defaultProtocol = {
function getHangingProtocolModule() { function getHangingProtocolModule() {
return [ return [
{ {
name: 'default', id: defaultProtocol.id,
protocols: [defaultProtocol], protocol: defaultProtocol,
}, },
]; ];
} }

View File

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

View File

@ -1,4 +1,5 @@
import { isImage } from '@ohif/core/src/utils/isImage'; import { isImage } from '@ohif/core/src/utils/isImage';
import sopClassDictionary from '@ohif/core/src/utils/sopClassDictionary';
import ImageSet from '@ohif/core/src/classes/ImageSet'; import ImageSet from '@ohif/core/src/classes/ImageSet';
import isDisplaySetReconstructable from '@ohif/core/src/utils/isDisplaySetReconstructable'; import isDisplaySetReconstructable from '@ohif/core/src/utils/isDisplaySetReconstructable';
import { id } from './id'; import { id } from './id';
@ -24,6 +25,7 @@ const makeDisplaySet = instances => {
StudyInstanceUID: instance.StudyInstanceUID, StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0, SeriesNumber: instance.SeriesNumber || 0,
FrameRate: instance.FrameTime, FrameRate: instance.FrameTime,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: instance.SeriesDescription || '', SeriesDescription: instance.SeriesDescription || '',
Modality: instance.Modality, Modality: instance.Modality,
isMultiFrame: isMultiFrame(instance), isMultiFrame: isMultiFrame(instance),
@ -118,7 +120,7 @@ function getDisplaySetsFromSeries(instances) {
acquisitionDatetime: instance.AcquisitionDateTime, acquisitionDatetime: instance.AcquisitionDateTime,
}); });
displaySets.push(displaySet); displaySets.push(displaySet);
} else if (isSingleImageModality(instance.modality)) { } else if (isSingleImageModality(instance.Modality)) {
displaySet = makeDisplaySet([instance]); displaySet = makeDisplaySet([instance]);
displaySet.setAttributes({ displaySet.setAttributes({
sopClassUids, sopClassUids,
@ -143,138 +145,6 @@ function getDisplaySetsFromSeries(instances) {
return displaySets; return displaySets;
} }
// TODO: Remove since we have roughly the same thing in dcmjs
const sopClassDictionary = {
ComputedRadiographyImageStorage: '1.2.840.10008.5.1.4.1.1.1',
DigitalXRayImageStorageForPresentation: '1.2.840.10008.5.1.4.1.1.1.1',
DigitalXRayImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.1.1.1',
DigitalMammographyXRayImageStorageForPresentation:
'1.2.840.10008.5.1.4.1.1.1.2',
DigitalMammographyXRayImageStorageForProcessing:
'1.2.840.10008.5.1.4.1.1.1.2.1',
DigitalIntraOralXRayImageStorageForPresentation:
'1.2.840.10008.5.1.4.1.1.1.3',
DigitalIntraOralXRayImageStorageForProcessing:
'1.2.840.10008.5.1.4.1.1.1.3.1',
CTImageStorage: '1.2.840.10008.5.1.4.1.1.2',
EnhancedCTImageStorage: '1.2.840.10008.5.1.4.1.1.2.1',
LegacyConvertedEnhancedCTImageStorage: '1.2.840.10008.5.1.4.1.1.2.2',
UltrasoundMultiframeImageStorage: '1.2.840.10008.5.1.4.1.1.3.1',
MRImageStorage: '1.2.840.10008.5.1.4.1.1.4',
EnhancedMRImageStorage: '1.2.840.10008.5.1.4.1.1.4.1',
MRSpectroscopyStorage: '1.2.840.10008.5.1.4.1.1.4.2',
EnhancedMRColorImageStorage: '1.2.840.10008.5.1.4.1.1.4.3',
LegacyConvertedEnhancedMRImageStorage: '1.2.840.10008.5.1.4.1.1.4.4',
UltrasoundImageStorageRET: '1.2.840.10008.5.1.4.1.1.6',
UltrasoundImageStorage: '1.2.840.10008.5.1.4.1.1.6.1',
EnhancedUSVolumeStorage: '1.2.840.10008.5.1.4.1.1.6.2',
SecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7',
MultiframeSingleBitSecondaryCaptureImageStorage:
'1.2.840.10008.5.1.4.1.1.7.1',
MultiframeGrayscaleByteSecondaryCaptureImageStorage:
'1.2.840.10008.5.1.4.1.1.7.2',
MultiframeGrayscaleWordSecondaryCaptureImageStorage:
'1.2.840.10008.5.1.4.1.1.7.3',
MultiframeTrueColorSecondaryCaptureImageStorage:
'1.2.840.10008.5.1.4.1.1.7.4',
Sop12LeadECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.1',
GeneralECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.2',
AmbulatoryECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.3',
HemodynamicWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.2.1',
CardiacElectrophysiologyWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.3.1',
BasicVoiceAudioWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.4.1',
GeneralAudioWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.4.2',
ArterialPulseWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.5.1',
RespiratoryWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.6.1',
GrayscaleSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.1',
ColorSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.2',
PseudoColorSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.3',
BlendingSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.4',
XAXRFGrayscaleSoftcopyPresentationStateStorage:
'1.2.840.10008.5.1.4.1.1.11.5',
XRayAngiographicImageStorage: '1.2.840.10008.5.1.4.1.1.12.1',
EnhancedXAImageStorage: '1.2.840.10008.5.1.4.1.1.12.1.1',
XRayRadiofluoroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.12.2',
EnhancedXRFImageStorage: '1.2.840.10008.5.1.4.1.1.12.2.1',
XRay3DAngiographicImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.1',
XRay3DCraniofacialImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.2',
BreastTomosynthesisImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.3',
BreastProjectionXRayImageStorageForPresentation:
'1.2.840.10008.5.1.4.1.1.13.1.4',
BreastProjectionXRayImageStorageForProcessing:
'1.2.840.10008.5.1.4.1.1.13.1.5',
IntravascularOpticalCoherenceTomographyImageStorageForPresentation:
'1.2.840.10008.5.1.4.1.1.14.1',
IntravascularOpticalCoherenceTomographyImageStorageForProcessing:
'1.2.840.10008.5.1.4.1.1.14.2',
NuclearMedicineImageStorage: '1.2.840.10008.5.1.4.1.1.20',
RawDataStorage: '1.2.840.10008.5.1.4.1.1.66',
SpatialRegistrationStorage: '1.2.840.10008.5.1.4.1.1.66.1',
SpatialFiducialsStorage: '1.2.840.10008.5.1.4.1.1.66.2',
DeformableSpatialRegistrationStorage: '1.2.840.10008.5.1.4.1.1.66.3',
SegmentationStorage: '1.2.840.10008.5.1.4.1.1.66.4',
SurfaceSegmentationStorage: '1.2.840.10008.5.1.4.1.1.66.5',
RealWorldValueMappingStorage: '1.2.840.10008.5.1.4.1.1.67',
SurfaceScanMeshStorage: '1.2.840.10008.5.1.4.1.1.68.1',
SurfaceScanPointCloudStorage: '1.2.840.10008.5.1.4.1.1.68.2',
VLEndoscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.1',
VideoEndoscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.1.1',
VLMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.2',
VideoMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.2.1',
VLSlideCoordinatesMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.3',
VLPhotographicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.4',
VideoPhotographicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.4.1',
OphthalmicPhotography8BitImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.1',
OphthalmicPhotography16BitImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.2',
StereometricRelationshipStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.3',
OphthalmicTomographyImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.4',
VLWholeSlideMicroscopyImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.6',
LensometryMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.1',
AutorefractionMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.2',
KeratometryMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.3',
SubjectiveRefractionMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.4',
VisualAcuityMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.5',
SpectaclePrescriptionReportStorage: '1.2.840.10008.5.1.4.1.1.78.6',
OphthalmicAxialMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.7',
IntraocularLensCalculationsStorage: '1.2.840.10008.5.1.4.1.1.78.8',
MacularGridThicknessandVolumeReport: '1.2.840.10008.5.1.4.1.1.79.1',
OphthalmicVisualFieldStaticPerimetryMeasurementsStorage:
'1.2.840.10008.5.1.4.1.1.80.1',
OphthalmicThicknessMapStorage: '1.2.840.10008.5.1.4.1.1.81.1',
CornealTopographyMapStorage: '1.2.840.10008.5.1.4.1.1.82.1',
BasicTextSR: '1.2.840.10008.5.1.4.1.1.88.11',
EnhancedSR: '1.2.840.10008.5.1.4.1.1.88.22',
ComprehensiveSR: '1.2.840.10008.5.1.4.1.1.88.33',
Comprehensive3DSR: '1.2.840.10008.5.1.4.1.1.88.34',
ProcedureLog: '1.2.840.10008.5.1.4.1.1.88.40',
MammographyCADSR: '1.2.840.10008.5.1.4.1.1.88.50',
KeyObjectSelection: '1.2.840.10008.5.1.4.1.1.88.59',
ChestCADSR: '1.2.840.10008.5.1.4.1.1.88.65',
XRayRadiationDoseSR: '1.2.840.10008.5.1.4.1.1.88.67',
RadiopharmaceuticalRadiationDoseSR: '1.2.840.10008.5.1.4.1.1.88.68',
ColonCADSR: '1.2.840.10008.5.1.4.1.1.88.69',
ImplantationPlanSRDocumentStorage: '1.2.840.10008.5.1.4.1.1.88.70',
EncapsulatedPDFStorage: '1.2.840.10008.5.1.4.1.1.104.1',
EncapsulatedCDAStorage: '1.2.840.10008.5.1.4.1.1.104.2',
PositronEmissionTomographyImageStorage: '1.2.840.10008.5.1.4.1.1.128',
EnhancedPETImageStorage: '1.2.840.10008.5.1.4.1.1.130',
LegacyConvertedEnhancedPETImageStorage: '1.2.840.10008.5.1.4.1.1.128.1',
BasicStructuredDisplayStorage: '1.2.840.10008.5.1.4.1.1.131',
RTImageStorage: '1.2.840.10008.5.1.4.1.1.481.1',
RTDoseStorage: '1.2.840.10008.5.1.4.1.1.481.2',
RTStructureSetStorage: '1.2.840.10008.5.1.4.1.1.481.3',
RTBeamsTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.4',
RTPlanStorage: '1.2.840.10008.5.1.4.1.1.481.5',
RTBrachyTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.6',
RTTreatmentSummaryRecordStorage: '1.2.840.10008.5.1.4.1.1.481.7',
RTIonPlanStorage: '1.2.840.10008.5.1.4.1.1.481.8',
RTIonBeamsTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.9',
RTBeamsDeliveryInstructionStorage: '1.2.840.10008.5.1.4.34.7',
GenericImplantTemplateStorage: '1.2.840.10008.5.1.4.43.1',
ImplantAssemblyTemplateStorage: '1.2.840.10008.5.1.4.44.1',
ImplantTemplateGroupStorage: '1.2.840.10008.5.1.4.45.1',
};
const sopClassUids = [ const sopClassUids = [
sopClassDictionary.ComputedRadiographyImageStorage, sopClassDictionary.ComputedRadiographyImageStorage,
sopClassDictionary.DigitalXRayImageStorageForPresentation, sopClassDictionary.DigitalXRayImageStorageForPresentation,
@ -325,6 +195,7 @@ const sopClassUids = [
sopClassDictionary.EnhancedPETImageStorage, sopClassDictionary.EnhancedPETImageStorage,
sopClassDictionary.LegacyConvertedEnhancedPETImageStorage, sopClassDictionary.LegacyConvertedEnhancedPETImageStorage,
sopClassDictionary.RTImageStorage, sopClassDictionary.RTImageStorage,
sopClassDictionary.EnhancedUSVolumeStorage,
]; ];
function getSopClassHandlerModule() { function getSopClassHandlerModule() {

View File

@ -2,9 +2,9 @@ import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js'; import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule'; import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js'; import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getHangingProtocolModule from './getHangingProtocolModule.js';
import getToolbarModule from './getToolbarModule'; import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule'; import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
import { id } from './id.js'; import { id } from './id.js';
import init from './init'; import init from './init';
@ -18,9 +18,9 @@ const defaultExtension = {
init({ servicesManager, configuration }); init({ servicesManager, configuration });
}, },
getDataSourcesModule, getDataSourcesModule,
getHangingProtocolModule,
getLayoutTemplateModule, getLayoutTemplateModule,
getPanelModule, getPanelModule,
getHangingProtocolModule,
getSopClassHandlerModule, getSopClassHandlerModule,
getToolbarModule, getToolbarModule,
getCommandsModule({ servicesManager, commandsManager }) { getCommandsModule({ servicesManager, commandsManager }) {

View File

@ -0,0 +1,85 @@
import {
DicomMetadataStore,
IWebApiDataSource,
utils,
errorHandler,
classes,
} from '@ohif/core';
/**
* Generates a URL that can be used for direct retrieve of the bulkdata
*
* @param {object} params
* @param {string} params.tag is the tag name of the URL to retrieve
* @param {string} params.defaultPath path for the pixel data url
* @param {object} params.instance is the instance object that the tag is in
* @param {string} params.defaultType is the mime type of the response
* @param {string} params.singlepart is the type of the part to retrieve
* @param {string} params.fetchPart unknown?
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
const getDirectURL = (wadoRoot, params) => {
const {
instance,
tag = 'PixelData',
defaultPath = '/pixeldata',
defaultType = 'video/mp4',
singlepart = null,
singlepart: fetchPart = 'video',
} = params;
const value = instance[tag];
if (!value) return undefined;
if (value.DirectRetrieveURL) return value.DirectRetrieveURL;
if (value.InlineBinary) {
const blob = utils.b64toBlob(value.InlineBinary, defaultType);
value.DirectRetrieveURL = URL.createObjectURL(blob);
return value.DirectRetrieveURL;
}
if (
!singlepart ||
(singlepart !== true && singlepart.indexOf(fetchPart) === -1)
) {
if (value.retrieveBulkData) {
return value.retrieveBulkData().then(arr => {
value.DirectRetrieveURL = URL.createObjectURL(
new Blob([arr], { type: defaultType })
);
return value.DirectRetrieveURL;
});
}
console.warn('Unable to retrieve', tag, 'from', instance);
return undefined;
}
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
} = instance;
const BulkDataURI =
(value && value.BulkDataURI) ||
`series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`;
const hasQuery = BulkDataURI.indexOf('?') != -1;
const hasAccept = BulkDataURI.indexOf('accept=') != -1;
const acceptUri =
BulkDataURI +
(hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`);
if (BulkDataURI.indexOf('http') === 0) return acceptUri;
if (BulkDataURI.indexOf('/') === 0) {
return wadoRoot + acceptUri;
}
if (BulkDataURI.indexOf('series/') == 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
}
if (BulkDataURI.indexOf('instances/') === 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`;
}
if (BulkDataURI.indexOf('bulkdata/') === 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
}
throw new Error('BulkDataURI in unknown format:' + BulkDataURI);
};
export default getDirectURL;

View File

@ -30,7 +30,7 @@
"peerDependencies": { "peerDependencies": {
"@ohif/core": "^3.0.0", "@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0", "@ohif/ui": "^2.0.0",
"dcmjs": "^0.24.5", "dcmjs": "^0.28.3",
"dicom-parser": "^1.8.9", "dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",

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