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 {
listen 80;
listen ${PORT};
# listen 3000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;

View File

@ -1,4 +1,6 @@
#!/bin/bash
#!/bin/sh
envsubst `${PORT}` < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf
if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
then

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Set the default behavior,
# in case people don't have core.autocrlf set.
* text=auto
# Declares that files will always have CRLF line ends
*.sh text eol=lf

3
.gitignore vendored
View File

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

View File

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

View File

@ -8,8 +8,10 @@ module.exports = {
],
plugins: [
'inline-react-svg',
'@babel/plugin-proposal-class-properties',
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-transform-typescript',
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
],
env: {
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": {
"@ohif/core": "^3.0.0",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0",
"@ohif/ui": "^2.0.0",
"dcmjs": "^0.24.5",
"dcmjs": "^0.28.3",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"@ohif/extension-cornerstone": "^3.0.0",
"@ohif/extension-measurement-tracking": "^3.0.0"
"react": "^17.0.2"
},
"dependencies": {
"@babel/runtime": "7.16.3",
"classnames": "^2.2.6",
"@cornerstonejs/core": "^0.14.4",
"@cornerstonejs/tools": "^0.21.7"
"@cornerstonejs/core": "^0.21.5",
"@cornerstonejs/tools": "^0.29.8"
}
}

View File

@ -6,7 +6,7 @@ import { adapters } from 'dcmjs';
const { CodeScheme: Cornerstone3DCodeScheme } = adapters.Cornerstone3D;
const { ImageSet } = classes;
const { ImageSet, MetadataProvider: metadataProvider } = classes;
// TODO ->
// Add SR thumbnail
// 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.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.34', //COMPREHENSIVE_3D_SR:
];
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
@ -44,6 +45,7 @@ const CodingSchemeDesignators = {
const RELATIONSHIP_TYPE = {
INFERRED_FROM: 'INFERRED FROM',
CONTAINS: 'CONTAINS',
};
const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';
@ -77,6 +79,7 @@ function _getDisplaySetsFromSeries(
SeriesNumber,
SeriesDate,
ConceptNameCodeSequence,
SOPClassUID,
} = instance;
if (
@ -101,6 +104,7 @@ function _getDisplaySetsFromSeries(
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
referencedImages: null,
measurements: null,
isDerivedDisplaySet: true,
@ -212,20 +216,26 @@ function _checkIfCanAddMeasurementsToDisplaySet(
newDisplaySet
);
for (let i = 0; i < images.length; i++) {
for (const imageId of imageIdsForDisplaySet) {
if (!unloadedMeasurements.length) {
// All measurements loaded.
break;
return;
}
const image = images[i];
const { SOPInstanceUID } = image;
if (SOPInstanceUIDs.includes(SOPInstanceUID)) {
const imageId = imageIdsForDisplaySet[i];
const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(
imageId
);
if (SOPInstanceUIDs.includes(SOPInstanceUID)) {
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
const measurement = unloadedMeasurements[j];
if (_measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID)) {
if (
_measurementReferencesSOPInstanceUID(
measurement,
SOPInstanceUID,
frameNumber
)
) {
addMeasurement(
measurement,
imageId,
@ -239,9 +249,23 @@ function _checkIfCanAddMeasurementsToDisplaySet(
}
}
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID) {
function _measurementReferencesSOPInstanceUID(
measurement,
SOPInstanceUID,
frameNumber
) {
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++) {
const coord = coords[j];
const { ReferencedSOPInstanceUID } = coord.ReferencedSOPSequence;
@ -376,7 +400,6 @@ function _processMeasurement(mergedContentSequence) {
function _processTID1410Measurement(mergedContentSequence) {
// 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
// TODO -> Look at RelationshipType => Contains means
const graphicItem = mergedContentSequence.find(
group => group.ValueType === 'SCOORD'
@ -536,9 +559,14 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
function _getCoordsFromSCOORDOrSCOORD3D(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(
`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;
@ -597,12 +625,18 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item;
const {
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
} = ReferencedSOPSequence;
referencedImages.push({ ReferencedSOPClassUID, ReferencedSOPInstanceUID });
if (item.hasOwnProperty('ReferencedSOPClassUID')) {
const {
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
} = ReferencedSOPSequence;
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
}
});
return referencedImages;

View File

@ -99,7 +99,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
const annotation = filteredAnnotations[i];
const annotationUID = annotation.annotationUID;
const { renderableData } = annotation.data.cachedStats;
const { label, cachedStats } = annotation.data;
const { cachedStats } = annotation.data;
const { referencedImageId } = annotation.metadata;
styleSpecifier.annotationUID = annotationUID;
@ -121,22 +121,30 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
const renderableDataForGraphicType = renderableData[GraphicType];
let renderMethod;
let renderTextBox;
let canvasCoordinatesAdapter;
switch (GraphicType) {
case SCOORD_TYPES.POINT:
renderMethod = this.renderPoint;
renderTextBox = this.renderTextBox;
break;
case SCOORD_TYPES.MULTIPOINT:
renderMethod = this.renderMultipoint;
renderTextBox = this.renderTextBox;
break;
case SCOORD_TYPES.POLYLINE:
renderMethod = this.renderPolyLine;
break;
case SCOORD_TYPES.CIRCLE:
renderMethod = this.renderEllipse;
renderTextBox = this.renderTextBox;
break;
case SCOORD_TYPES.ELLIPSE:
renderMethod = this.renderEllipse;
renderTextBox = this.renderTextBox;
canvasCoordinatesAdapter =
utilities.math.ellipse.getCanvasEllipseCorners;
break;
default:
throw new Error(`Unsupported GraphicType: ${GraphicType}`);
@ -151,60 +159,18 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
options
);
if (!canvasCoordinates) {
return;
}
const textLines = this._getTextBoxLinesFromLabels(label);
let canvasCornersToUseForTextBox = canvasCoordinates;
if (GraphicType === SCOORD_TYPES.ELLIPSE) {
canvasCornersToUseForTextBox = utilities.math.ellipse.getCanvasEllipseCorners(
canvasCoordinates
if (typeof renderTextBox === 'function') {
renderTextBox.call(
this,
svgDrawingHelper,
viewport,
canvasCoordinates,
canvasCoordinatesAdapter,
annotation,
styleSpecifier,
options
);
}
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,
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]),
};
});
}
};
@ -217,28 +183,32 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
referencedImageId,
options
) {
// Todo: this needs to use the drawPolyLine from cs3D since it is implemented
// now, before it was implemented with a loop over drawLine which is hacky
const drawingOptions = {
color: options.color,
width: options.lineWidth,
};
let canvasCoordinates;
renderableData.map((data, index) => {
canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
const lineUID = `${index}`;
if (canvasCoordinates.length === 2) {
const lineUID = `${index}`;
drawing.drawLine(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCoordinates[0],
canvasCoordinates[1],
{
color: options.color,
width: options.lineWidth,
}
drawingOptions
);
} 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;
}
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 = {

View File

@ -45,6 +45,12 @@ export default function addMeasurement(
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 = {
annotationUID: measurement.TrackingUniqueIdentifier,
metadata: {
@ -61,6 +67,7 @@ export default function addMeasurement(
TrackingUniqueIdentifier: measurementData.TrackingUniqueIdentifier,
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.
measurement.ReferencedSOPInstanceUID =
measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID;
measurement.frameNumber = frameNumber;
delete measurement.coords;
}

View File

@ -1,13 +1,18 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import OHIF, { utils } from '@ohif/core';
import { useTranslation } from 'react-i18next';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import {
Notification,
ViewportActionBar,
useViewportGrid,
useViewportDialog,
Tooltip,
Icon,
} from '@ohif/ui';
import classNames from 'classnames';
const { formatDate } = utils;
@ -27,6 +32,8 @@ function OHIFCornerstoneSRViewport(props) {
extensionManager,
} = props;
const { t } = useTranslation('SRViewport');
const {
DisplaySetService,
CornerstoneViewportService,
@ -318,8 +325,6 @@ function OHIFCornerstoneSRViewport(props) {
});
}
const { Modality } = srDisplaySet;
const {
PatientID,
PatientName,
@ -341,24 +346,23 @@ function OHIFCornerstoneSRViewport(props) {
evt.stopPropagation();
evt.preventDefault();
}}
onPillClick={() => {
sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', {
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
onArrowsClick={onMeasurementChange}
getStatusComponent={() =>
_getStatusComponent({
srDisplaySet,
viewportIndex,
});
}}
onSeriesChange={onMeasurementChange}
isTracked: false,
isRehydratable: srDisplaySet.isRehydratable,
isLocked,
sendTrackedMeasurementsEvent,
})
}
studyData={{
label: viewportLabel,
useAltStyling: true,
isTracked: false,
isLocked,
isRehydratable: srDisplaySet.isRehydratable,
isHydrated,
studyDate: formatDate(StudyDate),
currentSeries: SeriesNumber,
seriesDescription: SeriesDescription,
modality: Modality,
patientInformation: {
patientName: PatientName
? OHIF.utils.formatPN(PatientName.Alphabetic)
@ -439,6 +443,139 @@ async function _getViewportReferencedDisplaySetData(
return { referencedDisplaySetMetadata, referencedDisplaySet };
}
function _getStatusComponent({
srDisplaySet,
viewportIndex,
isRehydratable,
isLocked,
sendTrackedMeasurementsEvent,
}) {
const onPillClick = () => {
sendTrackedMeasurementsEvent('RESTORE_PROMPT_HYDRATE_SR', {
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
viewportIndex,
});
};
// 1 - Incompatible
// 2 - Locked
// 3 - Rehydratable / Open
const state =
isRehydratable && !isLocked ? 3 : isRehydratable && isLocked ? 2 : 1;
let ToolTipMessage = null;
let StatusIcon = null;
switch (state) {
case 1:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 rounded-full"
style={{
width: '18px',
height: '18px',
backgroundColor: '#98e5c1',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="exclamation"
style={{ color: '#000', width: '12px', height: '12px' }}
/>
</div>
);
ToolTipMessage = () => (
<div>
This structured report is not compatible
<br />
with this application.
</div>
);
break;
case 2:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 bg-black rounded-full"
style={{
width: '18px',
height: '18px',
}}
>
<Icon
name="lock"
style={{ color: '#05D97C', width: '8px', height: '11px' }}
/>
</div>
);
ToolTipMessage = () => (
<div>
This structured report is currently read-only
<br />
because you are tracking measurements in
<br />
another viewport.
</div>
);
break;
case 3:
StatusIcon = () => (
<div
className="flex items-center justify-center -mr-1 bg-white rounded-full group-hover:bg-customblue-200"
style={{
width: '18px',
height: '18px',
border: 'solid 1.5px #000000',
}}
>
<Icon
name="arrow-left"
style={{ color: '#000', width: '14px', height: '14px' }}
/>
</div>
);
ToolTipMessage = () => <div>Click to restore measurements.</div>;
}
const StatusPill = () => (
<div
className={classNames(
'group relative flex items-center justify-center px-2 rounded-full cursor-default bg-customgreen-100',
{
'hover:bg-customblue-100': state === 3,
'cursor-pointer': state === 3,
}
)}
style={{
height: '24px',
width: '55px',
}}
onClick={() => {
if (state === 3) {
if (onPillClick) {
onPillClick();
}
}
}}
>
<span className="pr-1 text-lg font-bold leading-none text-black">SR</span>
<StatusIcon />
</div>
);
return (
<>
{ToolTipMessage && (
<Tooltip content={<ToolTipMessage />} position="bottom-left">
<StatusPill />
</Tooltip>
)}
{!ToolTipMessage && <StatusPill />}
</>
);
}
// function _onDoubleClick() {
// const cancelActiveManipulatorsForElement = cornerstoneTools.getModule(
// 'manipulatorState'

View File

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

View File

@ -1,15 +1,25 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import ReactResizeDetector from 'react-resize-detector';
import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui';
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 CornerstoneCacheService from '../services/ViewportService/CornerstoneCacheService';
import './OHIFCornerstoneViewport.css';
import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
import {
IStackViewport,
IVolumeViewport,
} from '@cornerstonejs/core/dist/esm/types';
import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
const STACK = 'stack';
@ -22,24 +32,65 @@ function areEqual(prevProps, nextProps) {
return false;
}
const prevDisplaySets = prevProps.displaySets[0];
const nextDisplaySets = nextProps.displaySets[0];
if (prevDisplaySets && nextDisplaySets) {
const areSameDisplaySetInstanceUIDs =
prevDisplaySets.displaySetInstanceUID ===
nextDisplaySets.displaySetInstanceUID;
const areSameImageLength =
prevDisplaySets.images.length === nextDisplaySets.images.length;
const areSameImageIds = prevDisplaySets.images.every(
(prevImage, index) =>
prevImage.imageId === nextDisplaySets.images[index].imageId
);
return (
areSameDisplaySetInstanceUIDs && areSameImageLength && areSameImageIds
);
if (
prevProps.viewportOptions.orientation !==
nextProps.viewportOptions.orientation
) {
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
@ -61,8 +112,6 @@ const OHIFCornerstoneViewport = React.memo(props => {
} = props;
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [viewportData, setViewportData] = useState(null);
const [_, viewportGridService] = useViewportGrid();
const elementRef = useRef();
@ -73,6 +122,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
ToolGroupService,
SyncGroupService,
CornerstoneViewportService,
CornerstoneCacheService,
ViewportGridService,
} = servicesManager.services;
// useCallback for scroll bar height calculation
@ -89,6 +140,28 @@ const OHIFCornerstoneViewport = React.memo(props => {
}
}, [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(
evt => {
// 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
useEffect(() => {
CornerstoneViewportService.enableElement(
CornerstoneViewportService.enableViewport(
viewportIndex,
viewportOptions,
elementRef.current
@ -143,31 +216,22 @@ const OHIFCornerstoneViewport = React.memo(props => {
setImageScrollBarHeight();
return () => {
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
const viewportId = viewportInfo.getViewportId();
const renderingEngineId = viewportInfo.getRenderingEngineId();
const syncGroups = viewportInfo.getSyncGroups();
ToolGroupService.disable(viewportId, renderingEngineId);
SyncGroupService.removeViewportFromSyncGroup(
viewportId,
renderingEngineId,
syncGroups
);
cleanUpServices();
CornerstoneViewportService.disableElement(viewportIndex);
if (onElementDisabled) {
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
onElementDisabled(viewportInfo);
}
eventTarget.removeEventListener(
Enums.Events.ELEMENT_ENABLED,
elementEnabledHandler
);
if (onElementDisabled) {
onElementDisabled();
}
};
}, []);
@ -183,11 +247,12 @@ const OHIFCornerstoneViewport = React.memo(props => {
const { unsubscribe } = DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
async invalidatedDisplaySetInstanceUID => {
if (
viewportData.displaySetInstanceUIDs.includes(
invalidatedDisplaySetInstanceUID
)
) {
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) {
const viewportData = viewportInfo.getViewportData();
const newViewportData = await CornerstoneCacheService.invalidateViewportData(
viewportData,
invalidatedDisplaySetInstanceUID,
@ -201,15 +266,13 @@ const OHIFCornerstoneViewport = React.memo(props => {
newViewportData,
keepCamera
);
setViewportData(newViewportData);
}
}
);
return () => {
unsubscribe();
};
}, [viewportData, viewportIndex]);
}, [viewportIndex]);
useEffect(() => {
// handle the default viewportType to be stack
@ -218,22 +281,19 @@ const OHIFCornerstoneViewport = React.memo(props => {
}
const loadViewportData = async () => {
const viewportData = await CornerstoneCacheService.getViewportData(
viewportIndex,
const viewportData = await CornerstoneCacheService.createViewportData(
displaySets,
viewportOptions.viewportType,
viewportOptions,
dataSource,
initialImageIndex
);
CornerstoneViewportService.setViewportDisplaySets(
CornerstoneViewportService.setViewportData(
viewportIndex,
viewportData,
viewportOptions,
displaySetOptions
);
setViewportData(viewportData);
};
loadViewportData();
@ -256,7 +316,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
elementRef,
viewportIndex,
displaySets,
viewportGridService
ViewportGridService,
CornerstoneViewportService
);
_checkForCachedJumpToMeasurementEvents(
@ -265,13 +326,14 @@ const OHIFCornerstoneViewport = React.memo(props => {
elementRef,
viewportIndex,
displaySets,
viewportGridService
ViewportGridService,
CornerstoneViewportService
);
return () => {
unsubscribeFromJumpToMeasurementEvents();
};
}, [displaySets, elementRef, viewportIndex, viewportData]);
}, [displaySets, elementRef, viewportIndex]);
return (
<div className="viewport-wrapper">
@ -280,7 +342,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
handleHeight
skipOnMount={true} // Todo: make these configurable
refreshMode={'debounce'}
refreshRate={100}
refreshRate={200} // transition amount in side panel
onResize={onResize}
targetRef={elementRef.current}
/>
@ -308,7 +370,8 @@ function _subscribeToJumpToMeasurementEvents(
elementRef,
viewportIndex,
displaySets,
viewportGridService
ViewportGridService,
CornerstoneViewportService
) {
const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID
@ -326,7 +389,8 @@ function _subscribeToJumpToMeasurementEvents(
viewportIndex,
MeasurementService,
DisplaySetService,
viewportGridService
ViewportGridService,
CornerstoneViewportService
);
}
}
@ -342,7 +406,8 @@ function _checkForCachedJumpToMeasurementEvents(
elementRef,
viewportIndex,
displaySets,
viewportGridService
ViewportGridService,
CornerstoneViewportService
) {
const displaysUIDs = displaySets.map(
displaySet => displaySet.displaySetInstanceUID
@ -365,7 +430,8 @@ function _checkForCachedJumpToMeasurementEvents(
viewportIndex,
MeasurementService,
DisplaySetService,
viewportGridService
ViewportGridService,
CornerstoneViewportService
);
}
}
@ -377,10 +443,11 @@ function _jumpToMeasurement(
viewportIndex,
MeasurementService,
DisplaySetService,
viewportGridService
ViewportGridService,
CornerstoneViewportService
) {
const targetElement = targetElementRef.current;
const { displaySetInstanceUID, SOPInstanceUID } = measurement;
const { displaySetInstanceUID, SOPInstanceUID, frameNumber } = measurement;
if (!SOPInstanceUID) {
console.warn('cannot jump in a non-acquisition plane measurements yet');
@ -390,17 +457,63 @@ function _jumpToMeasurement(
displaySetInstanceUID
);
const imageIdIndex = referencedDisplaySet.images.findIndex(
i => i.SOPInstanceUID === SOPInstanceUID
);
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly
// setCornerstoneMeasurementActive(measurement);
viewportGridService.setActiveViewportIndex(viewportIndex);
ViewportGridService.setActiveViewportIndex(viewportIndex);
const enableElement = getEnabledElement(targetElement);
const viewportInfo = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
if (enableElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enableElement.viewport as IStackViewport | IVolumeViewport;
let imageIdIndex = 0;
let viewportCameraDirectionMatch = true;
if (viewport instanceof StackViewport) {
const imageIds = viewport.getImageIds();
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, {
imageIndex: imageIdIndex,
});
@ -419,7 +532,7 @@ OHIFCornerstoneViewport.propTypes = {
displaySets: PropTypes.array.isRequired,
dataSource: PropTypes.object.isRequired,
viewportOptions: PropTypes.object,
displaySetOptions: PropTypes.arrayOf(PropTypes.object),
displaySetOptions: PropTypes.arrayOf(PropTypes.any),
servicesManager: PropTypes.object.isRequired,
onElementEnabled: PropTypes.func,
// 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 ViewportOverlay from './ViewportOverlay';
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
import ViewportLoadingIndicator from './ViewportLoadingIndicator';
import CornerstoneCacheService from '../../services/ViewportService/CornerstoneCacheService';
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
function CornerstoneOverlays(props) {
const { viewportIndex, element, scrollbarHeight, servicesManager } = props;
@ -16,8 +15,8 @@ function CornerstoneOverlays(props) {
const [viewportData, setViewportData] = useState(null);
useEffect(() => {
const { unsubscribe } = CornerstoneCacheService.subscribe(
CornerstoneCacheService.EVENTS.VIEWPORT_DATA_CHANGED,
const { unsubscribe } = CornerstoneViewportService.subscribe(
CornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
props => {
if (props.viewportIndex !== viewportIndex) {
return;
@ -47,7 +46,7 @@ function CornerstoneOverlays(props) {
}
return (
<div>
<div className="noselect">
<ViewportImageScrollbar
viewportIndex={viewportIndex}
viewportData={viewportData}
@ -64,11 +63,15 @@ function CornerstoneOverlays(props) {
servicesManager={servicesManager}
element={element}
/>
<ViewportLoadingIndicator viewportData={viewportData} element={element} />
<ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers
imageSliceData={imageSliceData}
element={element}
viewportData={viewportData}
servicesManager={servicesManager}
viewportIndex={viewportIndex}
/>
</div>

View File

@ -25,9 +25,13 @@ function CornerstoneImageScrollbar({
viewportId
);
// on image scrollbar change, stop the CINE if it is playing
CineService.stopClip(element);
CineService.setCine({ id: viewportIndex, isPlaying: false });
const { isCineEnabled } = CineService.getState();
if (isCineEnabled) {
// on image scrollbar change, stop the CINE if it is playing
CineService.stopClip(element);
CineService.setCine({ id: viewportIndex, isPlaying: false });
}
csToolsUtils.jumpToSlice(viewport.element, {
imageIndex,
@ -53,7 +57,7 @@ function CornerstoneImageScrollbar({
setImageSliceData({
imageIndex: imageIndex,
numberOfSlices: viewportData.imageIds.length,
numberOfSlices: viewportData.data.imageIds.length,
});
return;
@ -83,7 +87,7 @@ function CornerstoneImageScrollbar({
// find the index of imageId in the imageIds
setImageSliceData({
imageIndex: newImageIdIndex,
numberOfSlices: viewportData.imageIds.length,
numberOfSlices: viewportData.data.imageIds.length,
});
};

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { Enums } from '@cornerstonejs/core';
function ViewportLoadingIndicator({ viewportData, element }) {
function ViewportImageSliceLoadingIndicator({ viewportData, element }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@ -89,15 +89,15 @@ function ViewportLoadingIndicator({ viewportData, element }) {
return null;
}
ViewportLoadingIndicator.propTypes = {
ViewportImageSliceLoadingIndicator.propTypes = {
percentComplete: PropTypes.number,
error: PropTypes.object,
element: PropTypes.object,
};
ViewportLoadingIndicator.defaultProps = {
ViewportImageSliceLoadingIndicator.defaultProps = {
percentComplete: 0,
error: null,
};
export default ViewportLoadingIndicator;
export default ViewportImageSliceLoadingIndicator;

View File

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

View File

@ -1,10 +1,164 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { metaData, Enums, Types } from '@cornerstonejs/core';
import React, { useEffect, useState, useMemo } from 'react';
import classNames from 'classnames';
import {
metaData,
Enums,
Types,
getEnabledElement,
utilities as csUtils,
} from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import PropTypes from 'prop-types';
import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css';
import { getEnabledElement } from '../../state';
const {
getOrientationStringLPS,
invertOrientationStringLPS,
} = utilities.orientation;
function ViewportOrientationMarkers({
element,
viewportData,
imageSliceData,
viewportIndex,
servicesManager,
orientationMarkers = ['top', 'left'],
}) {
// Rotation is in degrees
const [rotation, setRotation] = useState(0);
const [flipHorizontal, setFlipHorizontal] = useState(false);
const [flipVertical, setFlipVertical] = useState(false);
const { CornerstoneViewportService } = servicesManager.services;
useEffect(() => {
const cameraModifiedListener = (
evt: Types.EventTypes.CameraModifiedEvent
) => {
const { rotation, previousCamera, camera } = evt.detail;
if (rotation !== undefined) {
setRotation(rotation);
}
if (
camera.flipHorizontal !== undefined &&
previousCamera.flipHorizontal !== camera.flipHorizontal
) {
setFlipHorizontal(camera.flipHorizontal);
}
if (
camera.flipVertical !== undefined &&
previousCamera.flipVertical !== camera.flipVertical
) {
setFlipVertical(camera.flipVertical);
}
};
element.addEventListener(
Enums.Events.CAMERA_MODIFIED,
cameraModifiedListener
);
return () => {
element.removeEventListener(
Enums.Events.CAMERA_MODIFIED,
cameraModifiedListener
);
};
}, []);
const markers = useMemo(() => {
if (!viewportData) {
return '';
}
let rowCosines, columnCosines;
if (viewportData.viewportType === 'stack') {
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData.data.imageIds?.[imageIndex];
// Workaround for below TODO stub
if (!imageId) {
return false;
}
({ rowCosines, columnCosines } =
metaData.get('imagePlaneModule', imageId) || {});
} else {
if (!element || !getEnabledElement(element)) {
return '';
}
const { viewport } = getEnabledElement(element);
const { viewUp, viewPlaneNormal } = viewport.getCamera();
const viewRight = vec3.create();
vec3.cross(viewRight, viewUp, viewPlaneNormal);
columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]];
rowCosines = viewRight;
}
if (!rowCosines || !columnCosines || rotation === undefined) {
return '';
}
const markers = _getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
);
const ohifViewport = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
const backgroundColor = ohifViewport.getViewportOptions().background;
// 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
* @returns
*/
function getOrientationMarkers(
function _getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
) {
const {
getOrientationStringLPS,
invertOrientationStringLPS,
} = utilities.orientation;
const rowString = getOrientationStringLPS(rowCosines);
const columnString = getOrientationStringLPS(columnCosines);
const oppositeRowString = invertOrientationStringLPS(rowString);
@ -79,117 +229,4 @@ function getOrientationMarkers(
return markers;
}
function ViewportOrientationMarkers({
element,
viewportData,
imageSliceData,
viewportIndex,
orientationMarkers = ['top', 'left'],
}) {
// Rotation is in degrees
const [rotation, setRotation] = useState(0);
const [flipHorizontal, setFlipHorizontal] = useState(false);
const [flipVertical, setFlipVertical] = useState(false);
useEffect(() => {
const cameraModifiedListener = (
evt: Types.EventTypes.CameraModifiedEvent
) => {
const { rotation, previousCamera, camera } = evt.detail;
if (rotation !== undefined) {
setRotation(rotation);
}
if (camera.flipHorizontal !== undefined &&
previousCamera.flipHorizontal !== camera.flipHorizontal) {
setFlipHorizontal(camera.flipHorizontal);
}
if (camera.flipVertical !== undefined &&
previousCamera.flipVertical !== camera.flipVertical) {
setFlipVertical(camera.flipVertical);
}
};
element.addEventListener(
Enums.Events.CAMERA_MODIFIED,
cameraModifiedListener
);
return () => {
element.removeEventListener(
Enums.Events.CAMERA_MODIFIED,
cameraModifiedListener
);
};
}, []);
const getMarkers = useCallback(
orientationMarkers => {
// Todo: support orientation markers for the volume viewports
if (
!viewportData ||
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC
) {
return '';
}
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData?.imageIds[imageIndex];
// Workaround for below TODO stub
if (!imageId) {
return false;
}
const { rowCosines, columnCosines } =
metaData.get('imagePlaneModule', imageId) || {};
if (!rowCosines || !columnCosines || rotation === undefined) {
return false;
}
if (!rowCosines || !columnCosines) {
return '';
}
const markers = getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
);
return orientationMarkers.map((m, index) => (
<div
className={`${m}-mid orientation-marker`}
key={`${m}-mid orientation-marker`}
>
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
},
[flipHorizontal, flipVertical, rotation, viewportData, imageSliceData]
);
return (
<div className="ViewportOrientationMarkers noselect">
{getMarkers(orientationMarkers)}
</div>
);
}
ViewportOrientationMarkers.propTypes = {
percentComplete: PropTypes.number,
error: PropTypes.object,
};
ViewportOrientationMarkers.defaultProps = {
percentComplete: 0,
error: null,
};
export default ViewportOrientationMarkers;

View File

@ -28,6 +28,25 @@ function CornerstoneViewportOverlay({
setActiveTools(ToolBarService.getActiveTools());
}, []);
useEffect(() => {
let isMounted = true;
const { unsubscribe } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
if (!isMounted) {
return;
}
setActiveTools(ToolBarService.getActiveTools());
}
);
return () => {
isMounted = false;
unsubscribe();
};
}, []);
/**
* Updating the VOI when the viewport changes its voi
*/
@ -100,23 +119,6 @@ function CornerstoneViewportOverlay({
};
}, [viewportIndex, viewportData]);
/**
* Updating the active tools when the toolbar changes
*/
// Todo: this should act on the toolGroups instead of the toolbar state
useEffect(() => {
const { unsubscribe } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
setActiveTools(ToolBarService.getActiveTools());
}
);
return () => {
unsubscribe();
};
}, [ToolBarService]);
const getTopLeftContent = useCallback(() => {
const { windowWidth, windowCenter } = voi;
@ -186,22 +188,33 @@ function CornerstoneViewportOverlay({
return null;
}
if (viewportData.imageIds.length === 0) {
throw new Error(
'ViewportOverlay: only viewports with imageIds is supported at this time'
);
const ohifViewport = CornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
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 (
<ViewportOverlay
topLeft={getTopLeftContent()}
topRight={getTopRightContent()}
color={isLight && 'text-[#0944B3]'}
/>
);
}
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.imageIds;
const imageIds = viewportData.data.imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {
@ -258,8 +271,14 @@ function _getInstanceNumberFromVolume(
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } =
metaData.get('generalImageModule', imageIds[imageIndex]) || {};
metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}

View File

@ -1,21 +1,21 @@
import {
getEnabledElement,
StackViewport,
volumeLoader,
cache,
utilities as csUtils,
} from '@cornerstonejs/core';
import {
ToolGroupManager,
Enums,
segmentation,
utilities as csToolsUtils,
utilities as cstUtils,
ReferenceLinesTool,
} from '@cornerstonejs/tools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { getEnabledElement as OHIFgetEnabledElement } from './state';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import callInputDialog from './utils/callInputDialog';
import { setColormap } from './utils/colormap/transferFunctionHelpers';
import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol';
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
const commandsModule = ({ servicesManager }) => {
const {
@ -25,7 +25,8 @@ const commandsModule = ({ servicesManager }) => {
ToolBarService,
UIDialogService,
CornerstoneViewportService,
SegmentationService,
HangingProtocolService,
UINotificationService,
} = servicesManager.services;
function _getActiveViewportEnabledElement() {
@ -128,76 +129,68 @@ const commandsModule = ({ servicesManager }) => {
});
viewport.render();
},
toggleCrosshairs({ toolGroupId, toggledState }) {
const toolName = 'Crosshairs';
// If it is Enabled
if (toggledState) {
actions.setToolActive({ toolName, toolGroupId });
return;
}
const toolGroup = _getToolGroup(toolGroupId);
if (!toolGroup) {
return;
}
toolGroup.setToolDisabled(toolName);
// Get the primary toolId from the ToolBarService and set it to active
// Since it was set to passive if not already active
const primaryActiveTool = ToolBarService.state.primaryToolId;
if (
toolGroup?.toolOptions[primaryActiveTool]?.mode ===
Enums.ToolModes.Passive
) {
toolGroup.setToolActive(primaryActiveTool, {
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
});
}
},
setToolActive: ({ toolName, toolGroupId = null }) => {
const toolGroup = _getToolGroup(toolGroupId);
if (toolName === 'Crosshairs') {
const activeViewportToolGroup = _getToolGroup(null);
if (!toolGroup) {
console.warn('No tool group found for toolGroupId:', toolGroupId);
return;
if (!activeViewportToolGroup._toolInstances.Crosshairs) {
UINotificationService.show({
title: 'Crosshairs',
message:
'You need to be in a MPR view to use Crosshairs. Click on MPR button in the toolbar to activate it.',
type: 'info',
duration: 3000,
});
throw new Error('Crosshairs tool is not available in this viewport');
}
}
// 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() || {
viewports: [],
};
// iterate over all viewports and set the tool active for the
// viewports that belong to the toolGroup
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 }],
});
const toolGroup = _getToolGroup(toolGroupId);
const toolGroupViewportIds = toolGroup.getViewportIds();
// if toolGroup has been destroyed, or its viewports have been removed
if (!toolGroupViewportIds || !toolGroupViewportIds.length) {
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: () => {
const { activeViewportIndex } = ViewportGridService.getState();
@ -293,8 +286,12 @@ const commandsModule = ({ servicesManager }) => {
if (viewport instanceof StackViewport) {
viewport.resetProperties();
viewport.resetCamera();
viewport.render();
} else {
// Todo: add reset properties for volume viewport
viewport.resetCamera();
}
viewport.render();
},
scaleViewport: ({ direction }) => {
const enabledElement = _getActiveViewportEnabledElement();
@ -326,59 +323,7 @@ const commandsModule = ({ servicesManager }) => {
const { viewport } = enabledElement;
const options = { delta: direction };
csToolsUtils.scroll(viewport, options);
},
async createSegmentationForDisplaySet({ displaySetInstanceUID }) {
const volumeId = displaySetInstanceUID;
const segmentationUID = csUtils.uuidv4();
const segmentationId = `${volumeId}::${segmentationUID}`;
await volumeLoader.createAndCacheDerivedVolume(volumeId, {
volumeId: segmentationId,
});
// Add the segmentations to state
segmentation.addSegmentations([
{
segmentationId,
representation: {
// The type of segmentation
type: Enums.SegmentationRepresentations.Labelmap,
// The actual segmentation data, in the case of labelmap this is a
// reference to the source volume of the segmentation.
data: {
volumeId: segmentationId,
},
},
},
]);
return segmentationId;
},
async addSegmentationRepresentationToToolGroup({
segmentationId,
toolGroupId,
representationType,
}) {
// // Add the segmentation representation to the toolgroup
await segmentation.addSegmentationRepresentations(toolGroupId, [
{
segmentationId,
type: representationType,
},
]);
},
getLabelmapVolumes: ({ segmentations }) => {
if (!segmentations || !segmentations.length) {
segmentations = SegmentationService.getSegmentations();
}
const labelmapVolumes = segmentations.map(segmentation => {
return cache.getVolume(segmentation.id);
});
return labelmapVolumes;
cstUtils.scroll(viewport, options);
},
setViewportColormap: ({
viewportIndex,
@ -393,7 +338,7 @@ const commandsModule = ({ servicesManager }) => {
const actorEntries = viewport.getActors();
const actorEntry = actorEntries.find(actorEntry => {
return actorEntry.uid === displaySetInstanceUID;
return actorEntry.uid.includes(displaySetInstanceUID);
});
const { actor: volumeActor } = actorEntry;
@ -404,6 +349,56 @@ const commandsModule = ({ servicesManager }) => {
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 = {
@ -417,11 +412,6 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
toggleCrosshairs: {
commandFn: actions.toggleCrosshairs,
storeContexts: [],
options: {},
},
rotateViewportCW: {
commandFn: actions.rotateViewport,
storeContexts: [],
@ -432,6 +422,14 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: { rotation: -90 },
},
incrementActiveViewport: {
commandFn: actions.incrementActiveViewport,
storeContexts: [],
},
decrementActiveViewport: {
commandFn: actions.decrementActiveViewport,
storeContexts: [],
},
flipViewportHorizontal: {
commandFn: actions.flipViewportHorizontal,
storeContexts: [],
@ -497,27 +495,31 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
createSegmentationForDisplaySet: {
commandFn: actions.createSegmentationForDisplaySet,
storeContexts: [],
options: {},
},
addSegmentationRepresentationToToolGroup: {
commandFn: actions.addSegmentationRepresentationToToolGroup,
storeContexts: [],
options: {},
},
getLabelmapVolumes: {
commandFn: actions.getLabelmapVolumes,
storeContexts: [],
options: {},
},
setViewportColormap: {
commandFn: actions.setViewportColormap,
storeContexts: [],
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 {

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

View File

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

View File

@ -3,6 +3,7 @@ import { Enums, annotation } from '@cornerstonejs/tools';
import { DicomMetadataStore } from '@ohif/core';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
const { removeAnnotation } = annotation.state;
@ -252,7 +253,18 @@ const connectMeasurementServiceToTools = (
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();
annotationManager.addAnnotation({
annotationUID: measurement.uid,
@ -269,6 +281,7 @@ const connectMeasurementServiceToTools = (
handles: { ...data.annotation.data.handles },
cachedStats: { ...data.annotation.data.cachedStats },
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 { volumeLoader } from '@cornerstonejs/core';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import cornerstoneWADOImageLoader, {
webWorkerManager,
} from 'cornerstone-wado-image-loader';
import dicomParser from 'dicom-parser';
import { errorHandler } from '@ohif/core';
@ -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
// http://dicom.nema.org/medical/dicom/current/output/html/part18.html
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=*',
};
@ -78,3 +82,13 @@ export default function initWADOImageLoader(
initWebWorkers(appConfig);
}
export function destroy() {
// Note: we don't want to call .terminate on the webWorkerManager since
// that resets the config
const webWorkers = webWorkerManager.webWorkers;
for (let i = 0; i < webWorkers.length; i++) {
webWorkers[i].worker.terminate();
}
webWorkers.length = 0;
}

View File

@ -0,0 +1,252 @@
import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core';
import { utils } from '@ohif/core';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
const VOLUME_IMAGE_LOADER_SCHEME = 'streaming-wadors';
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
class CornerstoneCacheService {
stackImageIds: Map<string, string[]> = new Map();
volumeImageIds: Map<string, string[]> = new Map();
constructor(servicesManager) {
this.servicesManager = servicesManager;
}
public getCacheSize() {
return cs3DCache.getCacheSize();
}
public getCacheFreeSpace() {
return cs3DCache.getBytesAvailable();
}
public async createViewportData(
displaySets: unknown[],
viewportOptions: Record<string, unknown>,
dataSource: unknown,
initialImageIndex?: number
): Promise<StackViewportData | VolumeViewportData> {
let viewportType = viewportOptions.viewportType as string;
// Todo: Since Cornerstone 3D currently doesn't support segmentation
// on stack viewport, we should check if whether the the displaySets
// that are about to be displayed are referenced in a segmentation
// as a reference volume, if so, we should hang a volume viewport
// instead of a stack viewport
if (this._shouldRenderSegmentation(displaySets)) {
viewportType = 'volume';
// update viewportOptions to reflect the new viewport type
viewportOptions.viewportType = viewportType;
}
const cs3DViewportType = getCornerstoneViewportType(viewportType);
let viewportData: StackViewportData | VolumeViewportData;
if (cs3DViewportType === Enums.ViewportType.STACK) {
viewportData = await this._getStackViewportData(
dataSource,
displaySets,
initialImageIndex
);
}
if (cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC) {
viewportData = await this._getVolumeViewportData(dataSource, displaySets);
}
viewportData.viewportType = cs3DViewportType;
return viewportData;
}
public async invalidateViewportData(
viewportData: VolumeViewportData,
invalidatedDisplaySetInstanceUID: string,
dataSource,
DisplaySetService
) {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
throw new Error('Invalidation of StackViewport is not supported yet');
}
// Todo: grab the volume and get the id from the viewport itself
const volumeId = `${VOLUME_LOADER_SCHEME}:${invalidatedDisplaySetInstanceUID}`;
const volume = cs3DCache.getVolume(volumeId);
if (volume) {
cs3DCache.removeVolumeLoadObject(volumeId);
}
const displaySets = viewportData.data.map(({ displaySetInstanceUID }) =>
DisplaySetService.getDisplaySetByUID(displaySetInstanceUID)
);
const newViewportData = await this._getVolumeViewportData(
dataSource,
displaySets
);
return newViewportData;
}
private _getStackViewportData(
dataSource,
displaySets,
initialImageIndex
): StackViewportData {
// For Stack Viewport we don't have fusion currently
const displaySet = displaySets[0];
let stackImageIds = this.stackImageIds.get(
displaySet.displaySetInstanceUID
);
if (!stackImageIds) {
stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds);
}
const { displaySetInstanceUID, StudyInstanceUID } = displaySet;
const StackViewportData: StackViewportData = {
viewportType: Enums.ViewportType.STACK,
data: {
StudyInstanceUID,
displaySetInstanceUID,
imageIds: stackImageIds,
},
};
if (typeof initialImageIndex === 'number') {
StackViewportData.data.initialImageIndex = initialImageIndex;
}
return StackViewportData;
}
private async _getVolumeViewportData(
dataSource,
displaySets
): Promise<VolumeViewportData> {
// Todo: Check the cache for multiple scenarios to see if we need to
// decache the volume data from other viewports or not
const volumeData = [];
for (const displaySet of displaySets) {
// Don't create volumes for the displaySets that have custom load
// function (e.g., SEG, RT, since they rely on the reference volumes
// and they take care of their own loading after they are created in their
// getSOPClassHandler method
if (displaySet.load && displaySet.load instanceof Function) {
const { UserAuthenticationService } = this.servicesManager.services;
const headers = UserAuthenticationService.getAuthorizationHeader();
await displaySet.load({ headers });
volumeData.push({
studyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
});
// Todo: do some cache check and empty the cache if needed
continue;
}
const volumeLoaderSchema =
displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME;
const volumeId = `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`;
let volumeImageIds = this.volumeImageIds.get(
displaySet.displaySetInstanceUID
);
let volume = cs3DCache.getVolume(volumeId);
if (!volumeImageIds || !volume) {
volumeImageIds = this._getCornerstoneVolumeImageIds(
displaySet,
dataSource
);
volume = await volumeLoader.createAndCacheVolume(volumeId, {
imageIds: volumeImageIds,
});
this.volumeImageIds.set(
displaySet.displaySetInstanceUID,
volumeImageIds
);
}
volumeData.push({
StudyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
volume,
volumeId,
imageIds: volumeImageIds,
});
}
return {
viewportType: Enums.ViewportType.ORTHOGRAPHIC,
data: volumeData,
};
}
private _shouldRenderSegmentation(displaySets) {
const { SegmentationService } = this.servicesManager.services;
const viewportDisplaySetInstanceUIDs = displaySets.map(
({ displaySetInstanceUID }) => displaySetInstanceUID
);
// check inside segmentations if any of them are referencing the displaySets
// that are about to be displayed
const segmentations = SegmentationService.getSegmentations();
for (const segmentation of segmentations) {
const segDisplaySetInstanceUID = segmentation.displaySetInstanceUID;
const shouldDisplaySeg = SegmentationService.shouldRenderSegmentation(
viewportDisplaySetInstanceUIDs,
segDisplaySetInstanceUID
);
if (shouldDisplaySeg) {
return true;
}
}
}
private _getCornerstoneStackImageIds(displaySet, dataSource): string[] {
return dataSource.getImageIdsForDisplaySet(displaySet);
}
private _getCornerstoneVolumeImageIds(displaySet, dataSource): string[] {
const stackImageIds = this._getCornerstoneStackImageIds(
displaySet,
dataSource
);
if (stackImageIds[0].startsWith('dicomfile')) {
return stackImageIds;
}
return stackImageIds.map(imageId => {
const imageURI = utils.imageIdToURI(imageId);
return `${VOLUME_IMAGE_LOADER_SCHEME}:${imageURI}`;
});
}
}
export default CornerstoneCacheService;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
import { Enums as csToolsEnums, Types as cstTypes } from '@cornerstonejs/tools';
import { Types } from '@cornerstonejs/core';
type SegmentationConfig = cstTypes.LabelmapTypes.LabelmapConfig & {
renderInactiveSegmentations: boolean;
brushSize: number;
brushThresholdGate: number;
};
type Segment = {
// the label for the segment
label: string;
// the index of the segment in the segmentation
segmentIndex: number;
// the color of the segment
color: Types.Point3;
// the opacity of the segment
opacity: number;
// whether the segment is visible
isVisible: boolean;
// whether the segment is locked
isLocked: boolean;
};
type Segmentation = {
// active segment index is the index of the segment that is currently being edited.
activeSegmentIndex: number;
// colorLUTIndex is the index of the color LUT that is currently being used.
colorLUTIndex: number;
// if segmentation contains any data (often calculated from labelmap)
cachedStats: Record<string, any>;
// displayText is the text that is displayed on the segmentation panel (often derived from the data)
displayText?: string[];
// the id of the segmentation
id: string;
// if the segmentation is the active segmentation being used in the viewer
isActive: boolean;
// if the segmentation is visible in the viewer
isVisible: boolean;
// the label of the segmentation
label: string;
// the number of segments in the segmentation
segmentCount: number;
// the array of segments with their details, [null, segment1, segment2, ...]
segments: Array<Segment>;
// the set of segments that are locked
segmentsLocked: Array<number>;
// the segmentation representation type
type: csToolsEnums.SegmentationRepresentations;
// if labelmap, the id of the volume that the labelmap is associated with
volumeId?: string;
// whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport
// but hydrated SEG -> segmentation that is persisted in the store)
hydrated: boolean;
};
// Schema to generate a segmentation
type SegmentationSchema = {
// active segment index for the segmentation
activeSegmentIndex: number;
// statistics that are derived from the segmentation
cachedStats: Record<string, any>;
// the displayText for the segmentation in the panels
displayText?: string[];
// segmentation id
id: string;
// displaySetInstanceUID
displaySetInstanceUID: string;
// segmentation label
label: string;
// segment indices that are locked for the segmentation
segmentsLocked: Array<number>;
// the type of the segmentation (e.g., Labelmap etc.)
type: csToolsEnums.SegmentationRepresentations;
// the volume id of the volume that the labelmap is associated with, this only exists for the labelmap representation
volumeId: string;
// the referenced volumeURI for the segmentation
referencedVolumeURI: string;
// whether the segmentation is hydrated or not (non-hydrated SEG -> temporary segmentation for display in SEG Viewport
// but hydrated SEG -> segmentation that is persisted in the store)
hydrated: boolean;
// the number of segments in the segmentation
segmentCount: number;
// the array of segments with their details
segments: Array<Segment>;
};
export { SegmentationConfig, Segment, Segmentation, SegmentationSchema };

View File

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

View File

@ -1,4 +1,8 @@
import { synchronizers, SynchronizerManager } from '@cornerstonejs/tools';
import {
synchronizers,
SynchronizerManager,
Synchronizer,
} from '@cornerstonejs/tools';
import { pubSubServiceInterface } from '@ohif/core';
@ -6,20 +10,42 @@ const EVENTS = {
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 = {
type: string;
id: string;
source: boolean;
target: boolean;
id?: string;
// Source and target default to true if not specified
source?: boolean;
target?: boolean;
options?: Record<string, unknown>;
};
const POSITION = 'cameraposition';
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 {
serviceManager: any;
listeners: { [key: string]: (...args: any[]) => void } = {};
EVENTS: { [key: string]: string };
synchronizerCreators: Record<string, SyncCreator> = {
[POSITION]: synchronizers.createCameraPositionSynchronizer,
[VOI]: synchronizers.createVOISynchronizer,
[ZOOMPAN]: synchronizers.createZoomPanSynchronizer,
[STACKIMAGE]: synchronizers.createStackImageSynchronizer,
};
constructor(serviceManager) {
this.serviceManager = serviceManager;
@ -29,64 +55,95 @@ export default class SyncGroupService {
Object.assign(this, pubSubServiceInterface);
}
private _createSynchronizer(type: string, id: string) {
type = type.toLowerCase();
if (type === POSITION) {
return synchronizers.createCameraPositionSynchronizer(id);
} else if (type === VOI) {
return synchronizers.createVOISynchronizer(id);
private _createSynchronizer(
type: string,
id: string,
options
): Synchronizer | undefined {
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(
viewportId: string,
renderingEngineId: string,
syncGroups?: SyncGroup[]
syncGroups?: SyncGroup | string | SyncGroup[] | string[]
): void {
if (!syncGroups || !syncGroups.length) {
if (!syncGroups) {
return;
}
syncGroups.forEach(syncGroup => {
const { type, id, target, source } = syncGroup;
const syncGroupsArray = Array.isArray(syncGroups)
? 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) {
synchronizer = this._createSynchronizer(type, id);
}
const synchronizer = this._getOrCreateSynchronizer(type, id, options);
synchronizer.setOptions(viewportId, options);
const viewportInfo = { viewportId, renderingEngineId };
if (target && source) {
synchronizer.add({
viewportId,
renderingEngineId,
});
synchronizer.add(viewportInfo);
return;
} else if (source) {
synchronizer.addSource({
viewportId,
renderingEngineId,
});
synchronizer.addSource(viewportInfo);
} else if (target) {
synchronizer.addTarget({
viewportId,
renderingEngineId,
});
synchronizer.addTarget(viewportInfo);
}
});
}
public destroy() {
public destroy(): void {
SynchronizerManager.destroy();
}
public removeViewportFromSyncGroup(
viewportId: string,
renderingEngineId: string
renderingEngineId: string,
syncGroupId?: string
): void {
const synchronizers = SynchronizerManager.getAllSynchronizers();
synchronizers.forEach(synchronizer => {
const filteredSynchronizers = syncGroupId
? synchronizers.filter(s => s.id === syncGroupId)
: synchronizers;
filteredSynchronizers.forEach(synchronizer => {
if (!synchronizer) {
return;
}

View File

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

View File

@ -1,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 {
RenderingEngine,
StackViewport,
Enums,
Types,
getRenderingEngine,
utilities as csUtils,
@ -18,7 +17,10 @@ import ViewportInfo, {
DisplaySetOptions,
PublicViewportOptions,
} from './Viewport';
import { StackData, VolumeData } from './CornerstoneCacheService';
import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
import {
setColormap,
setLowerUpperColorTransferFunction,
@ -27,8 +29,8 @@ import {
import JumpPresets from '../../utils/JumpPresets';
const EVENTS = {
VIEWPORT_INFO_CREATED:
'event::cornerstone::viewportservice:viewportinfocreated',
VIEWPORT_DATA_CHANGED:
'event::cornerstoneViewportService:viewportDataChanged',
};
/**
@ -39,6 +41,7 @@ class CornerstoneViewportService implements IViewportService {
renderingEngine: Types.IRenderingEngine | null;
viewportsInfo: Map<number, ViewportInfo>;
viewportGridResizeObserver: ResizeObserver | null;
viewportsDisplaySets: Map<string, string[]> = new Map();
/**
* Service-specific
@ -50,6 +53,7 @@ class CornerstoneViewportService implements IViewportService {
enableResizeDetector: true;
resizeRefreshRateMs: 200;
resizeRefreshMode: 'debounce';
servicesManager = null;
constructor(servicesManager) {
this.renderingEngine = null;
@ -58,8 +62,7 @@ class CornerstoneViewportService implements IViewportService {
//
this.listeners = {};
this.EVENTS = EVENTS;
const { HangingProtocolService } = servicesManager.services;
this.HangingProtocolService = HangingProtocolService;
this.servicesManager = servicesManager;
Object.assign(this, pubSubServiceInterface);
//
}
@ -69,18 +72,29 @@ class CornerstoneViewportService implements IViewportService {
* @param {*} viewportIndex
* @param {*} elementRef
*/
public enableElement(
public enableViewport(
viewportIndex: number,
viewportOptions: PublicViewportOptions,
elementRef: HTMLDivElement
) {
const viewportId =
viewportOptions.viewportId || this.getViewportId(viewportIndex);
const viewportInfo = new ViewportInfo(viewportIndex, viewportId);
const viewportInfo = new ViewportInfo(
viewportIndex,
this.getViewportId(viewportIndex)
);
viewportInfo.setElement(elementRef);
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 {
return `viewport-${viewportIndex}`;
}
@ -110,10 +124,9 @@ class CornerstoneViewportService implements IViewportService {
*/
public resize() {
const immediate = true;
const resetPan = false;
const resetZoom = false;
const keepCamera = true;
this.renderingEngine.resize(immediate, resetPan, resetZoom);
this.renderingEngine.resize(immediate, keepCamera);
this.renderingEngine.render();
}
@ -124,8 +137,9 @@ class CornerstoneViewportService implements IViewportService {
this._removeResizeObserver();
this.viewportGridResizeObserver = null;
this.renderingEngine.destroy();
this.viewportsDisplaySets.clear();
this.renderingEngine = null;
cache.purgeVolumeCache();
cache.purgeCache();
}
/**
@ -140,12 +154,11 @@ class CornerstoneViewportService implements IViewportService {
}
const viewportId = viewportInfo.getViewportId();
this.renderingEngine.disableElement(viewportId);
this.viewportsInfo.delete(viewportIndex);
if (this.viewportsInfo.size === 0) {
this.destroy();
}
this.renderingEngine && this.renderingEngine.disableElement(viewportId);
this.viewportsInfo.get(viewportIndex).destroy();
this.viewportsInfo.delete(viewportIndex);
}
/**
@ -156,29 +169,56 @@ class CornerstoneViewportService implements IViewportService {
* @param {*} dataSource
* @returns
*/
public setViewportDisplaySets(
public setViewportData(
viewportIndex: number,
viewportData: StackData | VolumeData,
viewportData: StackViewportData | VolumeViewportData,
publicViewportOptions: PublicViewportOptions,
publicDisplaySetOptions: DisplaySetOptions[]
): void {
const renderingEngine = this.getRenderingEngine();
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);
const { viewportOptions, displaySetOptions } =
this._getViewportAndDisplaySetOptions(
publicViewportOptions,
publicDisplaySetOptions,
viewportInfo
);
const {
viewportOptions,
displaySetOptions,
} = this._getViewportAndDisplaySetOptions(
publicViewportOptions,
publicDisplaySetOptions,
viewportInfo
);
viewportInfo.setViewportOptions(viewportOptions);
viewportInfo.setDisplaySetOptions(displaySetOptions);
viewportInfo.setViewportData(viewportData);
this._broadcastEvent(EVENTS.VIEWPORT_INFO_CREATED, viewportInfo);
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportIndex,
});
const viewportId = viewportInfo.getViewportId();
viewportId = viewportInfo.getViewportId();
const element = viewportInfo.getElement();
const type = viewportInfo.getViewportType();
const background = viewportInfo.getBackground();
@ -199,7 +239,9 @@ class CornerstoneViewportService implements IViewportService {
// renderingEngine is designed to be used like this. This will trigger
// ENABLED_ELEMENT again and again, which will run onEnableElement callbacks
renderingEngine.enableElement(viewportInput);
this._setDisplaySets(viewportId, viewportData, viewportInfo);
const viewport = renderingEngine.getViewport(viewportId);
this._setDisplaySets(viewport, viewportData, viewportInfo);
}
public getCornerstoneViewport(
@ -261,12 +303,18 @@ class CornerstoneViewportService implements IViewportService {
_setStackViewport(
viewport: Types.IStackViewport,
viewportData: StackData,
viewportData: StackViewportData,
viewportInfo: ViewportInfo
) {
const displaySetOptions = viewportInfo.getDisplaySetOptions();
const { imageIds, initialImageIndex } = viewportData;
const {
imageIds,
initialImageIndex,
displaySetInstanceUID,
} = viewportData.data;
this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]);
let initialImageIndexToUse = initialImageIndex;
@ -328,7 +376,15 @@ class CornerstoneViewportService implements IViewportService {
}
if (preset === JumpPresets.Middle) {
return Math.floor(lastSliceIndex / 2);
// Note: this is a simple but yet very important formula.
// since viewport reset works with the middle slice
// if the below formula is not correct, on a viewport reset
// it will jump to a different slice than the middle one which
// was the initial slice, and we have some tools such as Crosshairs
// which rely on a relative camera modifications and those will break.
return lastSliceIndex % 2 === 0
? lastSliceIndex / 2
: (lastSliceIndex + 1) / 2;
}
return 0;
@ -336,7 +392,7 @@ class CornerstoneViewportService implements IViewportService {
async _setVolumeViewport(
viewport: Types.IVolumeViewport,
viewportData: VolumeData,
viewportData: VolumeViewportData,
viewportInfo: ViewportInfo
): Promise<void> {
// TODO: We need to overhaul the way data sources work so requests can be made
@ -352,21 +408,24 @@ class CornerstoneViewportService implements IViewportService {
// (This call may or may not create sub-requests for series metadata)
const volumeInputArray = [];
const displaySetOptionsArray = viewportInfo.getDisplaySetOptions();
const { HangingProtocolService } = this;
const { HangingProtocolService } = this.servicesManager.services;
for (let i = 0; i < viewportData.imageIds.length; i++) {
const imageIds = viewportData.imageIds[i];
const displaySetInstanceUID = viewportData.displaySetInstanceUIDs[i];
const displaySetOptions = displaySetOptionsArray[i];
const volumeToLoad = [];
const displaySetInstanceUIDs = [];
const volumeId = displaySetInstanceUID;
for (const [index, data] of viewportData.data.entries()) {
const { volume, imageIds, displaySetInstanceUID } = data;
// if (displaySet.needsRerendering) {
// console.warn('Removing volume from cache', volumeId);
// cache.removeVolumeLoadObject(volumeId);
// displaySet.needsRerendering = false;
// this.displaySetsNeedRerendering.add(displaySet.displaySetInstanceUID);
// }
displaySetInstanceUIDs.push(displaySetInstanceUID);
if (!volume) {
continue;
}
volumeToLoad.push(volume);
const displaySetOptions = displaySetOptionsArray[index];
const { volumeId } = volume;
const voiCallbacks = this._getVOICallbacks(volumeId, displaySetOptions);
@ -383,6 +442,8 @@ class CornerstoneViewportService implements IViewportService {
});
}
this.viewportsDisplaySets.set(viewport.id, displaySetInstanceUIDs);
if (
HangingProtocolService.hasCustomImageLoadStrategy() &&
!HangingProtocolService.customImageLoadPerformed
@ -394,7 +455,7 @@ class CornerstoneViewportService implements IViewportService {
});
}
viewportData.volumes.forEach(volume => {
volumeToLoad.forEach(volume => {
volume.load();
});
@ -403,9 +464,98 @@ class CornerstoneViewportService implements IViewportService {
}
public async setVolumesForViewport(viewport, volumeInputArray) {
const {
DisplaySetService,
SegmentationService,
ToolGroupService,
} = this.servicesManager.services;
await viewport.setVolumes(volumeInputArray);
// load any secondary displaySets
const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id);
const segDisplaySet = displaySetInstanceUIDs
.map(DisplaySetService.getDisplaySetByUID)
.find(displaySet => displaySet && displaySet.Modality === 'SEG');
if (segDisplaySet) {
const { referencedVolumeId } = segDisplaySet;
const referencedVolume = cache.getVolume(referencedVolumeId);
const segmentationId = segDisplaySet.displaySetInstanceUID;
const toolGroup = ToolGroupService.getToolGroupForViewport(viewport.id);
if (referencedVolume) {
SegmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentationId
);
}
} else {
const toolGroup = ToolGroupService.getToolGroupForViewport(viewport.id);
const toolGroupSegmentationRepresentations =
SegmentationService.getSegmentationRepresentationsForToolGroup(
toolGroup.id
) || [];
// csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
// If the displaySet is not a SEG displaySet we assume it is a primary displaySet
// and we can look into hydrated segmentations to check if any of them are
// associated with the primary displaySet
// get segmentations only returns the hydrated segmentations
const segmentations = SegmentationService.getSegmentations();
for (const segmentation of segmentations) {
// if there is already a segmentation representation for this segmentation
// for this toolGroup, don't bother at all
if (
toolGroupSegmentationRepresentations.find(
representation => representation.segmentationId === segmentation.id
)
) {
continue;
}
// otherwise, check if the hydrated segmentations are in the same FOR
// as the primary displaySet, if so add the representation (since it was not there)
const { id: segDisplaySetInstanceUID } = segmentation;
const segFrameOfReferenceUID = this._getFrameOfReferenceUID(
segDisplaySetInstanceUID
);
let shouldDisplaySeg = false;
for (const displaySetInstanceUID of displaySetInstanceUIDs) {
const primaryFrameOfReferenceUID = this._getFrameOfReferenceUID(
displaySetInstanceUID
);
if (segFrameOfReferenceUID === primaryFrameOfReferenceUID) {
shouldDisplaySeg = true;
break;
}
}
if (shouldDisplaySeg) {
const toolGroup = ToolGroupService.getToolGroupForViewport(
viewport.id
);
SegmentationService.addSegmentationRepresentationToToolGroup(
toolGroup.id,
segmentation.id
);
}
}
}
const viewportInfo = this.getViewportInfo(viewport.id);
const toolGroup = ToolGroupService.getToolGroupForViewport(viewport.id);
csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
const initialImageOptions = viewportInfo.getInitialImageOptions();
if (
@ -415,8 +565,9 @@ class CornerstoneViewportService implements IViewportService {
) {
const { index, preset } = initialImageOptions;
const { numberOfSlices } =
csUtils.getImageSliceDataForVolumeViewport(viewport);
const { numberOfSlices } = csUtils.getImageSliceDataForVolumeViewport(
viewport
);
const imageIndex = this._getInitialImageIndex(
numberOfSlices,
@ -488,18 +639,20 @@ class CornerstoneViewportService implements IViewportService {
}
_setDisplaySets(
viewportId: string,
viewportData: StackData | VolumeData,
viewport: StackViewport | VolumeViewport,
viewportData: StackViewportData | VolumeViewportData,
viewportInfo: ViewportInfo
): void {
const viewport = this.getCornerstoneViewport(viewportId);
if (viewport instanceof StackViewport) {
this._setStackViewport(viewport, viewportData as StackData, viewportInfo);
this._setStackViewport(
viewport,
viewportData as StackViewportData,
viewportInfo
);
} else if (viewport instanceof VolumeViewport) {
this._setVolumeViewport(
viewport,
viewportData as VolumeData,
viewportData as VolumeViewportData,
viewportInfo
);
} else {
@ -574,6 +727,31 @@ class CornerstoneViewportService implements IViewportService {
displaySetOptions: newDisplaySetOptions,
};
}
_getFrameOfReferenceUID(displaySetInstanceUID) {
const { DisplaySetService } = this.servicesManager.services;
const displaySet = DisplaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
if (!displaySet) {
return;
}
if (displaySet.frameOfReferenceUID) {
return displaySet.frameOfReferenceUID;
}
if (displaySet.Modality === 'SEG') {
const { instance } = displaySet;
return instance.FrameOfReferenceUID;
}
const { images } = displaySet;
if (images && images.length) {
return images[0].FrameOfReferenceUID;
}
}
}
export default function ExtendedCornerstoneViewportService(serviceManager) {

View File

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

View File

@ -1,9 +1,13 @@
import { Types, Enums, CONSTANTS } from '@cornerstonejs/core';
import { Types, Enums } from '@cornerstonejs/core';
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import JumpPresets from '../../utils/JumpPresets';
import { SyncGroup } from '../SyncGroupService/SyncGroupService';
import {
StackViewportData,
VolumeViewportData,
} from './CornerstoneCacheService';
export type InitialImageOptions = {
index?: number;
@ -16,7 +20,6 @@ export type ViewportOptions = {
viewportId: string;
orientation?: Types.Orientation;
background?: Types.Point3;
initialView?: string;
syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions;
customViewportProps?: Record<string, unknown>;
@ -28,7 +31,6 @@ export type PublicViewportOptions = {
viewportId?: string;
orientation?: string;
background?: Types.Point3;
initialView?: string;
syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions;
customViewportProps?: Record<string, unknown>;
@ -69,6 +71,7 @@ class ViewportInfo {
private element: HTMLDivElement;
private viewportOptions: ViewportOptions;
private displaySetOptions: Array<DisplaySetOptions>;
private viewportData: StackViewportData | VolumeViewportData;
private renderingEngineId: string;
constructor(viewportIndex: number, viewportId: string) {
@ -78,6 +81,13 @@ class ViewportInfo {
this.setPublicDisplaySetOptions([{}]);
}
public destroy = (): void => {
this.element = null;
this.viewportData = null;
this.viewportOptions = null;
this.displaySetOptions = null;
};
public setRenderingEngineId(renderingEngineId: string): void {
this.renderingEngineId = renderingEngineId;
}
@ -97,6 +107,16 @@ class ViewportInfo {
this.element = element;
}
public setViewportData(
viewportData: StackViewportData | VolumeViewportData
): void {
this.viewportData = viewportData;
}
public getViewportData(): StackViewportData | VolumeViewportData {
return this.viewportData;
}
public getViewportIndex(): number {
return this.viewportIndex;
}
@ -120,6 +140,23 @@ class ViewportInfo {
this.setDisplaySetOptions(displaySetOptions);
}
public hasDisplaySet(displaySetInstanceUID: string): boolean {
// Todo: currently this does not work for non image & referenceImage displaySets.
// Since SEG and other derived displaySets are loaded in a different way, and not
// via cornerstoneViewportService
let viewportData = this.getViewportData();
if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
viewportData = viewportData as VolumeViewportData;
return viewportData.data.some(
({ displaySetInstanceUID: dsUID }) => dsUID === displaySetInstanceUID
);
}
viewportData = viewportData as StackViewportData;
return viewportData.data.displaySetInstanceUID === displaySetInstanceUID;
}
public setPublicViewportOptions(
viewportOptionsEntry: PublicViewportOptions
): void {
@ -139,7 +176,7 @@ class ViewportInfo {
if (viewportOptionsEntry.viewportType?.toLowerCase() === VOLUME) {
orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
} else {
orientation = CONSTANTS.ORIENTATION.AXIAL;
orientation = Enums.OrientationAxis.AXIAL;
}
if (!toolGroupId) {
@ -203,12 +240,21 @@ class ViewportInfo {
const displaySetOptions: Array<DisplaySetOptions> = [];
publicDisplaySetOptions.forEach(option => {
if (!option) {
option = {
blendMode: undefined,
slabThickness: undefined,
colormap: undefined,
voi: {},
voiInverted: false,
};
}
const blendMode = getCornerstoneBlendMode(option.blendMode);
displaySetOptions.push({
voi: option.voi || ({} as VOI),
voiInverted: option.voiInverted || false,
colormap: option.colormap || undefined,
voi: option.voi,
voiInverted: option.voiInverted,
colormap: option.colormap,
slabThickness: option.slabThickness,
blendMode,
});

View File

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

View File

@ -0,0 +1,38 @@
import {
Enums,
Types,
} from '@cornerstonejs/core';
type StackData = {
StudyInstanceUID: string;
displaySetInstanceUID: string;
imageIds: string[];
frameRate?: number;
isClip?: boolean;
initialImageIndex?: number | string | null;
};
type VolumeData = {
studyInstanceUID: string;
displaySetInstanceUID: string;
volume?: Types.IVolume;
imageIds?: string[];
};
type StackViewportData = {
viewportType: Enums.ViewportType;
data: StackData;
};
type VolumeViewportData = {
viewportType: Enums.ViewportType;
data: VolumeData[];
};
export type {
StackViewportData,
VolumeViewportData,
StackData,
VolumeData,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ const viewportIdVolumeInputArrayMap = new Map<string, unknown[]>();
export default function interleaveTopToBottom({
data: { viewportId, volumeInputArray },
displaySetsMatchDetails,
matchDetails,
viewportMatchDetails: matchDetails,
}) {
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
* since volumes are already started loading.
*/
if (matchDetails.length !== viewportIdVolumeInputArrayMap.size) {
if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) {
return;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,7 @@ function _getUIDFromImageID(imageId) {
SOPInstanceUID: instance.SOPInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
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": {
"@ohif/core": "^3.0.0",
"@ohif/i18n": "^1.0.0",
"dcmjs": "^0.24.5",
"dcmjs": "^0.28.3",
"dicomweb-client": "^0.6.0",
"prop-types": "^15.6.2",
"react": "^17.0.2",
@ -43,6 +43,6 @@
},
"dependencies": {
"@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 getImageId from '../DicomWebDataSource/utils/getImageId';
import getDirectURL from '../utils/getDirectURL';
const metadataProvider = OHIF.classes.MetadataProvider;
@ -40,7 +41,7 @@ const findStudies = (key, value) => {
};
function createDicomJSONApi(dicomJsonConfig) {
const { name } = dicomJsonConfig;
const { name, wadoRoot } = dicomJsonConfig;
const implementation = {
initialize: async ({ params, query, url }) => {
@ -93,7 +94,7 @@ function createDicomJSONApi(dicomJsonConfig) {
},
query: {
studies: {
mapParams: () => {},
mapParams: () => { },
search: async param => {
const [key, value] = Object.entries(param)[0];
const mappedParam = mappings[key];
@ -133,6 +134,22 @@ function createDicomJSONApi(dicomJsonConfig) {
},
},
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: {
metadata: ({
StudyInstanceUID,

View File

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

View File

@ -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,
} from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient.js';
import getDirectURL from '../utils/getDirectURL.js';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -70,6 +71,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const wadoConfig = {
url: wadoRoot,
staticWado,
singlepart,
headers: UserAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
@ -80,15 +82,19 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const qidoDicomWebClient = staticWado
? new StaticWadoClient(qidoConfig)
: new api.DICOMwebClient(qidoConfig);
const wadoDicomWebClient = new api.DICOMwebClient(wadoConfig);
const wadoDicomWebClient = staticWado
? new StaticWadoClient(wadoConfig)
: new api.DICOMwebClient(wadoConfig);
const implementation = {
initialize: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.get('StudyInstanceUIDs');
const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const StudyInstanceUIDs =
queryStudyInstanceUIDs || paramsStudyInstanceUIDs;
(queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) ||
paramsStudyInstanceUIDs;
const StudyInstanceUIDsAsArray =
StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs)
? StudyInstanceUIDs
@ -98,7 +104,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
query: {
studies: {
mapParams: mapParams.bind(),
search: async function(origParams) {
search: async function (origParams) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
@ -123,7 +129,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
},
series: {
// mapParams: mapParams.bind(),
search: async function(studyInstanceUid) {
search: async function (studyInstanceUid) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (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
*/
directURL: params => {
const {
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);
return getDirectURL(wadoRoot, params);
},
series: {
metadata: async ({
@ -418,6 +366,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
// any implementation that stores static copies of the metadata
StudyInstanceUID: naturalized.StudyInstanceUID,
};
// Todo: this needs to be from wado dicom web client
return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined;
value.Value = ret;
@ -435,6 +384,9 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach((instance, index) => {
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
const imageId = implementation.getImageIdsForInstance({
instance,
});
@ -473,12 +425,13 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
const numberOfSeries = seriesPromises.length;
seriesPromises.forEach(async (seriesPromise, index) => {
const instances = await seriesPromise;
storeInstances(instances);
if (index === numberOfSeries - 1) setSuccessFlag();
});
const seriesDeliveredPromises = seriesPromises.map(promise =>
promise.then(instances => {
storeInstances(instances);
})
);
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
},
deleteStudyMetadataPromise,
getImageIdsForDisplaySet(displaySet) {
@ -493,10 +446,10 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) {
for (let i = 0; i < NumberOfFrames; i++) {
for (let frame = 1; frame <= NumberOfFrames; frame++) {
const imageId = this.getImageIdsForInstance({
instance,
frame: i,
frame,
});
imageIds.push(imageId);
}

View File

@ -171,7 +171,7 @@ function mapParams(params, options = {}) {
// Named
PatientName: withWildcard(params.patientName),
//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),
StudyDescription: withWildcard(params.studyDescription),
ModalitiesInStudy: params.modalitiesInStudy,

View File

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

View File

@ -8,14 +8,6 @@ import { utils } from '@ohif/core';
const { downloadCSVReport } = utils;
// tools with measurements to display inside the panel
const MEASUREMENT_TOOLS = [
'EllipticalROI',
'RectangleROI',
'Length',
'Bidirectional',
];
export default function PanelMeasurementTable({
servicesManager,
commandsManager,
@ -155,7 +147,6 @@ export default function PanelMeasurementTable({
>
<MeasurementTable
title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
@ -186,12 +177,8 @@ PanelMeasurementTable.propTypes = {
function _getMappedMeasurements(MeasurementService) {
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)
);

View File

@ -10,12 +10,17 @@ const { formatDate } = utils;
* @param {*} param0
*/
function PanelStudyBrowser({
DisplaySetService,
servicesManager,
getImageSrc,
getStudiesForPatientByStudyInstanceUID,
requestDisplaySetCreationForStudy,
dataSource,
}) {
const {
HangingProtocolService,
DisplaySetService,
UINotificationService,
} = servicesManager.services;
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> Studies --> DisplaySets --> Thumbnails
@ -34,10 +39,25 @@ function PanelStudyBrowser({
const isMounted = useRef(true);
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
viewportGridService.setDisplaySetsForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [displaySetInstanceUID],
});
let updatedViewports = [];
const viewportIndex = activeViewportIndex;
try {
updatedViewports = HangingProtocolService.getViewportsRequireUpdate(
viewportIndex,
displaySetInstanceUID
);
} catch (error) {
console.warn(error);
UINotificationService.show({
title: 'Thumbnail Double Click',
message:
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'info',
duration: 3000,
});
}
viewportGridService.setDisplaySetsForViewports(updatedViewports);
};
// ~~ studyDisplayList
@ -61,7 +81,19 @@ function PanelStudyBrowser({
};
});
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 = {
DisplaySetService: PropTypes.shape({
EVENTS: PropTypes.object.isRequired,
activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired,
getDisplaySetByUID: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
}).isRequired,
servicesManager: PropTypes.object.isRequired,
dataSource: PropTypes.shape({
getImageIdsForDisplaySet: PropTypes.func.isRequired,
}).isRequired,

View File

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

View File

@ -0,0 +1,62 @@
import React, { useEffect, useState } from 'react';
export default function Toolbar({ servicesManager }) {
const { ToolBarService } = servicesManager.services;
const [toolbarButtons, setToolbarButtons] = useState([]);
const [buttonState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
// Could track buttons and state separately...?
useEffect(() => {
const { unsubscribe: unsub1 } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_MODIFIED,
() => setToolbarButtons(ToolBarService.getButtonSection('primary'))
);
const { unsubscribe: unsub2 } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => setButtonState({ ...ToolBarService.state })
);
return () => {
unsub1();
unsub2();
};
}, [ToolBarService]);
return (
<>
{toolbarButtons.map((toolDef, index) => {
const { id, Component, componentProps } = toolDef;
// TODO: ...
// isActive if:
// - id is primary?
// - id is in list of "toggled on"?
let isActive;
if (componentProps.type === 'toggle') {
isActive = buttonState.toggles[id];
}
// Also need... to filter list for splitButton, and set primary based on most recently clicked
// Also need to kill the radioGroup button's magic logic
// Everything should be reactive off these props, so commands can inform ToolbarService
// These can... Trigger toolbar events based on updates?
// Then sync using useEffect, or simply modify the state here?
return (
<Component
key={id}
id={id}
{...componentProps}
bState={buttonState}
isActive={isActive}
onInteraction={args => ToolBarService.recordInteraction(args)}
servicesManager={servicesManager}
/>
);
})}
</>
);
}

View File

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

View File

@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import {
SidePanel,
ErrorBoundary,
@ -9,74 +11,15 @@ import {
AboutModal,
Header,
useModal,
LoadingIndicatorProgress,
} from '@ohif/ui';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { useAppConfig } from '@state';
import Toolbar from '../Toolbar/Toolbar';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
function Toolbar({ servicesManager }) {
const { ToolBarService } = servicesManager.services;
const [toolbarButtons, setToolbarButtons] = useState([]);
const [buttonState, setButtonState] = useState({
primaryToolId: '',
toggles: {},
groups: {},
});
// Could track buttons and state separately...?
useEffect(() => {
const { unsubscribe: unsub1 } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_MODIFIED,
() => setToolbarButtons(ToolBarService.getButtonSection('primary'))
);
const { unsubscribe: unsub2 } = ToolBarService.subscribe(
ToolBarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => setButtonState({ ...ToolBarService.state })
);
return () => {
unsub1();
unsub2();
};
}, [ToolBarService]);
return (
<>
{toolbarButtons.map((toolDef, index) => {
const { id, Component, componentProps } = toolDef;
// TODO: ...
// isActive if:
// - id is primary?
// - id is in list of "toggled on"?
let isActive;
if (componentProps.type === 'toggle') {
isActive = buttonState.toggles[id];
}
// Also need... to filter list for splitButton, and set primary based on most recently clicked
// Also need to kill the radioGroup button's magic logic
// Everything should be reactive off these props, so commands can inform ToolbarService
// These can... Trigger toolbar events based on updates?
// Then sync using useEffect, or simply modify the state here?
return (
<Component
key={id}
id={id}
{...componentProps}
bState={buttonState}
isActive={isActive}
onInteraction={args => ToolBarService.recordInteraction(args)}
/>
);
})}
</>
);
}
function ViewerLayout({
// From Extension Module Params
extensionManager,
@ -84,21 +27,39 @@ function ViewerLayout({
hotkeysManager,
commandsManager,
// From Modes
leftPanels,
rightPanels,
viewports,
ViewportGridComp,
leftPanels = [],
rightPanels = [],
leftPanelDefaultClosed = false,
rightPanelDefaultClosed = false,
}) {
const [appConfig] = useAppConfig();
const navigate = useNavigate();
const location = useLocation();
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 { show, hide } = useModal();
const [showLoadingIndicator, setShowLoadingIndicator] = useState(
appConfig.showLoadingIndicator
);
const { HangingProtocolService } = servicesManager.services;
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const versionNumber = process.env.VERSION_NUMBER;
const buildNumber = process.env.BUILD_NUM;
@ -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
* 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);
// 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 {
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 entry = extensionManager.getModuleEntry(viewportComponent.namespace);
const { entry } = getComponent(viewportComponent.namespace);
return {
component: entry.component,
@ -201,40 +212,45 @@ function ViewerLayout({
</ErrorBoundary>
</Header>
<div
className="bg-black flex flex-row items-stretch w-full overflow-hidden flex-nowrap"
className="bg-black flex flex-row items-stretch w-full overflow-hidden flex-nowrap relative"
style={{ height: 'calc(100vh - 52px' }}
>
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel">
<SidePanel
side="left"
defaultComponentOpen={leftPanelComponents[0].name}
childComponents={leftPanelComponents}
/>
</ErrorBoundary>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
<React.Fragment>
{showLoadingIndicator && (
<LoadingIndicatorProgress className="h-full w-full bg-black" />
)}
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel">
<SidePanel
side="left"
activeTabIndex={leftPanelDefaultClosed ? null : 0}
tabs={leftPanelComponents}
/>
</ErrorBoundary>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black relative">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</ErrorBoundary>
</div>
</div>
</div>
{rightPanelComponents.length ? (
<ErrorBoundary context="Right Panel">
<SidePanel
side="right"
defaultComponentOpen={rightPanelComponents[0].name}
childComponents={rightPanelComponents}
/>
</ErrorBoundary>
) : null}
{rightPanelComponents.length ? (
<ErrorBoundary context="Right Panel">
<SidePanel
side="right"
activeTabIndex={rightPanelDefaultClosed ? null : 0}
tabs={rightPanelComponents}
/>
</ErrorBoundary>
) : null}
</React.Fragment>
</div>
</div>
);
@ -249,13 +265,10 @@ ViewerLayout.propTypes = {
// From modes
leftPanels: PropTypes.array,
rightPanels: PropTypes.array,
leftPanelDefaultClosed: PropTypes.bool.isRequired,
rightPanelDefaultClosed: PropTypes.bool.isRequired,
/** Responsible for rendering our grid of viewports; provided by consuming application */
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
};
ViewerLayout.defaultProps = {
leftPanels: [],
rightPanels: [],
};
export default ViewerLayout;

View File

@ -1,8 +1,13 @@
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import React from 'react';
const commandsModule = ({ servicesManager, commandsManager }) => {
const {
MeasurementService,
HangingProtocolService,
UINotificationService,
ViewportGridService,
DisplaySetService,
} = servicesManager.services;
const actions = {
@ -23,6 +28,25 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
previousStage: () => {
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 = {
@ -46,6 +70,9 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
storeContexts: [],
options: {},
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
};
return {

View File

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

View File

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

View File

@ -1,4 +1,5 @@
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 isDisplaySetReconstructable from '@ohif/core/src/utils/isDisplaySetReconstructable';
import { id } from './id';
@ -24,6 +25,7 @@ const makeDisplaySet = instances => {
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
FrameRate: instance.FrameTime,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: instance.SeriesDescription || '',
Modality: instance.Modality,
isMultiFrame: isMultiFrame(instance),
@ -118,7 +120,7 @@ function getDisplaySetsFromSeries(instances) {
acquisitionDatetime: instance.AcquisitionDateTime,
});
displaySets.push(displaySet);
} else if (isSingleImageModality(instance.modality)) {
} else if (isSingleImageModality(instance.Modality)) {
displaySet = makeDisplaySet([instance]);
displaySet.setAttributes({
sopClassUids,
@ -143,138 +145,6 @@ function getDisplaySetsFromSeries(instances) {
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 = [
sopClassDictionary.ComputedRadiographyImageStorage,
sopClassDictionary.DigitalXRayImageStorageForPresentation,
@ -325,6 +195,7 @@ const sopClassUids = [
sopClassDictionary.EnhancedPETImageStorage,
sopClassDictionary.LegacyConvertedEnhancedPETImageStorage,
sopClassDictionary.RTImageStorage,
sopClassDictionary.EnhancedUSVolumeStorage,
];
function getSopClassHandlerModule() {

View File

@ -2,9 +2,9 @@ import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getHangingProtocolModule from './getHangingProtocolModule.js';
import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
import { id } from './id.js';
import init from './init';
@ -18,9 +18,9 @@ const defaultExtension = {
init({ servicesManager, configuration });
},
getDataSourcesModule,
getHangingProtocolModule,
getLayoutTemplateModule,
getPanelModule,
getHangingProtocolModule,
getSopClassHandlerModule,
getToolbarModule,
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": {
"@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0",
"dcmjs": "^0.24.5",
"dcmjs": "^0.28.3",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",

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