init: sudah ganti logo, hilangin setting, dan investigational use dialog

This commit is contained in:
one
2025-03-06 11:32:45 +07:00
commit 8f31d4ed41
2857 changed files with 355646 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};

View File

@@ -0,0 +1,54 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-pmap',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
MIT License
Copyright (c) 2023 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,12 @@
# dicom-pmap
## Description
DICOM PMAP read workflow. This extension will allow you to load a DICOM Parametric
Map image and display it on OHIF.
## Author
OHIF
## License
MIT

View File

@@ -0,0 +1,44 @@
module.exports = {
plugins: ['@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,54 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.10.0-beta.111",
"description": "DICOM Parametric Map read workflow",
"author": "OHIF",
"license": "MIT",
"main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js",
"module": "src/index.tsx",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-pmap": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/extension-cornerstone": "3.10.0-beta.111",
"@ohif/extension-default": "3.10.0-beta.111",
"@ohif/i18n": "3.10.0-beta.111",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^12.2.2",
"react-router": "^6.8.1",
"react-router-dom": "^6.8.1"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^2.19.14",
"@cornerstonejs/core": "^2.19.14",
"@kitware/vtk.js": "32.1.1",
"react-color": "^2.19.3"
}
}

View File

@@ -0,0 +1,244 @@
import { utils } from '@ohif/core';
import { metaData, cache, utilities as csUtils, volumeLoader } from '@cornerstonejs/core';
import { adaptersPMAP } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomLoaderService } from '@ohif/extension-cornerstone';
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.30'];
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
const instance = instances[0];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
wadoRoot,
wadoUri,
wadoUriRoot,
} = instance;
const displaySet = {
// Parametric map use to have the same modality as its referenced volume but
// "PMAP" is used in the viewer even though this is not a valid DICOM modality
Modality: 'PMAP',
isReconstructable: true, // by default for now
displaySetInstanceUID: `pmap.${utils.guid()}`,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
referencedVolumeURI: null,
referencedVolumeId: null,
isDerivedDisplaySet: true,
loadStatus: {
loading: false,
loaded: false,
},
sopClassUids,
instance,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the parametric map');
return;
}
const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence;
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
// Does not get the referenced displaySet during parametric displaySet creation
// because it is still not available (getDisplaySetByUID returns `undefined`).
displaySet.getReferenceDisplaySet = () => {
const { displaySetService } = servicesManager.services;
if (displaySet.referencedDisplaySetInstanceUID) {
return displaySetService.getDisplaySetByUID(displaySet.referencedDisplaySetInstanceUID);
}
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
displaySet.referencedSeriesInstanceUID
);
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
throw new Error('Referenced displaySet is missing for the parametric map');
}
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
return referencedDisplaySet;
};
// Does not get the referenced volumeId during parametric displaySet creation because the
// referenced displaySet is still not available (getDisplaySetByUID returns `undefined`).
displaySet.getReferencedVolumeId = () => {
if (displaySet.referencedVolumeId) {
return displaySet.referencedVolumeId;
}
const referencedDisplaySet = displaySet.getReferenceDisplaySet();
const referencedVolumeURI = referencedDisplaySet.displaySetInstanceUID;
const referencedVolumeId = `${VOLUME_LOADER_SCHEME}:${referencedVolumeURI}`;
displaySet.referencedVolumeURI = referencedVolumeURI;
displaySet.referencedVolumeId = referencedVolumeId;
return referencedVolumeId;
};
displaySet.load = async ({ headers }) =>
await _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
const getRangeFromPixelData = (pixelData: Float32Array) => {
let lowest = pixelData[0];
let highest = pixelData[0];
for (let i = 1; i < pixelData.length; i++) {
if (pixelData[i] < lowest) {
lowest = pixelData[i];
}
if (pixelData[i] > highest) {
highest = pixelData[i];
}
}
return [lowest, highest];
};
async function _load(
displaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers
) {
const volumeId = `${VOLUME_LOADER_SCHEME}:${displaySet.displaySetInstanceUID}`;
const volumeLoadObject = cache.getVolumeLoadObject(volumeId);
if (volumeLoadObject) {
return volumeLoadObject.promise;
}
displaySet.loading = true;
displaySet.isLoaded = false;
// 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.
const promise = _loadParametricMap({
extensionManager,
displaySet,
headers,
});
cache.putVolumeLoadObject(volumeId, { promise }).catch(err => {
throw err;
});
promise
.then(() => {
displaySet.loading = false;
displaySet.isLoaded = true;
// Broadcast that loading is complete
servicesManager.services.segmentationService._broadcastEvent(
servicesManager.services.segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
{
pmapDisplaySet: displaySet,
}
);
})
.catch(err => {
displaySet.loading = false;
displaySet.isLoaded = false;
throw err;
});
return promise;
}
async function _loadParametricMap({ displaySet, headers }: withAppTypes) {
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(displaySet, null, headers);
const referencedVolumeId = displaySet.getReferencedVolumeId();
const cachedReferencedVolume = cache.getVolume(referencedVolumeId);
// Parametric map can be loaded only if its referenced volume exists otherwise it will fail
if (!cachedReferencedVolume) {
throw new Error(
'Referenced Volume is missing for the PMAP, and stack viewport PMAP is not supported yet'
);
}
const { imageIds } = cachedReferencedVolume;
const results = await adaptersPMAP.Cornerstone3D.ParametricMap.generateToolState(
imageIds,
arrayBuffer,
metaData
);
const { pixelData } = results;
const TypedArrayConstructor = pixelData.constructor;
const paramMapId = displaySet.displaySetInstanceUID;
const derivedVolume = await volumeLoader.createAndCacheDerivedVolume(referencedVolumeId, {
volumeId: paramMapId,
targetBuffer: {
type: TypedArrayConstructor.name,
},
});
const newPixelData = new TypedArrayConstructor(pixelData.length);
for (let i = 0; i < pixelData.length; i++) {
newPixelData[i] = pixelData[i] * 100;
}
derivedVolume.voxelManager.setCompleteScalarDataArray(newPixelData);
const range = getRangeFromPixelData(newPixelData);
const windowLevel = csUtils.windowLevel.toWindowLevel(range[0], range[1]);
derivedVolume.metadata.voiLut = [windowLevel];
derivedVolume.loadStatus = { loaded: true };
return derivedVolume;
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return [
{
name: 'dicom-pmap',
sopClassUids,
getDisplaySetsFromSeries,
},
];
}
export default getSopClassHandlerModule;

View File

@@ -0,0 +1,7 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-pmap';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { id, SOPClassHandlerId, SOPClassHandlerName };

View File

@@ -0,0 +1,39 @@
import { id } from './id';
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstonePMAPViewport');
});
const OHIFCornerstonePMAPViewport = 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 = {
id,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstonePMAPViewport = props => {
return (
<OHIFCornerstonePMAPViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
{...props}
/>
);
};
return [{ name: 'dicom-pmap', component: ExtendedOHIFCornerstonePMAPViewport }];
},
getSopClassHandlerModule,
};
export default extension;

View File

@@ -0,0 +1,210 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
function OHIFCornerstonePMAPViewport(props: withAppTypes) {
const {
displaySets,
children,
viewportOptions,
displaySetOptions,
servicesManager,
extensionManager,
} = props;
const viewportId = viewportOptions.viewportId;
const { displaySetService, segmentationService, uiNotificationService, customizationService } =
servicesManager.services;
// PMAP viewport will always have a single display set
if (displaySets.length !== 1) {
throw new Error('PMAP viewport must have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const pmapDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySet = pmapDisplaySet.getReferenceDisplaySet();
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
referencedDisplaySet,
pmapDisplaySet
);
referencedDisplaySetRef.current = {
displaySet: referencedDisplaySet,
metadata: referencedDisplaySetMetadata,
};
const [pmapIsLoading, setPmapIsLoading] = useState(!pmapDisplaySet.isLoaded);
// Add effect to listen for loading complete
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.pmapDisplaySet?.displaySetInstanceUID === pmapDisplaySet.displaySetInstanceUID) {
setPmapIsLoading(false);
}
}
);
return () => {
unsubscribe();
};
}, [pmapDisplaySet]);
const getCornerstoneViewport = useCallback(() => {
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
displaySetOptions.unshift({});
const [pmapDisplaySetOptions] = displaySetOptions;
// Make sure `options` exists
pmapDisplaySetOptions.options = pmapDisplaySetOptions.options ?? {};
Object.assign(pmapDisplaySetOptions.options, {
colormap: {
name: 'rainbow_2',
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.25, opacity: 0.25 },
{ value: 0.5, opacity: 0.5 },
{ value: 0.75, opacity: 0.75 },
{ value: 0.9, opacity: 0.99 },
],
},
voi: {
windowCenter: 50,
windowWidth: 100,
},
});
uiNotificationService.show({
title: 'Parametric Map',
type: 'warning',
message: 'The values are multiplied by 100 in the viewport for better visibility',
});
return (
<Component
{...props}
// Referenced + PMAP displaySets must be passed as parameter in this order
displaySets={[referencedDisplaySet, pmapDisplaySet]}
viewportOptions={{
viewportType: 'volume',
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
displaySetOptions={[{}, pmapDisplaySetOptions]}
></Component>
);
}, [
extensionManager,
displaySetOptions,
props,
pmapDisplaySet,
viewportOptions.orientation,
viewportOptions.viewportId,
]);
// Cleanup the PMAP viewport when the viewport is destroyed
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, [activeViewportId, displaySetService, viewportGridService, viewports]);
let childrenWithProps = null;
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportId,
key: index,
})
);
});
}
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{pmapIsLoading && (
<LoadingIndicatorTotalPercent
className="h-full w-full"
totalNumbers={null}
percentComplete={null}
loadingText="Loading Parametric Map..."
/>
)}
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstonePMAPViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet, pmapDisplaySet) {
const { SharedFunctionalGroupsSequence } = pmapDisplaySet.instance;
const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence)
? SharedFunctionalGroupsSequence[0]
: SharedFunctionalGroupsSequence;
const { PixelMeasuresSequence } = SharedFunctionalGroup;
const PixelMeasures = Array.isArray(PixelMeasuresSequence)
? PixelMeasuresSequence[0]
: PixelMeasuresSequence;
const { SpacingBetweenSlices, SliceThickness } = PixelMeasures;
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness || SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices,
};
return referencedDisplaySetMetadata;
}
export default OHIFCornerstonePMAPViewport;

View File

@@ -0,0 +1,12 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};

View File

@@ -0,0 +1,48 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const pkg = require('./../package.json');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const ROOT_DIR = path.join(__dirname, './..');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, ENTRY, DIST_DIR });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: false,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-rt',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
// new BundleAnalyzerPlugin(),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
MIT License
Copyright (c) 2023 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,13 @@
# dicom-rt
## Description
DICOM RT read workflow. This extension will allow you to load a DICOM RTSS image
and display it in OHIF.
## Author
OHIF
## License
MIT

View File

@@ -0,0 +1,43 @@
module.exports = {
plugins: ['@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',
],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
};

View File

@@ -0,0 +1,51 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.10.0-beta.111",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",
"main": "dist/ohif-extension-cornerstone-dicom-rt.umd.js",
"module": "src/index.tsx",
"files": [
"dist/**",
"public/**",
"README.md"
],
"publishConfig": {
"access": "public"
},
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-seg": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/extension-cornerstone": "3.10.0-beta.111",
"@ohif/extension-default": "3.10.0-beta.111",
"@ohif/i18n": "3.10.0-beta.111",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^10.11.0",
"react-router": "^6.23.1",
"react-router-dom": "^6.23.1"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"react-color": "^2.19.3"
}
}

View File

@@ -0,0 +1,55 @@
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
const services = servicesManager.services;
const { displaySetService, viewportGridService } = services;
const actions = {
hydrateRTSDisplaySet: ({ displaySet, viewportId }) => {
if (displaySet.Modality !== 'RTSTRUCT') {
throw new Error('Display set is not an RTSTRUCT');
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
displaySet.referencedDisplaySetInstanceUID
);
// update the previously stored segmentationPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will have the correct segmentation representation hydrated
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: displaySet,
type: SegmentationRepresentations.Contour,
});
// update the previously stored positionPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId,
displaySetInstanceUID: referencedDisplaySet.displaySetInstanceUID,
});
viewportGridService.setDisplaySetsForViewport({
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
});
},
};
const definitions = {
hydrateRTSDisplaySet: {
commandFn: actions.hydrateRTSDisplaySet,
storeContexts: [],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'cornerstone-dicom-rt',
};
};
export default commandsModule;

View File

@@ -0,0 +1,199 @@
import { utils } from '@ohif/core';
import { SOPClassHandlerId } from './id';
import loadRTStruct from './loadRTStruct';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.481.3'];
const loadPromises = {};
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
const instance = instances[0];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
wadoRoot,
wadoUri,
wadoUriRoot,
} = instance;
const displaySet = {
Modality: 'RTSTRUCT',
loading: false,
isReconstructable: false, // by default for now since it is a volumetric SEG currently
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
SOPClassUID,
referencedImages: null,
referencedSeriesInstanceUID: null,
referencedDisplaySetInstanceUID: null,
isDerivedDisplaySet: true,
isLoaded: false,
isHydrated: false,
structureSet: null,
sopClassUids,
instance,
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
let referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (instance.ReferencedFrameOfReferenceSequence && !instance.ReferencedSeriesSequence) {
instance.ReferencedSeriesSequence = _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
instance.ReferencedFrameOfReferenceSequence
);
referencedSeriesSequence = instance.ReferencedSeriesSequence;
}
if (!referencedSeriesSequence) {
throw new Error('ReferencedSeriesSequence is missing for the RTSTRUCT');
}
const referencedSeries = referencedSeriesSequence[0];
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
const { displaySetService } = servicesManager.services;
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
displaySet.referencedSeriesInstanceUID
);
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
// Instead of throwing error, subscribe to display sets added
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
({ displaySetsAdded }) => {
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
unsubscribe();
}
}
);
} else {
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
}
displaySet.load = ({ headers }) => _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(rtDisplaySet, servicesManager: AppTypes.ServicesManager, extensionManager, headers) {
const { SOPInstanceUID } = rtDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(rtDisplaySet.loading || rtDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExistsInCache(rtDisplaySet, segmentationService)
) {
return loadPromises[SOPInstanceUID];
}
rtDisplaySet.loading = true;
// We don't want to fire multiple loads, so we'll wait for the first to finish
// and also return the same promise to any other callers.
loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => {
if (!rtDisplaySet.structureSet) {
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
rtDisplaySet.structureSet = structureSet;
}
segmentationService
.createSegmentationForRTDisplaySet(rtDisplaySet)
.then(() => {
rtDisplaySet.loading = false;
resolve();
})
.catch(error => {
rtDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
}
function _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
ReferencedFrameOfReferenceSequence
) {
const ReferencedSeriesSequence = [];
ReferencedFrameOfReferenceSequence.forEach(referencedFrameOfReference => {
const { RTReferencedStudySequence } = referencedFrameOfReference;
RTReferencedStudySequence.forEach(rtReferencedStudy => {
const { RTReferencedSeriesSequence } = rtReferencedStudy;
RTReferencedSeriesSequence.forEach(rtReferencedSeries => {
const ReferencedInstanceSequence = [];
const { ContourImageSequence, SeriesInstanceUID } = rtReferencedSeries;
ContourImageSequence.forEach(contourImage => {
ReferencedInstanceSequence.push({
ReferencedSOPInstanceUID: contourImage.ReferencedSOPInstanceUID,
ReferencedSOPClassUID: contourImage.ReferencedSOPClassUID,
});
});
const referencedSeries = {
SeriesInstanceUID,
ReferencedInstanceSequence,
};
ReferencedSeriesSequence.push(referencedSeries);
});
});
});
return ReferencedSeriesSequence;
}
function _segmentationExistsInCache(
rtDisplaySet,
segmentationService: AppTypes.SegmentationService
) {
// Todo: fix this
return false;
// This should be abstracted with the CornerstoneCacheService
const rtContourId = rtDisplaySet.displaySetInstanceUID;
const contour = segmentationService.getContour(rtContourId);
return contour !== undefined;
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
return [
{
name: 'dicom-rt',
sopClassUids,
getDisplaySetsFromSeries: instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
},
},
];
}
export default getSopClassHandlerModule;

View File

@@ -0,0 +1,7 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-rt';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { id, SOPClassHandlerId, SOPClassHandlerName };

View File

@@ -0,0 +1,63 @@
import { id } from './id';
import React from 'react';
import { Types } from '@ohif/core';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getCommandsModule from './getCommandsModule';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneRTViewport');
});
const OHIFCornerstoneRTViewport = 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: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
* You ID can be anything you want, but it should be unique.
*/
id,
getCommandsModule,
/**
* PanelModule should provide a list of panels that will be available in OHIF
* for Modes to consume and render. Each panel is defined by a {name,
* iconName, iconLabel, label, component} object. Example of a panel module
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
*/
getViewportModule({
servicesManager,
extensionManager,
commandsManager,
}: Types.Extensions.ExtensionParams) {
const ExtendedOHIFCornerstoneRTViewport = props => {
return (
<OHIFCornerstoneRTViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
{...props}
/>
);
};
return [{ name: 'dicom-rt', component: ExtendedOHIFCornerstoneRTViewport }];
},
/**
* SopClassHandlerModule should provide a list of sop class handlers that will be
* available in OHIF for Modes to consume and use to create displaySets from Series.
* Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}.
* Examples include the default sop class handler provided by the default extension
*/
getSopClassHandlerModule,
};
export default extension;

View File

@@ -0,0 +1,267 @@
import dcmjs from 'dcmjs';
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB;
async function checkAndLoadContourData(instance, datasource) {
if (!instance || !instance.ROIContourSequence) {
return Promise.reject('Invalid instance object or ROIContourSequence');
}
const promisesMap = new Map();
for (const ROIContour of instance.ROIContourSequence) {
const referencedROINumber = ROIContour.ReferencedROINumber;
if (!ROIContour || !ROIContour.ContourSequence) {
promisesMap.set(referencedROINumber, [Promise.resolve([])]);
continue;
}
for (const Contour of ROIContour.ContourSequence) {
if (!Contour || !Contour.ContourData) {
return Promise.reject('Invalid Contour or ContourData');
}
const contourData = Contour.ContourData;
if (Array.isArray(contourData)) {
promisesMap.has(referencedROINumber)
? promisesMap.get(referencedROINumber).push(Promise.resolve(contourData))
: promisesMap.set(referencedROINumber, [Promise.resolve(contourData)]);
} else if (contourData && contourData.BulkDataURI) {
const bulkDataURI = contourData.BulkDataURI;
if (!datasource || !datasource.retrieve || !datasource.retrieve.bulkDataURI) {
return Promise.reject('Invalid datasource object or retrieve function');
}
const bulkDataPromise = datasource.retrieve.bulkDataURI({
BulkDataURI: bulkDataURI,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
promisesMap.has(referencedROINumber)
? promisesMap.get(referencedROINumber).push(bulkDataPromise)
: promisesMap.set(referencedROINumber, [bulkDataPromise]);
} else {
return Promise.reject(`Invalid ContourData: ${contourData}`);
}
}
}
const resolvedPromisesMap = new Map();
for (const [key, promiseArray] of promisesMap.entries()) {
resolvedPromisesMap.set(key, await Promise.allSettled(promiseArray));
}
instance.ROIContourSequence.forEach(ROIContour => {
try {
const referencedROINumber = ROIContour.ReferencedROINumber;
const resolvedPromises = resolvedPromisesMap.get(referencedROINumber);
if (ROIContour.ContourSequence) {
ROIContour.ContourSequence.forEach((Contour, index) => {
const promise = resolvedPromises[index];
if (promise.status === 'fulfilled') {
if (Array.isArray(promise.value) && promise.value.every(Number.isFinite)) {
// If promise.value is already an array of numbers, use it directly
Contour.ContourData = promise.value;
} else {
// If the resolved promise value is a byte array (Blob), it needs to be decoded
const uint8Array = new Uint8Array(promise.value);
const textDecoder = new TextDecoder();
const dataUint8Array = textDecoder.decode(uint8Array);
if (typeof dataUint8Array === 'string' && dataUint8Array.includes('\\')) {
Contour.ContourData = dataUint8Array.split('\\').map(parseFloat);
} else {
Contour.ContourData = [];
}
}
} else {
console.error(promise.reason);
}
});
}
} catch (error) {
console.error(error);
}
});
}
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const dataSource = extensionManager.getActiveDataSource()[0];
const { bulkDataURI } = dataSource.getConfig?.() || {};
const { dicomLoaderService } = utilityModule.exports;
// Set here is loading is asynchronous.
// If this function throws its set back to false.
rtStructDisplaySet.isLoaded = true;
let instance = rtStructDisplaySet.instance;
if (!bulkDataURI || !bulkDataURI.enabled) {
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
rtStructDisplaySet,
null,
headers
);
const dicomData = DicomMessage.readFile(segArrayBuffer);
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
instance = rtStructDataset;
} else {
await checkAndLoadContourData(instance, dataSource);
}
const { StructureSetROISequence, ROIContourSequence, RTROIObservationsSequence } = instance;
// Define our structure set entry and add it to the rtstruct module state.
const structureSet = {
StructureSetLabel: instance.StructureSetLabel,
SeriesInstanceUID: instance.SeriesInstanceUID,
ROIContours: [],
visible: true,
ReferencedSOPInstanceUIDsSet: new Set(),
};
for (let i = 0; i < ROIContourSequence.length; i++) {
const ROIContour = ROIContourSequence[i];
const { ContourSequence } = ROIContour;
if (!ContourSequence) {
continue;
}
const isSupported = false;
const ContourSequenceArray = _toArray(ContourSequence);
const contourPoints = [];
for (let c = 0; c < ContourSequenceArray.length; c++) {
const { ContourData, NumberOfContourPoints, ContourGeometricType, ContourImageSequence } =
ContourSequenceArray[c];
let isSupported = false;
const points = [];
for (let p = 0; p < NumberOfContourPoints * 3; p += 3) {
points.push({
x: ContourData[p],
y: ContourData[p + 1],
z: ContourData[p + 2],
});
}
switch (ContourGeometricType) {
case 'CLOSED_PLANAR':
case 'OPEN_PLANAR':
case 'POINT':
isSupported = true;
break;
default:
continue;
}
contourPoints.push({
numberOfPoints: NumberOfContourPoints,
points,
type: ContourGeometricType,
isSupported,
});
if (ContourImageSequence?.ReferencedSOPInstanceUID) {
structureSet.ReferencedSOPInstanceUIDsSet.add(
ContourImageSequence?.ReferencedSOPInstanceUID
);
}
}
_setROIContourMetadata(
structureSet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints,
isSupported
);
}
return structureSet;
}
function _setROIContourMetadata(
structureSet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints,
isSupported
) {
const StructureSetROI = StructureSetROISequence.find(
structureSetROI => structureSetROI.ROINumber === ROIContour.ReferencedROINumber
);
const ROIContourData = {
ROINumber: StructureSetROI.ROINumber,
ROIName: StructureSetROI.ROIName,
ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm,
ROIDescription: StructureSetROI.ROIDescription,
isSupported,
contourPoints,
visible: true,
};
_setROIContourDataColor(ROIContour, ROIContourData);
if (RTROIObservationsSequence) {
// If present, add additional RTROIObservations metadata.
_setROIContourRTROIObservations(
ROIContourData,
RTROIObservationsSequence,
ROIContour.ReferencedROINumber
);
}
structureSet.ROIContours.push(ROIContourData);
}
function _setROIContourDataColor(ROIContour, ROIContourData) {
let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour;
if (!ROIDisplayColor && RecommendedDisplayCIELabValue) {
// If ROIDisplayColor is absent, try using the RecommendedDisplayCIELabValue color.
ROIDisplayColor = dicomlab2RGB(RecommendedDisplayCIELabValue);
}
if (ROIDisplayColor) {
ROIContourData.colorArray = [...ROIDisplayColor];
}
}
function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequence, ROINumber) {
const RTROIObservations = RTROIObservationsSequence.find(
RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber
);
if (RTROIObservations) {
// Deep copy so we don't keep the reference to the dcmjs dataset entry.
const { ObservationNumber, ROIObservationDescription, RTROIInterpretedType, ROIInterpreter } =
RTROIObservations;
ROIContourData.RTROIObservations = {
ObservationNumber,
ROIObservationDescription,
RTROIInterpretedType,
ROIInterpreter,
};
}
}
function _toArray(objOrArray) {
return Array.isArray(objOrArray) ? objOrArray : [objOrArray];
}

View File

@@ -0,0 +1,7 @@
function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
export default createRTToolGroupAndAddTools;

View File

@@ -0,0 +1,82 @@
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateRT({
servicesManager,
rtDisplaySet,
viewportId,
preHydrateCallbacks,
hydrateRTDisplaySet,
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
servicesManager,
});
resolve(isHydrated);
}
});
}
function _askHydrate(uiViewportDialogService: AppTypes.UIViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
id: 'no-hydrate',
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
id: 'yes-hydrate',
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
id: 'promptHydrateRT',
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
export default promptHydrateRT;

View File

@@ -0,0 +1,397 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { ViewportActionArrows } from '@ohif/ui';
import { useViewportGrid } from '@ohif/ui-next';
import { utils } from '@ohif/extension-cornerstone';
import promptHydrateRT from '../utils/promptHydrateRT';
import _getStatusComponent from './_getStatusComponent';
import createRTToolGroupAndAddTools from '../utils/initRTToolGroup';
import { usePositionPresentationStore } from '@ohif/extension-cornerstone';
const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup';
function OHIFCornerstoneRTViewport(props: withAppTypes) {
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const {
displaySetService,
toolGroupService,
segmentationService,
uiNotificationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const viewportId = viewportOptions.viewportId;
const toolGroupId = `${RT_TOOLGROUP_BASE_NAME}-${viewportId}`;
// RT viewport will always have a single display set
if (displaySets.length > 1) {
throw new Error('RT viewport should only have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const rtDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const selectedSegmentObjectIndex: number = 0;
const { setPositionPresentation } = usePositionPresentationStore();
// Hydration means that the RT is opened and segments are loaded into the
// segmentation panel, and RT is also rendered on any viewport that is in the
// same frameOfReferenceUID as the referencedSeriesUID of the RT. However,
// loading basically means RT loading over network and bit unpacking of the
// RT data.
const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated);
const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
percentComplete: null,
totalSegments: null,
});
// refs
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
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 storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
const hydrateRTDisplaySet = useCallback(
({ rtDisplaySet, viewportId }) => {
commandsManager.runCommand('hydrateRTSDisplaySet', {
displaySet: rtDisplaySet,
viewportId,
});
},
[commandsManager]
);
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, rtDisplaySet]}
viewportOptions={{
viewportType: 'stack',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, rtDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
utils.handleSegmentChange({
direction,
segDisplaySet: rtDisplaySet,
viewportId,
selectedSegmentObjectIndex,
segmentationService,
});
},
[selectedSegmentObjectIndex]
);
useEffect(() => {
if (rtIsLoading) {
return;
}
promptHydrateRT({
servicesManager,
viewportId,
rtDisplaySet,
preHydrateCallbacks: [storePresentationState],
hydrateRTDisplaySet,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]);
useEffect(() => {
// I'm not sure what is this, since in RT we support Overlapping segments
// via contours
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.rtDisplaySet.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
setRtIsLoading(false);
}
if (rtDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = rtDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewportType: 'stack',
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
viewPresentation: {},
});
}
if (evt.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message: 'Overlapping segments detected which is not currently supported',
type: 'warning',
});
}
}
);
return () => {
unsubscribe();
};
}, [rtDisplaySet]);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
percentComplete,
totalSegments: numSegments,
});
}
);
return () => {
unsubscribe();
};
}, [rtDisplaySet]);
/**
Cleanup the SEG viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
useEffect(() => {
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (toolGroup) {
return;
}
toolGroup = createRTToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentations(viewportId);
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
useEffect(() => {
setIsHydrated(rtDisplaySet.isHydrated);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentations(viewportId);
referencedDisplaySetRef.current = null;
};
}, [rtDisplaySet]);
const onStatusClick = useCallback(async () => {
// Before hydrating a RT and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the RT. This is
// required if the user has changed the viewport (other viewport than RT viewport)
// presentation state (w/l and invert) and then opens the RT. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
});
setIsHydrated(isHydrated);
}, [hydrateRTDisplaySet, rtDisplaySet, storePresentationState, viewportId]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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, {
viewportId,
key: index,
})
);
});
}
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover/pane:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{rtIsLoading && (
<LoadingIndicatorTotalPercent
className="h-full w-full"
totalNumbers={processingProgress.totalSegments}
percentComplete={processingProgress.percentComplete}
loadingText="Loading RTSTRUCT..."
/>
)}
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneRTViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
};
function _getReferencedDisplaySetMetadata(referencedDisplaySet) {
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices,
};
return referencedDisplaySetMetadata;
}
export default OHIFCornerstoneRTViewport;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ViewportActionButton } from '@ohif/ui';
import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next';
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
let ToolTipMessage = null;
let StatusIcon = null;
switch (isHydrated) {
case true:
StatusIcon = () => <Icons.ByName name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icons.ByName
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">RTSTRUCT</span>
</div>
{!isHydrated && (
<ViewportActionButton onInteraction={onStatusClick}>{loadStr}</ViewportActionButton>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<StatusArea />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<ToolTipMessage />
</TooltipContent>
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}

View File

@@ -0,0 +1,12 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};

View File

@@ -0,0 +1,54 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-seg',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
MIT License
Copyright (c) 2023 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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,43 @@
module.exports = {
plugins: ['@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',
],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
};

View File

@@ -0,0 +1,54 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.10.0-beta.111",
"description": "DICOM SEG read workflow",
"author": "OHIF",
"license": "MIT",
"main": "dist/ohif-extension-cornerstone-dicom-seg.umd.js",
"module": "src/index.tsx",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-seg": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/extension-cornerstone": "3.10.0-beta.111",
"@ohif/extension-default": "3.10.0-beta.111",
"@ohif/i18n": "3.10.0-beta.111",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^12.2.2",
"react-router": "^6.23.1",
"react-router-dom": "^6.23.1"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^2.19.14",
"@cornerstonejs/core": "^2.19.14",
"@kitware/vtk.js": "32.1.1",
"react-color": "^2.19.3"
}
}

View File

@@ -0,0 +1,379 @@
import dcmjs from 'dcmjs';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import {
segmentation as cornerstoneToolsSegmentation,
Enums as cornerstoneToolsEnums,
utilities,
} from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { classes, DicomMetadataStore } from '@ohif/core';
import vtkImageMarchingSquares from '@kitware/vtk.js/Filters/General/ImageMarchingSquares';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
const { segmentation: segmentationUtils } = utilities;
const { datasetToBlob } = dcmjs.data;
const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
const targetViewportId = viewportId || activeViewportId;
const viewport = viewports.get(targetViewportId);
return viewport;
};
const {
Cornerstone3D: {
Segmentation: { generateSegmentation },
},
} = adaptersSEG;
const {
Cornerstone3D: {
RTSS: { generateRTSSFromSegmentations },
},
} = adaptersRT;
const { downloadDICOMData } = helpers;
const commandsModule = ({
servicesManager,
extensionManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const {
segmentationService,
uiDialogService,
displaySetService,
viewportGridService,
toolGroupService,
} = servicesManager.services as AppTypes.Services;
const actions = {
/**
* Loads segmentations for a specified viewport.
* The function prepares the viewport for rendering, then loads the segmentation details.
* Additionally, if the segmentation has scalar data, it is set for the corresponding label map volume.
*
* @param {Object} params - Parameters for the function.
* @param params.segmentations - Array of segmentations to be loaded.
* @param params.viewportId - the target viewport ID.
*
*/
loadSegmentationsForViewport: async ({ segmentations, viewportId }) => {
// Todo: handle adding more than one segmentation
const viewport = getTargetViewport({ viewportId, viewportGridService });
const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0];
const segmentation = segmentations[0];
const segmentationId = segmentation.segmentationId;
const label = segmentation.config.label;
const segments = segmentation.config.segments;
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
await segmentationService.createLabelmapForDisplaySet(displaySet, {
segmentationId,
segments,
label,
});
segmentationService.addOrUpdateSegmentation(segmentation);
await segmentationService.addSegmentationRepresentation(viewport.viewportId, {
segmentationId,
});
return segmentationId;
},
/**
* Generates a segmentation from a given segmentation ID.
* This function retrieves the associated segmentation and
* its referenced volume, extracts label maps from the
* segmentation volume, and produces segmentation data
* alongside associated metadata.
*
* @param {Object} params - Parameters for the function.
* @param params.segmentationId - ID of the segmentation to be generated.
* @param params.options - Optional configuration for the generation process.
*
* @returns Returns the generated segmentation data.
*/
generateSegmentation: ({ segmentationId, options = {} }) => {
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
const { imageIds } = segmentation.representationData.Labelmap;
const segImages = imageIds.map(imageId => cache.getImage(imageId));
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
const labelmaps2D = [];
let z = 0;
for (const segImage of segImages) {
const segmentsOnLabelmap = new Set();
const pixelData = segImage.getPixelData();
const { rows, columns } = segImage;
// Use a single pass through the pixel data
for (let i = 0; i < pixelData.length; i++) {
const segment = pixelData[i];
if (segment !== 0) {
segmentsOnLabelmap.add(segment);
}
}
labelmaps2D[z++] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
pixelData,
rows,
columns,
};
}
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
const labelmap3D = {
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
metadata: [],
labelmaps2D,
};
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
// segmentation service already has a color for each segment
if (!segment) {
return;
}
const { label } = segment;
const firstRepresentation = representations[0];
const color = segmentationService.getSegmentColor(
firstRepresentation.viewportId,
segmentationId,
segment.segmentIndex
);
const RecommendedDisplayCIELabValue = dcmjs.data.Colors.rgb2DICOMLAB(
color.slice(0, 3).map(value => value / 255)
).map(value => Math.round(value));
const segmentMetadata = {
SegmentNumber: segmentIndex.toString(),
SegmentLabel: label,
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
SegmentAlgorithmName: segment?.algorithmName || 'OHIF Brush',
RecommendedDisplayCIELabValue,
SegmentedPropertyCategoryCodeSequence: {
CodeValue: 'T-D0050',
CodingSchemeDesignator: 'SRT',
CodeMeaning: 'Tissue',
},
SegmentedPropertyTypeCodeSequence: {
CodeValue: 'T-D0050',
CodingSchemeDesignator: 'SRT',
CodeMeaning: 'Tissue',
},
};
labelmap3D.metadata[segmentIndex] = segmentMetadata;
});
const generatedSegmentation = generateSegmentation(
referencedImages,
labelmap3D,
metaData,
options
);
return generatedSegmentation;
},
/**
* Downloads a segmentation based on the provided segmentation ID.
* This function retrieves the associated segmentation and
* uses it to generate the corresponding DICOM dataset, which
* is then downloaded with an appropriate filename.
*
* @param {Object} params - Parameters for the function.
* @param params.segmentationId - ID of the segmentation to be downloaded.
*
*/
downloadSegmentation: ({ segmentationId }) => {
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
const generatedSegmentation = actions.generateSegmentation({
segmentationId,
});
downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`);
},
/**
* Stores a segmentation based on the provided segmentationId into a specified data source.
* The SeriesDescription is derived from user input or defaults to the segmentation label,
* and in its absence, defaults to 'Research Derived Series'.
*
* @param {Object} params - Parameters for the function.
* @param params.segmentationId - ID of the segmentation to be stored.
* @param params.dataSource - Data source where the generated segmentation will be stored.
*
* @returns {Object|void} Returns the naturalized report if successfully stored,
* otherwise throws an error.
*/
storeSegmentation: async ({ segmentationId, dataSource }) => {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action !== 1 && !promptResult.value) {
return;
}
const segmentation = segmentationService.getSegmentation(segmentationId);
if (!segmentation) {
throw new Error('No segmentation found');
}
const { label } = segmentation;
const SeriesDescription = promptResult.value || label || 'Research Derived Series';
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription,
},
});
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
await dataSource.store.dicom(naturalizedReport);
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
},
/**
* Converts segmentations into RTSS for download.
* This sample function retrieves all segentations and passes to
* cornerstone tool adapter to convert to DICOM RTSS format. It then
* converts dataset to downloadable blob.
*
*/
downloadRTSS: ({ segmentationId }) => {
const segmentations = segmentationService.getSegmentation(segmentationId);
const vtkUtils = {
vtkImageMarchingSquares,
vtkDataArray,
vtkImageData,
};
const RTSS = generateRTSSFromSegmentations(
segmentations,
classes.MetadataProvider,
DicomMetadataStore,
cache,
cornerstoneToolsEnums,
vtkUtils
);
try {
const reportBlob = datasetToBlob(RTSS);
//Create a URL for the binary.
const objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
} catch (e) {
console.warn(e);
}
},
setBrushSize: ({ value, toolNames }) => {
const brushSize = Number(value);
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
if (toolNames?.length === 0) {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize);
} else {
toolNames?.forEach(toolName => {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName);
});
}
});
},
setThresholdRange: ({
value,
toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
}) => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
toolNames?.forEach(toolName => {
toolGroup.setToolConfiguration(toolName, {
strategySpecificConfiguration: {
THRESHOLD: {
threshold: value,
},
},
});
});
});
},
};
const definitions = {
/**
* Obsolete?
*/
loadSegmentationDisplaySetsForViewport: {
commandFn: actions.loadSegmentationDisplaySetsForViewport,
},
/**
* Obsolete?
*/
loadSegmentationsForViewport: {
commandFn: actions.loadSegmentationsForViewport,
},
generateSegmentation: {
commandFn: actions.generateSegmentation,
},
downloadSegmentation: {
commandFn: actions.downloadSegmentation,
},
storeSegmentation: {
commandFn: actions.storeSegmentation,
},
downloadRTSS: {
commandFn: actions.downloadRTSS,
},
setBrushSize: {
commandFn: actions.setBrushSize,
},
setThresholdRange: {
commandFn: actions.setThresholdRange,
},
};
return {
actions,
definitions,
defaultContext: 'SEGMENTATION',
};
};
export default commandsModule;

View File

@@ -0,0 +1,101 @@
import { Types } from '@ohif/core';
const segProtocol: Types.HangingProtocol.Protocol = {
id: '@ohif/seg',
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
name: 'Segmentations',
// Just apply this one when specifically listed
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
id: 'segDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
segDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'SEG',
},
},
],
},
},
stages: [
{
name: 'Segmentations',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
id: 'segDisplaySetId',
},
],
},
],
},
],
};
function getHangingProtocolModule() {
return [
{
name: segProtocol.id,
protocol: segProtocol,
},
];
}
export default getHangingProtocolModule;
export { segProtocol };

View File

@@ -0,0 +1,255 @@
import { utils } from '@ohif/core';
import { metaData, triggerEvent, eventTarget } from '@cornerstonejs/core';
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomlabToRGB } from './utils/dicomlabToRGB';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4'];
const loadPromises = {};
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.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,
instances: [instance],
wadoRoot,
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
if (!referencedSeriesSequence) {
console.error('ReferencedSeriesSequence is missing for the SEG');
return;
}
const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence;
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
const { displaySetService } = servicesManager.services;
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
displaySet.referencedSeriesInstanceUID
);
const referencedDisplaySet = referencedDisplaySets[0];
if (!referencedDisplaySet) {
// subscribe to display sets added which means at some point it will be available
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
({ displaySetsAdded }) => {
// here we can also do a little bit of search, since sometimes DICOM SEG
// does not contain the referenced display set uid , and we can just
// see which of the display sets added is more similar and assign it
// to the referencedDisplaySet
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
unsubscribe();
}
}
);
} else {
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
}
displaySet.load = async ({ headers }) =>
await _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(
segDisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers
) {
const { SOPInstanceUID } = segDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(segDisplaySet.loading || segDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
_segmentationExists(segDisplaySet)
) {
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) => {
if (!segDisplaySet.segments || Object.keys(segDisplaySet.segments).length === 0) {
try {
await _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
});
} catch (e) {
segDisplaySet.loading = false;
return reject(e);
}
}
segmentationService
.createSegmentationForSEGDisplaySet(segDisplaySet)
.then(() => {
segDisplaySet.loading = false;
resolve();
})
.catch(error => {
segDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
}
async function _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
}: withAppTypes) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { segmentationService, uiNotificationService } = servicesManager.services;
const { dicomLoaderService } = utilityModule.exports;
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
segDisplaySet.referencedDisplaySetInstanceUID
);
if (!referencedDisplaySet) {
throw new Error('referencedDisplaySet is missing for SEG');
}
const { instances: images } = referencedDisplaySet;
const imageIds = images.map(({ imageId }) => imageId);
// Todo: what should be defaults here
const tolerance = 0.001;
const skipOverlapping = true;
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
const { percentComplete } = evt.detail;
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
percentComplete,
});
});
const results = await adaptersSEG.Cornerstone3D.Segmentation.generateToolState(
imageIds,
arrayBuffer,
metaData,
{ skipOverlapping, tolerance, eventTarget, triggerEvent }
);
let usedRecommendedDisplayCIELabValue = true;
results.segMetadata.data.forEach((data, i) => {
if (i > 0) {
data.rgba = data.RecommendedDisplayCIELabValue;
if (data.rgba) {
data.rgba = dicomlabToRGB(data.rgba);
} else {
usedRecommendedDisplayCIELabValue = false;
data.rgba = CONSTANTS.COLOR_LUT[i % CONSTANTS.COLOR_LUT.length];
}
}
});
if (results.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message:
'Unsupported overlapping segments detected, segmentation rendering results may be incorrect.',
type: 'warning',
});
}
if (!usedRecommendedDisplayCIELabValue) {
// Display a notification about the non-utilization of RecommendedDisplayCIELabValue
uiNotificationService.show({
title: 'DICOM SEG import',
message:
'RecommendedDisplayCIELabValue not found for one or more segments. The default color was used instead.',
type: 'warning',
duration: 5000,
});
}
Object.assign(segDisplaySet, results);
}
function _segmentationExists(segDisplaySet) {
return cstSegmentation.state.getSegmentation(segDisplaySet.displaySetInstanceUID);
}
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,65 @@
export function getToolbarModule({ servicesManager }: withAppTypes) {
const { segmentationService, toolbarService, toolGroupService } = servicesManager.services;
return [
{
name: 'evaluate.cornerstone.hasSegmentation',
evaluate: ({ viewportId }) => {
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
return {
disabled: !segmentations?.length,
};
},
},
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
// Todo: we need to pass in the button section Id since we are kind of
// forcing the button to have black background since initially
// it is designed for the toolbox not the toolbar on top
// we should then branch the buttonSectionId to have different styles
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
if (!segmentations?.length) {
return {
disabled: true,
disabledText: disabledText ?? 'No segmentations available',
};
}
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
if (!Object.keys(activeSegmentation.segments).length) {
return {
disabled: true,
disabledText: 'Add segment to enable this tool',
};
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return {
disabled: true,
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup.hasTool(toolName) && !toolNames) {
return {
disabled: true,
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
const isPrimaryActive = toolNames
? toolNames.includes(toolGroup.getActivePrimaryMouseButtonTool())
: toolGroup.getActivePrimaryMouseButtonTool() === toolName;
return {
disabled: false,
isActive: isPrimaryActive,
};
},
},
];
}

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,56 @@
import { id } from './id';
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getCommandsModule from './commandsModule';
import { getToolbarModule } from './getToolbarModule';
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,
getCommandsModule,
getToolbarModule,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
return (
<OHIFCornerstoneSEGViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
{...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,
getHangingProtocolModule,
};
export default extension;

View File

@@ -0,0 +1,4 @@
export enum SegmentationPanelMode {
Expanded = 'expanded',
Dropdown = 'dropdown',
}

View File

@@ -0,0 +1,14 @@
import dcmjs from 'dcmjs';
/**
* Converts a CIELAB color to an RGB color using the dcmjs library.
* @param cielab - The CIELAB color to convert.
* @returns The RGB color as an array of three integers between 0 and 255.
*/
function dicomlabToRGB(cielab: number[]): number[] {
const rgb = dcmjs.data.Colors.dicomlab2RGB(cielab).map(x => Math.round(x * 255));
return rgb;
}
export { dicomlabToRGB };

View File

@@ -0,0 +1,7 @@
function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
export default createSEGToolGroupAndAddTools;

View File

@@ -0,0 +1,83 @@
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateSEG({
servicesManager,
segDisplaySet,
viewportId,
preHydrateCallbacks,
hydrateCallback,
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
window.setTimeout(async () => {
const isHydrated = await hydrateCallback({
segDisplaySet,
viewportId,
});
resolve(isHydrated);
}, 0);
}
});
}
function _askHydrate(uiViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
id: 'no-hydrate',
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
id: 'yes-hydrate',
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
export default promptHydrateSEG;

View File

@@ -0,0 +1,408 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ViewportActionArrows } from '@ohif/ui';
import { useViewportGrid } from '@ohif/ui-next';
import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup';
import promptHydrateSEG from '../utils/promptHydrateSEG';
import _getStatusComponent from './_getStatusComponent';
import { usePositionPresentationStore } from '@ohif/extension-cornerstone';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { utils } from '@ohif/extension-cornerstone';
const SEG_TOOLGROUP_BASE_NAME = 'SEGToolGroup';
function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const { t } = useTranslation('SEGViewport');
const viewportId = viewportOptions.viewportId;
const {
displaySetService,
toolGroupService,
segmentationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportId}`;
// 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();
// States
let selectedSegmentObjectIndex: number = 0;
const { setPositionPresentation } = usePositionPresentationStore();
// 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({
percentComplete: null,
totalSegments: null,
});
// refs
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(
referencedDisplaySet,
segDisplaySet
);
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 storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
const getCornerstoneViewport = useCallback(() => {
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
// Todo: jump to the center of the first segment
return (
<Component
{...props}
displaySets={[segDisplaySet]}
viewportOptions={{
viewportType: viewportOptions.viewportType,
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, segDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
utils.handleSegmentChange({
direction,
segDisplaySet: segDisplaySet,
viewportId,
selectedSegmentObjectIndex,
segmentationService,
});
},
[selectedSegmentObjectIndex]
);
const hydrateSEG = useCallback(() => {
// update the previously stored segmentationPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will have the correct segmentation representation hydrated
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: segDisplaySet,
type: SegmentationRepresentations.Labelmap,
});
// update the previously stored positionPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId,
displaySetInstanceUID: referencedDisplaySet.displaySetInstanceUID,
});
viewportGridService.setDisplaySetsForViewport({
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
});
}, [commandsManager, viewportId, referencedDisplaySet, segDisplaySet]);
useEffect(() => {
if (segIsLoading) {
return;
}
promptHydrateSEG({
servicesManager,
viewportId,
segDisplaySet,
preHydrateCallbacks: [storePresentationState],
hydrateCallback: hydrateSEG,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, segDisplaySet, segIsLoading, hydrateSEG]);
useEffect(() => {
// on new seg display set, remove all segmentations from all viewports
segmentationService.clearSegmentationRepresentations(viewportId);
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
if (evt.segDisplaySet.displaySetInstanceUID === segDisplaySet.displaySetInstanceUID) {
setSegIsLoading(false);
}
if (segDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = segDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
});
}
}
);
return () => {
unsubscribe();
};
}, [segDisplaySet]);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
percentComplete,
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.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
useEffect(() => {
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (toolGroup) {
return;
}
// keep the already stored segmentationPresentation for this viewport in memory
// so that we can restore it after hydrating the SEG
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: segDisplaySet,
type: SegmentationRepresentations.Labelmap,
});
// always start fresh for this viewport since it is special type of viewport
// that should only show one segmentation at a time.
segmentationService.clearSegmentationRepresentations(viewportId);
// This creates a custom tool group which has the lifetime of this view
// only, and does NOT interfere with currently displayed segmentations.
toolGroup = createSEGToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
return () => {
// remove the segmentation representations if seg displayset changed
// e.g., another seg displayset is dragged into the viewport
segmentationService.clearSegmentationRepresentations(viewportId);
// Only destroy the viewport specific implementation
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
const onStatusClick = useCallback(async () => {
// Before hydrating a SEG and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the SEG. This is
// required if the user has changed the viewport (other viewport than SEG viewport)
// presentation state (w/l and invert) and then opens the SEG. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
hydrateSEG();
}, [storePresentationState, hydrateSEG]);
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover/pane:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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, {
viewportId,
key: index,
})
);
});
}
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{segIsLoading && (
<LoadingIndicatorTotalPercent
className="h-full w-full"
totalNumbers={processingProgress.totalSegments}
percentComplete={processingProgress.percentComplete}
loadingText="Loading SEG..."
/>
)}
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
function _getReferencedDisplaySetMetadata(referencedDisplaySet, segDisplaySet) {
const { SharedFunctionalGroupsSequence } = segDisplaySet.instance;
const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence)
? SharedFunctionalGroupsSequence[0]
: SharedFunctionalGroupsSequence;
const { PixelMeasuresSequence } = SharedFunctionalGroup;
const PixelMeasures = Array.isArray(PixelMeasuresSequence)
? PixelMeasuresSequence[0]
: PixelMeasuresSequence;
const { SpacingBetweenSlices, SliceThickness } = PixelMeasures;
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
PatientID: image0.PatientID,
PatientName: image0.PatientName,
PatientSex: image0.PatientSex,
PatientAge: image0.PatientAge,
SliceThickness: image0.SliceThickness || SliceThickness,
StudyDate: image0.StudyDate,
SeriesDescription: image0.SeriesDescription,
SeriesInstanceUID: image0.SeriesInstanceUID,
SeriesNumber: image0.SeriesNumber,
ManufacturerModelName: image0.ManufacturerModelName,
SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices,
};
return referencedDisplaySetMetadata;
}
export default OHIFCornerstoneSEGViewport;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ViewportActionButton } from '@ohif/ui';
import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next';
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
let ToolTipMessage = null;
let StatusIcon = null;
switch (isHydrated) {
case true:
StatusIcon = () => <Icons.ByName name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icons.ByName
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load segmentation.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">SEG</span>
</div>
{!isHydrated && (
<ViewportActionButton onInteraction={onStatusClick}>{loadStr}</ViewportActionButton>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<StatusArea />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<ToolTipMessage />
</TooltipContent>
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}

View File

@@ -0,0 +1,12 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};

View File

@@ -0,0 +1,55 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-sr',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
// new MiniCssExtractPlugin({
// filename: `./dist/${outputName}.css`,
// chunkFilename: `./dist/${outputName}.css`,
// }),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 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 @@
module.exports = require('../../babel.config.js');

View File

@@ -0,0 +1,54 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.10.0-beta.111",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dicom-sr.umd.js",
"module": "src/index.tsx",
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"keywords": [
"ohif-extension"
],
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/extension-cornerstone": "3.10.0-beta.111",
"@ohif/extension-measurement-tracking": "3.10.0-beta.111",
"@ohif/ui": "3.10.0-beta.111",
"dcmjs": "*",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^18.3.1"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^2.19.14",
"@cornerstonejs/core": "^2.19.14",
"@cornerstonejs/tools": "^2.19.14",
"classnames": "^2.3.2"
}
}

View File

@@ -0,0 +1,188 @@
import { metaData, utilities } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF;
/**
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const _generateReport = (measurementData, additionalFindingTypes, options = {}) => {
const filteredToolState = getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(
filteredToolState,
metaData,
utilities.worldToImageCoords,
options
);
const { dataset } = report;
// Set the default character set as UTF-8
// https://dicom.innolitics.com/ciods/nm-image/sop-common/00080005
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
return dataset;
};
const commandsModule = (props: withAppTypes) => {
const { servicesManager, extensionManager } = props;
const { customizationService, viewportGridService, displaySetService } = servicesManager.services;
const actions = {
changeColorMeasurement: ({ uid }) => {
// When this gets supported, it probably belongs in cornerstone, not sr
throw new Error('Unsupported operation: changeColorMeasurement');
// const { color } = measurementService.getMeasurement(uid);
// const rgbaColor = {
// r: color[0],
// g: color[1],
// b: color[2],
// a: color[3] / 255.0,
// };
// colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
// if (actionId === 'cancel') {
// return;
// }
// const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
// });
},
/**
*
* @param measurementData An array of measurements from the measurements service
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* as opposed to Finding Sites.
* that you wish to serialize.
*/
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
const srDataset = _generateReport(measurementData, additionalFindingTypes, options);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
const objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
},
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param dataSource The dataSource that you wish to use to persist the data.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* @return The naturalized report
*/
storeMeasurements: async ({
measurementData,
dataSource,
additionalFindingTypes,
options = {},
}) => {
// Use the @cornerstonejs adapter for converting to/from DICOM
// But it is good enough for now whilst we only have cornerstone as a datasource.
log.info('[DICOMSR] storeMeasurements');
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
return Promise.reject({});
}
try {
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
// The content sequence has 5 or more elements, of which
// the `[4]` element contains the annotation data, so this is
// checking that there is some annotation data present.
if (!ContentSequence?.[4].ContentSequence?.length) {
console.log('naturalizedReport missing imaging content', naturalizedReport);
throw new Error('Invalid report, no content');
}
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
let dicomDict;
if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
}
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.warn(error);
log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`);
throw new Error(error.message || 'Error while saving the measurements.');
}
},
/**
* Loads measurements by hydrating and loading the SR for the given display set instance UID
* and displays it in the active viewport.
*/
loadSRMeasurements: ({ displaySetInstanceUID }) => {
const { SeriesInstanceUIDs } = hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager },
displaySetInstanceUID
);
const displaySets = displaySetService.getDisplaySetsForSeries(SeriesInstanceUIDs[0]);
if (displaySets.length) {
viewportGridService.setDisplaySetsForViewports([
{
viewportId: viewportGridService.getActiveViewportId(),
displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID],
},
]);
}
},
};
const definitions = {
downloadReport: {
commandFn: actions.downloadReport,
},
storeMeasurements: {
commandFn: actions.storeMeasurements,
},
loadSRMeasurements: {
commandFn: actions.loadSRMeasurements,
},
};
return {
actions,
definitions,
defaultContext: 'CORNERSTONE_STRUCTURED_REPORT',
};
};
export default commandsModule;

View File

@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React from 'react';
import { OHIFCornerstoneSRContentItem } from './OHIFCornerstoneSRContentItem';
export function OHIFCornerstoneSRContainer(props) {
const { container, nodeIndexesTree = [0], containerNumberedTree = [1] } = props;
const { ContinuityOfContent, ConceptNameCodeSequence } = container;
const { CodeMeaning } = ConceptNameCodeSequence ?? {};
let childContainerIndex = 1;
const contentItems = container.ContentSequence?.map((contentItem, i) => {
const { ValueType } = contentItem;
const childNodeLevel = [...nodeIndexesTree, i];
const key = childNodeLevel.join('.');
let Component;
let componentProps;
if (ValueType === 'CONTAINER') {
const childContainerNumberedTree = [...containerNumberedTree, childContainerIndex++];
Component = OHIFCornerstoneSRContainer;
componentProps = {
container: contentItem,
nodeIndexesTree: childNodeLevel,
containerNumberedTree: childContainerNumberedTree,
};
} else {
Component = OHIFCornerstoneSRContentItem;
componentProps = {
contentItem,
nodeIndexesTree: childNodeLevel,
continuityOfContent: ContinuityOfContent,
};
}
return (
<Component
key={key}
{...componentProps}
/>
);
});
return (
<div>
<div className="font-bold">
{containerNumberedTree.join('.')}.&nbsp;
{CodeMeaning}
</div>
<div className="ml-4 mb-2">{contentItems}</div>
</div>
);
}
OHIFCornerstoneSRContainer.propTypes = {
/**
* A tree node that may contain another container or one or more content items
* (text, code, uidref, pname, etc.)
*/
container: PropTypes.object,
/**
* A 0-based index list
*/
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
/**
* A 1-based index list that represents a container in a multi-level numbered
* list (tree).
*
* Example:
* 1. History
* 1.1. Chief Complaint
* 1.2. Present Illness
* 1.3. Past History
* 1.4. Family History
* 2. Findings
* */
containerNumberedTree: PropTypes.arrayOf(PropTypes.number),
};

View File

@@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React from 'react';
import { CodeNameCodeSequenceValues } from '../enums';
import formatContentItemValue from '../utils/formatContentItem';
const EMPTY_TAG_VALUE = '[empty]';
function OHIFCornerstoneSRContentItem(props) {
const { contentItem, nodeIndexesTree, continuityOfContent } = props;
const { ConceptNameCodeSequence } = contentItem;
const { CodeValue, CodeMeaning } = ConceptNameCodeSequence;
const isChildFirstNode = nodeIndexesTree[nodeIndexesTree.length - 1] === 0;
const formattedValue = formatContentItemValue(contentItem) ?? EMPTY_TAG_VALUE;
const startWithAlphaNumCharRegEx = /^[a-zA-Z0-9]/;
const isContinuous = continuityOfContent === 'CONTINUOUS';
const isFinding = CodeValue === CodeNameCodeSequenceValues.Finding;
const addExtraSpace =
isContinuous && !isChildFirstNode && startWithAlphaNumCharRegEx.test(formattedValue?.[0]);
// Collapse sequences of white space preserving newline characters
let className = 'whitespace-pre-line';
if (CodeValue === CodeNameCodeSequenceValues.Finding) {
// Preserve spaces because it is common to see tabular text in a
// "Findings" ConceptNameCodeSequence
className = 'whitespace-pre-wrap';
}
if (isContinuous) {
return (
<>
<span
className={className}
title={CodeMeaning}
>
{addExtraSpace ? ' ' : ''}
{formattedValue}
</span>
</>
);
}
return (
<>
<div className="mb-2">
<span className="font-bold">{CodeMeaning}: </span>
{isFinding ? (
<pre>{formattedValue}</pre>
) : (
<span className={className}>{formattedValue}</span>
)}
</div>
</>
);
}
OHIFCornerstoneSRContentItem.propTypes = {
contentItem: PropTypes.object,
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
continuityOfContent: PropTypes.string,
};
export { OHIFCornerstoneSRContentItem };

View File

@@ -0,0 +1,491 @@
import PropTypes from 'prop-types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExtensionManager, useToolbar } from '@ohif/core';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import { ViewportActionArrows } from '@ohif/ui';
import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet';
import { usePositionPresentationStore } from '@ohif/extension-cornerstone';
import { useViewportGrid } from '@ohif/ui-next';
import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next';
const MEASUREMENT_TRACKING_EXTENSION_ID = '@ohif/extension-measurement-tracking';
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) {
const { children, dataSource, displaySets, viewportOptions, servicesManager, extensionManager } =
props;
const { displaySetService, viewportActionCornersService } = servicesManager.services;
const viewportId = viewportOptions.viewportId;
// SR viewport will always have a single display set
if (displaySets.length > 1) {
throw new Error('SR viewport should only have a single display set');
}
const srDisplaySet = displaySets[0];
const { setPositionPresentation } = usePositionPresentationStore();
const [viewportGrid, viewportGridService] = useViewportGrid();
const [measurementSelected, setMeasurementSelected] = useState(0);
const [measurementCount, setMeasurementCount] = useState(1);
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(null);
const [referencedDisplaySetMetadata, setReferencedDisplaySetMetadata] = useState(null);
const [element, setElement] = useState(null);
const { viewports, activeViewportId } = viewportGrid;
const { t } = useTranslation('Common');
// Optional hook into tracking extension, if present.
let trackedMeasurements;
const hasMeasurementTrackingExtension = extensionManager.registeredExtensionIds.includes(
MEASUREMENT_TRACKING_EXTENSION_ID
);
if (hasMeasurementTrackingExtension) {
const contextModule = extensionManager.getModuleEntry(
'@ohif/extension-measurement-tracking.contextModule.TrackedMeasurementsContext'
);
const tracked = useContext(contextModule.context);
trackedMeasurements = tracked?.[0];
}
/**
* Todo: what is this, not sure what it does regarding the react aspect,
* it is updating a local variable? which is not state.
*/
const [isLocked, setIsLocked] = useState(trackedMeasurements?.context?.trackedSeries?.length > 0);
/**
* Store the tracking identifiers per viewport in order to be able to
* show the SR measurements on the referenced image on the correct viewport,
* when multiple viewports are used.
*/
const setTrackingIdentifiers = useCallback(
measurementSelected => {
const { measurements } = srDisplaySet;
setTrackingUniqueIdentifiersForElement(
element,
measurements.map(measurement => measurement.TrackingUniqueIdentifier),
measurementSelected
);
},
[element, measurementSelected, srDisplaySet]
);
/**
* 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 updateViewport = useCallback(
newMeasurementSelected => {
const { StudyInstanceUID, displaySetInstanceUID, sopClassUids } = srDisplaySet;
if (!StudyInstanceUID || !displaySetInstanceUID) {
return;
}
if (sopClassUids && sopClassUids.length > 1) {
// Todo: what happens if there are multiple SOP Classes? Why we are
// not throwing an error?
console.warn('More than one SOPClassUID in the same series is not yet supported.');
}
// if (!srDisplaySet.measurements || !srDisplaySet.measurements.length) {
// return;
// }
_getViewportReferencedDisplaySetData(
srDisplaySet,
newMeasurementSelected,
displaySetService
).then(({ referencedDisplaySet, referencedDisplaySetMetadata }) => {
if (!referencedDisplaySet || !referencedDisplaySetMetadata) {
return;
}
setMeasurementSelected(newMeasurementSelected);
setActiveImageDisplaySetData(referencedDisplaySet);
setReferencedDisplaySetMetadata(referencedDisplaySetMetadata);
const { presentationIds } = viewportOptions;
const measurement = srDisplaySet.measurements[newMeasurementSelected];
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: {
referencedImageId: measurement.imageId,
},
});
});
},
[dataSource, srDisplaySet, activeImageDisplaySetData, viewportId]
);
const getCornerstoneViewport = useCallback(() => {
if (!activeImageDisplaySetData) {
return null;
}
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const { measurements } = srDisplaySet;
const measurement = measurements[measurementSelected];
if (!measurement) {
return null;
}
return (
<Component
{...props}
// should be passed second since we don't want SR displaySet to
// override the activeImageDisplaySetData
displaySets={[activeImageDisplaySetData]}
// It is possible that there is a hanging protocol applying viewportOptions
// for the SR, so inherit the viewport options
// TODO: Ensure the viewport options are set correctly with respect to
// stack etc, in the incoming viewport options.
viewportOptions={{
...viewportOptions,
toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`,
// viewportType should not be required, as the stack type should be
// required already in order to view SR, but sometimes segmentation
// views set the viewport type without fixing the allowed display
viewportType: 'stack',
// The positionIds for the viewport aren't meaningful for the child display sets
positionIds: null,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
isJumpToMeasurementDisabled={true}
></Component>
);
}, [activeImageDisplaySetData, viewportId, measurementSelected]);
const onMeasurementChange = useCallback(
direction => {
let newMeasurementSelected = measurementSelected;
newMeasurementSelected += direction;
if (newMeasurementSelected >= measurementCount) {
newMeasurementSelected = 0;
} else if (newMeasurementSelected < 0) {
newMeasurementSelected = measurementCount - 1;
}
setTrackingIdentifiers(newMeasurementSelected);
updateViewport(newMeasurementSelected);
},
[measurementSelected, measurementCount, updateViewport, setTrackingIdentifiers]
);
/**
Cleanup the SR viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [],
});
}
}
);
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
/**
* Loading the measurements from the SR viewport, which goes through the
* isHydratable check, the outcome for the isHydrated state here is always FALSE
* since we don't do the hydration here. Todo: can't we just set it as false? why
* we are changing the state here? isHydrated is always false at this stage, and
* if it is hydrated we don't even use the SR viewport.
*/
useEffect(() => {
const loadSR = async () => {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
const numMeasurements = srDisplaySet.measurements.length;
setMeasurementCount(numMeasurements);
updateViewport(measurementSelected);
};
loadSR();
}, [srDisplaySet]);
/**
* Hook to update the tracking identifiers when the selected measurement changes or
* the element changes
*/
useEffect(() => {
const updateSR = async () => {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
if (!element || !srDisplaySet.isLoaded) {
return;
}
setTrackingIdentifiers(measurementSelected);
};
updateSR();
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
useEffect(() => {
setIsLocked(trackedMeasurements?.context?.trackedSeries?.length > 0);
}, [trackedMeasurements]);
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
srDisplaySet,
viewportId,
isRehydratable: srDisplaySet.isRehydratable,
isLocked,
t,
servicesManager,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
index: 0,
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onMeasurementChange}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [isLocked, onMeasurementChange, srDisplaySet, t, viewportActionCornersService, viewportId]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (!activeImageDisplaySetData || !referencedDisplaySetMetadata) {
return null;
}
if (children && children.length) {
childrenWithProps = children.map((child, index) => {
return (
child &&
React.cloneElement(child, {
viewportId,
key: index,
})
);
});
}
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
{getCornerstoneViewport()}
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneSRMeasurementViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
async function _getViewportReferencedDisplaySetData(
displaySet,
measurementSelected,
displaySetService
) {
const { measurements } = displaySet;
const measurement = measurements[measurementSelected];
const { displaySetInstanceUID } = measurement;
if (!displaySet.keyImageDisplaySet) {
// Create a new display set, and preserve a reference to it here,
// so that it can be re-displayed and shown inside the SR viewport.
// This is only for ease of redisplay - the display set is stored in the
// usual manner in the display set service.
displaySet.keyImageDisplaySet = createReferencedImageDisplaySet(displaySetService, displaySet);
}
if (!displaySetInstanceUID) {
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
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, referencedDisplaySet };
}
function _getStatusComponent({
srDisplaySet,
viewportId,
isRehydratable,
isLocked,
t,
servicesManager,
}) {
const loadStr = t('LOAD');
// 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 = () => <Icons.ByName name="status-alert" />;
ToolTipMessage = () => (
<div>
This structured report is not compatible
<br />
with this application.
</div>
);
break;
case 2:
StatusIcon = () => <Icons.ByName name="status-locked" />;
ToolTipMessage = () => (
<div>
This structured report is currently read-only
<br />
because you are tracking measurements in
<br />
another viewport.
</div>
);
break;
case 3:
StatusIcon = () => (
<Icons.ByName
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>{`Click ${loadStr} to restore measurements.`}</div>;
}
const StatusArea = () => {
const { toolbarButtons: loadSRMeasurementsButtons, onInteraction } = useToolbar({
servicesManager,
buttonSection: 'loadSRMeasurements',
});
const commandOptions = {
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
viewportId,
};
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">SR</span>
</div>
{state === 3 && (
<>
{loadSRMeasurementsButtons.map(toolDef => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
const tool = (
<Component
key={id}
id={id}
onInteraction={args => onInteraction({ ...args, ...commandOptions })}
{...componentProps}
/>
);
return <div key={id}>{tool}</div>;
})}
</>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<StatusArea />
</span>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
>
<ToolTipMessage />
</TooltipContent>
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
export default OHIFCornerstoneSRMeasurementViewport;

View File

@@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ExtensionManager } from '@ohif/core';
import { OHIFCornerstoneSRContainer } from './OHIFCornerstoneSRContainer';
function OHIFCornerstoneSRTextViewport(props: withAppTypes) {
const { displaySets } = props;
const displaySet = displaySets[0];
const instance = displaySet.instances[0];
return (
<div className="relative flex h-full w-full flex-col overflow-auto p-4 text-white">
<div>
{/* The root level is always a container */}
<OHIFCornerstoneSRContainer container={instance} />
</div>
</div>
);
}
OHIFCornerstoneSRTextViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
export default OHIFCornerstoneSRTextViewport;

View File

@@ -0,0 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ExtensionManager } from '@ohif/core';
import OHIFCornerstoneSRMeasurementViewport from './OHIFCornerstoneSRMeasurementViewport';
import OHIFCornerstoneSRTextViewport from './OHIFCornerstoneSRTextViewport';
function OHIFCornerstoneSRViewport(props: withAppTypes) {
const { displaySets } = props;
const { isImagingMeasurementReport } = displaySets[0];
if (isImagingMeasurementReport) {
return <OHIFCornerstoneSRMeasurementViewport {...props}></OHIFCornerstoneSRMeasurementViewport>;
}
return <OHIFCornerstoneSRTextViewport {...props}></OHIFCornerstoneSRTextViewport>;
}
OHIFCornerstoneSRViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object,
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
export default OHIFCornerstoneSRViewport;

View File

@@ -0,0 +1,44 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
export const SCOORDTypes = {
POINT: 'POINT',
MULTIPOINT: 'MULTIPOINT',
POLYLINE: 'POLYLINE',
CIRCLE: 'CIRCLE',
ELLIPSE: 'ELLIPSE',
};
export const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
ImagingMeasurements: '126010',
MeasurementGroup: '125007',
ImageLibraryGroup: '126200',
TrackingUniqueIdentifier: '112040',
TrackingIdentifier: '112039',
Finding: '121071',
FindingSite: 'G-C0E3', // SRT
FindingSiteSCT: '363698007', // SCT
};
export const CodingSchemeDesignators = {
SRT: 'SRT',
SCT: 'SCT',
CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'],
};
export const RelationshipType = {
INFERRED_FROM: 'INFERRED FROM',
CONTAINS: 'CONTAINS',
};
const enums = {
CodeNameCodeSequenceValues,
CodingSchemeDesignators,
RelationshipType,
SCOORDTypes,
};
export default enums;

View File

@@ -0,0 +1,77 @@
import { Types } from '@ohif/core';
const srProtocol: Types.HangingProtocol.Protocol = {
id: '@ohif/sr',
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
name: 'SR Key Images',
// Just apply this one when specifically listed
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'srDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
srDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'SR',
},
},
],
},
},
stages: [
{
name: 'SR Key Images',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: { allowUnmatchedView: true },
displaySets: [
{
id: 'srDisplaySetId',
},
],
},
],
},
],
};
function getHangingProtocolModule() {
return [
{
name: srProtocol.id,
protocol: srProtocol,
},
];
}
export default getHangingProtocolModule;
export { srProtocol };

View File

@@ -0,0 +1,724 @@
import { utils, classes, DisplaySetService, Types } from '@ohif/core';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
import { adaptersSR } from '@cornerstonejs/adapters';
import addSRAnnotation from './utils/addSRAnnotation';
import isRehydratable from './utils/isRehydratable';
import {
SOPClassHandlerName,
SOPClassHandlerId,
SOPClassHandlerId3D,
SOPClassHandlerName3D,
} from './id';
import { CodeNameCodeSequenceValues, CodingSchemeDesignators } from './enums';
const { sopClassDictionary } = utils;
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const { ImageSet, MetadataProvider: metadataProvider } = classes;
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
type InstanceMetadata = Types.InstanceMetadata;
/**
* TODO
* - [ ] Add SR thumbnail
* - [ ] Make viewport
* - [ ] Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport
*/
const sopClassUids = [
sopClassDictionary.BasicTextSR,
sopClassDictionary.EnhancedSR,
sopClassDictionary.ComprehensiveSR,
];
const validateSameStudyUID = (uid: string, instances): void => {
instances.forEach(it => {
if (it.StudyInstanceUID !== uid) {
console.warn('Not all instances have the same UID', uid, it);
throw new Error(`Instances ${it.SOPInstanceUID} does not belong to ${uid}`);
}
});
};
/**
* Adds instances to the DICOM SR series, rather than creating a new
* series, so that as SR's are saved, they append to the series, and the
* key image display set gets updated as well, containing just the new series.
* @param instances is a list of instances from THIS series that are not
* in this DICOM SR Display Set already.
*/
function addInstances(instances: InstanceMetadata[], displaySetService: DisplaySetService) {
this.instances.push(...instances);
utils.sortStudyInstances(this.instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
this.instance = this.instances[this.instances.length - 1];
this.isLoaded = false;
return this;
}
/**
* DICOM SR SOP Class Handler
* For all referenced images in the TID 1500/300 sections, add an image to the
* display.
* @param instances is a set of instances all from the same series
* @param servicesManager is the services that can be used for creating
* @returns The list of display sets created for the given instances object
*/
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
utils.sortStudyInstances(instances);
// The last instance is the newest one, so is the one most interesting.
// Eventually, the SR viewer should have the ability to choose which SR
// gets loaded, and to navigate among them.
const instance = instances[instances.length - 1];
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
ConceptNameCodeSequence,
SOPClassUID,
} = instance;
validateSameStudyUID(instance.StudyInstanceUID, instances);
const is3DSR = SOPClassUID === sopClassDictionary.Comprehensive3DSR;
const isImagingMeasurementReport =
ConceptNameCodeSequence?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurementReport;
const displaySet = {
Modality: 'SR',
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId: is3DSR ? SOPClassHandlerId3D : SOPClassHandlerId,
SOPClassUID,
instances,
referencedImages: null,
measurements: null,
isDerivedDisplaySet: true,
isLoaded: false,
isImagingMeasurementReport,
sopClassUids,
instance,
addInstances,
};
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
return [displaySet];
}
/**
* Loads the display set with the given services and extension manager.
* @param srDisplaySet - The display set to load.
* @param servicesManager - The services manager containing displaySetService and measurementService.
* @param extensionManager - The extension manager containing data sources.
*/
async function _load(
srDisplaySet: Types.DisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager: AppTypes.ExtensionManager
) {
const { displaySetService, measurementService } = servicesManager.services;
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const { ContentSequence } = srDisplaySet.instance;
async function retrieveBulkData(obj, parentObj = null, key = null) {
for (const prop in obj) {
if (typeof obj[prop] === 'object' && obj[prop] !== null) {
await retrieveBulkData(obj[prop], obj, prop);
} else if (Array.isArray(obj[prop])) {
await Promise.all(obj[prop].map(item => retrieveBulkData(item, obj, prop)));
} else if (prop === 'BulkDataURI') {
const value = await dataSource.retrieve.bulkDataURI({
BulkDataURI: obj[prop],
StudyInstanceUID: srDisplaySet.instance.StudyInstanceUID,
SeriesInstanceUID: srDisplaySet.instance.SeriesInstanceUID,
SOPInstanceUID: srDisplaySet.instance.SOPInstanceUID,
});
if (parentObj && key) {
parentObj[key] = new Float32Array(value);
}
}
}
}
if (srDisplaySet.isLoaded !== true) {
await retrieveBulkData(ContentSequence);
}
if (srDisplaySet.isImagingMeasurementReport) {
srDisplaySet.referencedImages = _getReferencedImagesList(ContentSequence);
srDisplaySet.measurements = _getMeasurements(ContentSequence);
} else {
srDisplaySet.referencedImages = [];
srDisplaySet.measurements = [];
}
const mappings = measurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
srDisplaySet.isHydrated = false;
srDisplaySet.isRehydratable = isRehydratable(srDisplaySet, mappings);
srDisplaySet.isLoaded = true;
/** Check currently added displaySets and add measurements if the sources exist */
displaySetService.activeDisplaySets.forEach(activeDisplaySet => {
_checkIfCanAddMeasurementsToDisplaySet(
srDisplaySet,
activeDisplaySet,
dataSource,
servicesManager
);
});
/** Subscribe to new displaySets as the source may come in after */
displaySetService.subscribe(displaySetService.EVENTS.DISPLAY_SETS_ADDED, data => {
const { displaySetsAdded } = data;
/**
* If there are still some measurements that have not yet been loaded into cornerstone,
* See if we can load them onto any of the new displaySets.
*/
displaySetsAdded.forEach(newDisplaySet => {
_checkIfCanAddMeasurementsToDisplaySet(
srDisplaySet,
newDisplaySet,
dataSource,
servicesManager
);
});
});
}
/**
* Checks if measurements can be added to a display set.
*
* @param srDisplaySet - The source display set containing measurements.
* @param newDisplaySet - The new display set to check if measurements can be added.
* @param dataSource - The data source used to retrieve image IDs.
* @param servicesManager - The services manager.
*/
function _checkIfCanAddMeasurementsToDisplaySet(
srDisplaySet,
newDisplaySet,
dataSource,
servicesManager: AppTypes.ServicesManager
) {
const { customizationService } = servicesManager.services;
const unloadedMeasurements = srDisplaySet.measurements.filter(
measurement => measurement.loaded === false
);
if (
unloadedMeasurements.length === 0 ||
!(newDisplaySet instanceof ImageSet) ||
newDisplaySet.unsupported
) {
return;
}
// const { sopClassUids } = newDisplaySet;
// Create a Set for faster lookups
// const sopClassUidSet = new Set(sopClassUids);
// Create a Map to efficiently look up ImageIds by SOPInstanceUID and frame number
const imageIdMap = new Map<string, string>();
const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet);
for (const imageId of imageIds) {
const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(imageId);
const key = `${SOPInstanceUID}:${frameNumber || 1}`;
imageIdMap.set(key, imageId);
}
if (!unloadedMeasurements?.length) {
return;
}
const is3DSR = srDisplaySet.SOPClassUID === sopClassDictionary.Comprehensive3DSR;
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
let measurement = unloadedMeasurements[j];
const onBeforeSRAddMeasurement = customizationService.getCustomization(
'onBeforeSRAddMeasurement'
);
if (typeof onBeforeSRAddMeasurement === 'function') {
measurement = onBeforeSRAddMeasurement({
measurement,
StudyInstanceUID: srDisplaySet.StudyInstanceUID,
SeriesInstanceUID: srDisplaySet.SeriesInstanceUID,
});
}
// if it is 3d SR we can just add the SR annotation
if (is3DSR) {
addSRAnnotation(measurement, null, null);
measurement.loaded = true;
continue;
}
const referencedSOPSequence = measurement.coords[0].ReferencedSOPSequence;
if (!referencedSOPSequence) {
continue;
}
const { ReferencedSOPInstanceUID } = referencedSOPSequence;
const frame = referencedSOPSequence.ReferencedFrameNumber || 1;
const key = `${ReferencedSOPInstanceUID}:${frame}`;
const imageId = imageIdMap.get(key);
if (
imageId &&
_measurementReferencesSOPInstanceUID(measurement, ReferencedSOPInstanceUID, frame)
) {
addSRAnnotation(measurement, imageId, frame);
// Update measurement properties
measurement.loaded = true;
measurement.imageId = imageId;
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
measurement.ReferencedSOPInstanceUID = ReferencedSOPInstanceUID;
measurement.frameNumber = frame;
unloadedMeasurements.splice(j, 1);
}
}
}
/**
* Checks if a measurement references a specific SOP Instance UID.
* @param measurement - The measurement object.
* @param SOPInstanceUID - The SOP Instance UID to check against.
* @param frameNumber - The frame number to check against (optional).
* @returns True if the measurement references the specified SOP Instance UID, false otherwise.
*/
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?.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;
if (ReferencedSOPInstanceUID === SOPInstanceUID) {
return true;
}
}
return false;
}
/**
* Retrieves the SOP class handler module.
*
* @param {Object} options - The options for retrieving the SOP class handler module.
* @param {Object} options.servicesManager - The services manager.
* @param {Object} options.extensionManager - The extension manager.
* @returns {Array} An array containing the SOP class handler module.
*/
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
return [
{
name: SOPClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries,
},
{
name: SOPClassHandlerName3D,
sopClassUids: [sopClassDictionary.Comprehensive3DSR],
getDisplaySetsFromSeries,
},
];
}
/**
* Retrieves the measurements from the ImagingMeasurementReportContentSequence.
*
* @param {Array} ImagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
* @returns {Array} - The array of measurements.
*/
function _getMeasurements(ImagingMeasurementReportContentSequence) {
const ImagingMeasurements = ImagingMeasurementReportContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements
);
if (!ImagingMeasurements) {
return [];
}
const MeasurementGroups = _getSequenceAsArray(ImagingMeasurements.ContentSequence).filter(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup
);
const mergedContentSequencesByTrackingUniqueIdentifiers =
_getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups);
const measurements = [];
Object.keys(mergedContentSequencesByTrackingUniqueIdentifiers).forEach(
trackingUniqueIdentifier => {
const mergedContentSequence =
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier];
const measurement = _processMeasurement(mergedContentSequence);
if (measurement) {
measurements.push(measurement);
}
}
);
return measurements;
}
/**
* Retrieves merged content sequences by tracking unique identifiers.
*
* @param {Array} MeasurementGroups - The measurement groups.
* @returns {Object} - The merged content sequences by tracking unique identifiers.
*/
function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups) {
const mergedContentSequencesByTrackingUniqueIdentifiers = {};
MeasurementGroups.forEach(MeasurementGroup => {
const ContentSequence = _getSequenceAsArray(MeasurementGroup.ContentSequence);
const TrackingUniqueIdentifierItem = ContentSequence.find(
item =>
item.ConceptNameCodeSequence.CodeValue ===
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
);
if (!TrackingUniqueIdentifierItem) {
console.warn('No Tracking Unique Identifier, skipping ambiguous measurement.');
}
const trackingUniqueIdentifier = TrackingUniqueIdentifierItem.UID;
if (mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier] === undefined) {
// Add the full ContentSequence
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier] = [
...ContentSequence,
];
} else {
// Add the ContentSequence minus the tracking identifier, as we have this
// Information in the merged ContentSequence anyway.
ContentSequence.forEach(item => {
if (
item.ConceptNameCodeSequence.CodeValue !==
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
) {
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier].push(item);
}
});
}
});
return mergedContentSequencesByTrackingUniqueIdentifiers;
}
/**
* Processes the measurement based on the merged content sequence.
* If the merged content sequence contains SCOORD or SCOORD3D value types,
* it calls the _processTID1410Measurement function.
* Otherwise, it calls the _processNonGeometricallyDefinedMeasurement function.
*
* @param {Array<Object>} mergedContentSequence - The merged content sequence to process.
* @returns {any} - The processed measurement result.
*/
function _processMeasurement(mergedContentSequence) {
if (
mergedContentSequence.some(
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
)
) {
return _processTID1410Measurement(mergedContentSequence);
}
return _processNonGeometricallyDefinedMeasurement(mergedContentSequence);
}
/**
* Processes TID 1410 style measurements from the mergedContentSequence.
* TID 1410 style measurements have a SCOORD or SCOORD3D at the top level,
* and non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D.
*
* @param mergedContentSequence - The merged content sequence containing the measurements.
* @returns The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
*/
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
const graphicItem = mergedContentSequence.find(
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
);
const UIDREFContentItem = mergedContentSequence.find(group => group.ValueType === 'UIDREF');
const TrackingIdentifierContentItem = mergedContentSequence.find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.TrackingIdentifier
);
if (!graphicItem) {
console.warn(
`graphic ValueType ${graphicItem.ValueType} not currently supported, skipping annotation.`
);
return;
}
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
const measurement = {
loaded: false,
labels: [],
coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)],
TrackingUniqueIdentifier: UIDREFContentItem.UID,
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
};
NUMContentItems.forEach(item => {
const { ConceptNameCodeSequence, MeasuredValueSequence } = item;
if (MeasuredValueSequence) {
measurement.labels.push(
_getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence)
);
}
});
const findingSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator === CodingSchemeDesignators.SCT &&
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSiteSCT
);
if (findingSites.length) {
measurement.labels.push({
label: CodeNameCodeSequenceValues.FindingSiteSCT,
value: findingSites[0].ConceptCodeSequence.CodeMeaning,
});
}
return measurement;
}
/**
* Processes the non-geometrically defined measurement from the merged content sequence.
*
* @param mergedContentSequence The merged content sequence containing the measurement data.
* @returns The processed measurement object.
*/
function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
const UIDREFContentItem = mergedContentSequence.find(group => group.ValueType === 'UIDREF');
const TrackingIdentifierContentItem = mergedContentSequence.find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.TrackingIdentifier
);
const finding = mergedContentSequence.find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.Finding
);
const findingSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator === CodingSchemeDesignators.SRT &&
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSite
);
const measurement = {
loaded: false,
labels: [],
coords: [],
TrackingUniqueIdentifier: UIDREFContentItem.UID,
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
};
if (
finding &&
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
finding.ConceptCodeSequence.CodingSchemeDesignator
) &&
finding.ConceptCodeSequence.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
) {
measurement.labels.push({
label: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
value: finding.ConceptCodeSequence.CodeMeaning,
});
}
// TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now.
if (findingSites.length) {
const cornerstoneFreeTextFindingSite = findingSites.find(
FindingSite =>
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
FindingSite.ConceptCodeSequence.CodingSchemeDesignator
) &&
FindingSite.ConceptCodeSequence.CodeValue ===
Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
);
if (cornerstoneFreeTextFindingSite) {
measurement.labels.push({
label: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
value: cornerstoneFreeTextFindingSite.ConceptCodeSequence.CodeMeaning,
});
}
}
NUMContentItems.forEach(item => {
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
const { ValueType } = ContentSequence;
if (!ValueType === 'SCOORD') {
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
return;
}
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
if (coords) {
measurement.coords.push(coords);
}
if (MeasuredValueSequence) {
measurement.labels.push(
_getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence)
);
}
});
return measurement;
}
/**
* Extracts coordinates from a graphic item of type SCOORD or SCOORD3D.
* @param {object} graphicItem - The graphic item containing the coordinates.
* @returns {object} - The extracted coordinates.
*/
const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
const { ValueType, GraphicType, GraphicData } = graphicItem;
const coords = { ValueType, GraphicType, GraphicData };
coords.ReferencedSOPSequence = graphicItem.ContentSequence?.ReferencedSOPSequence;
coords.ReferencedFrameOfReferenceSequence =
graphicItem.ReferencedFrameOfReferenceUID ||
graphicItem.ContentSequence?.ReferencedFrameOfReferenceSequence;
return coords;
};
/**
* Retrieves the label and value from the provided ConceptNameCodeSequence and MeasuredValueSequence.
* @param {Object} ConceptNameCodeSequence - The ConceptNameCodeSequence object.
* @param {Object} MeasuredValueSequence - The MeasuredValueSequence object.
* @returns {Object} - An object containing the label and value.
* The label represents the CodeMeaning from the ConceptNameCodeSequence.
* The value represents the formatted NumericValue and CodeValue from the MeasuredValueSequence.
* Example: { label: 'Long Axis', value: '31.00 mm' }
*/
function _getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence) {
const { CodeMeaning } = ConceptNameCodeSequence;
const { NumericValue, MeasurementUnitsCodeSequence } = MeasuredValueSequence;
const { CodeValue } = MeasurementUnitsCodeSequence;
const formatedNumericValue = NumericValue ? Number(NumericValue).toFixed(2) : '';
return {
label: CodeMeaning,
value: `${formatedNumericValue} ${CodeValue}`,
}; // E.g. Long Axis: 31.0 mm
}
/**
* Retrieves a list of referenced images from the Imaging Measurement Report Content Sequence.
*
* @param {Array} ImagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
* @returns {Array} - The list of referenced images.
*/
function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
const ImageLibrary = ImagingMeasurementReportContentSequence.find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibrary
);
if (!ImageLibrary) {
return [];
}
const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup
);
if (!ImageLibraryGroup) {
return [];
}
const referencedImages = [];
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
const { ReferencedSOPSequence } = item;
if (!ReferencedSOPSequence) {
return;
}
for (const ref of _getSequenceAsArray(ReferencedSOPSequence)) {
if (ref.ReferencedSOPClassUID) {
const { ReferencedSOPClassUID, ReferencedSOPInstanceUID } = ref;
referencedImages.push({
ReferencedSOPClassUID,
ReferencedSOPInstanceUID,
});
}
}
});
return referencedImages;
}
/**
* Converts a DICOM sequence to an array.
* If the sequence is null or undefined, an empty array is returned.
* If the sequence is already an array, it is returned as is.
* Otherwise, the sequence is wrapped in an array and returned.
*
* @param {any} sequence - The DICOM sequence to convert.
* @returns {any[]} - The converted array.
*/
function _getSequenceAsArray(sequence) {
if (!sequence) {
return [];
}
return Array.isArray(sequence) ? sequence : [sequence];
}
export default getSopClassHandlerModule;

View File

@@ -0,0 +1,11 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dicom-sr';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
const SOPClassHandlerName3D = 'dicom-sr-3d';
const SOPClassHandlerId3D = `${id}.sopClassHandlerModule.${SOPClassHandlerName3D}`;
export { SOPClassHandlerName, SOPClassHandlerId, SOPClassHandlerName3D, SOPClassHandlerId3D, id };

View File

@@ -0,0 +1,75 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import { srProtocol } from './getHangingProtocolModule';
import onModeEnter from './onModeEnter';
import getCommandsModule from './commandsModule';
import preRegistration from './init';
import { id } from './id.js';
import toolNames from './tools/toolNames';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
import Enums from './enums';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './components/OHIFCornerstoneSRViewport');
});
const OHIFCornerstoneSRViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
*
*/
const dicomSRExtension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
onModeEnter,
preRegistration,
/**
*
*
* @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneSRViewport = props => {
return (
<OHIFCornerstoneSRViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
return [{ name: 'dicom-sr', component: ExtendedOHIFCornerstoneSRViewport }];
},
getCommandsModule,
getSopClassHandlerModule,
// Include dynamically computed values such as toolNames not known till instantiation
getUtilityModule({ servicesManager }) {
return [
{
name: 'tools',
exports: {
toolNames,
},
},
];
},
};
export default dicomSRExtension;
// Put static exports here so they can be type checked
export { hydrateStructuredReport, createReferencedImageDisplaySet, srProtocol, Enums, toolNames };

View File

@@ -0,0 +1,104 @@
import {
AngleTool,
annotation,
ArrowAnnotateTool,
BidirectionalTool,
CobbAngleTool,
EllipticalROITool,
CircleROITool,
LengthTool,
PlanarFreehandROITool,
RectangleROITool,
utilities as csToolsUtils,
} from '@cornerstonejs/tools';
import { Types, MeasurementService } from '@ohif/core';
import { StackViewport, utilities as csUtils } from '@cornerstonejs/core';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool';
import SCOORD3DPointTool from './tools/SCOORD3DPointTool';
import SRSCOOR3DProbeMapper from './utils/SRSCOOR3DProbeMapper';
import addToolInstance from './utils/addToolInstance';
import toolNames from './tools/toolNames';
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
/**
* @param {object} configuration
*/
export default function init({
configuration = {},
servicesManager,
}: Types.Extensions.ExtensionParams): void {
const { measurementService, cornerstoneViewportService } = servicesManager.services;
addToolInstance(toolNames.DICOMSRDisplay, DICOMSRDisplayTool);
addToolInstance(toolNames.SRLength, LengthTool);
addToolInstance(toolNames.SRBidirectional, BidirectionalTool);
addToolInstance(toolNames.SREllipticalROI, EllipticalROITool);
addToolInstance(toolNames.SRCircleROI, CircleROITool);
addToolInstance(toolNames.SRArrowAnnotate, ArrowAnnotateTool);
addToolInstance(toolNames.SRAngle, AngleTool);
addToolInstance(toolNames.SRPlanarFreehandROI, PlanarFreehandROITool);
addToolInstance(toolNames.SRRectangleROI, RectangleROITool);
addToolInstance(toolNames.SRSCOORD3DPoint, SCOORD3DPointTool);
// TODO - fix the SR display of Cobb Angle, as it joins the two lines
addToolInstance(toolNames.SRCobbAngle, CobbAngleTool);
const csTools3DVer1MeasurementSource = measurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
const { POINT } = measurementService.VALUE_TYPES;
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'SRSCOORD3DPoint',
POINT,
SRSCOOR3DProbeMapper.toAnnotation,
SRSCOOR3DProbeMapper.toMeasurement
);
// Modify annotation tools to use dashed lines on SR
const dashedLine = {
lineDash: '4,4',
};
annotation.config.style.setToolGroupToolStyles('SRToolGroup', {
[toolNames.DICOMSRDisplay]: dashedLine,
SRLength: dashedLine,
SRBidirectional: dashedLine,
SREllipticalROI: dashedLine,
SRCircleROI: dashedLine,
SRArrowAnnotate: dashedLine,
SRCobbAngle: dashedLine,
SRAngle: dashedLine,
SRPlanarFreehandROI: dashedLine,
SRRectangleROI: dashedLine,
global: {},
});
measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportId, measurement, isConsumed }) => {
if (isConsumed) {
return;
}
try {
const currentViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { viewPlaneNormal } = currentViewport.getCamera();
const referencedImageId = csToolsUtils.getClosestImageIdForStackViewport(
currentViewport as StackViewport,
measurement.points[0],
viewPlaneNormal
);
const imageIndex = (currentViewport as StackViewport)
.getImageIds()
.indexOf(referencedImageId);
csUtils.jumpToSlice(currentViewport.element, { imageIndex });
} catch (error) {
console.warn('Unable to jump to image based on measurement coordinate', error);
}
}
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { SOPClassHandlerId, SOPClassHandlerId3D } from './id';
import { ViewportActionButton } from '@ohif/ui';
import i18n from '@ohif/i18n';
export default function onModeEnter({ servicesManager }) {
const { displaySetService, toolbarService } = servicesManager.services;
const displaySetCache = displaySetService.getDisplaySetCache();
const srDisplaySets = [...displaySetCache.values()].filter(
ds => ds.SOPClassHandlerId === SOPClassHandlerId || ds.SOPClassHandlerId === SOPClassHandlerId3D
);
srDisplaySets.forEach(ds => {
// New mode route, allow SRs to be hydrated again
ds.isHydrated = false;
});
toolbarService.addButtons([
{
// A base/default button for loading measurements. It is added to the toolbar below.
// Customizations to this button can be made in the mode or by another extension.
// For example, the button label can be changed and/or the command to clear
// the measurements can be dropped.
id: 'loadSRMeasurements',
component: props => (
<ViewportActionButton {...props}>{i18n.t('Common:LOAD')}</ViewportActionButton>
),
props: {
commands: ['clearMeasurements', 'loadSRMeasurements'],
},
},
]);
// The toolbar used in the viewport's status bar. Modes and extensions can further customize
// it to optionally add other buttons.
toolbarService.createButtonSection('loadSRMeasurements', ['loadSRMeasurements']);
}

View File

@@ -0,0 +1,407 @@
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
import {
AnnotationTool,
annotation,
drawing,
utilities,
Types as cs3DToolsTypes,
} from '@cornerstonejs/tools';
import { getTrackingUniqueIdentifiersForElement } from './modules/dicomSRModule';
import { SCOORDTypes } from '../enums';
import toolNames from './toolNames';
export default class DICOMSRDisplayTool extends AnnotationTool {
static toolName = toolNames.DICOMSRDisplay;
constructor(
toolProps = {},
defaultToolProps = {
configuration: {},
}
) {
super(toolProps, defaultToolProps);
}
_getTextBoxLinesFromLabels(labels) {
// TODO -> max 5 for now (label + shortAxis + longAxis), need a generic solution for this!
const labelLength = Math.min(labels.length, 5);
const lines = [];
for (let i = 0; i < labelLength; i++) {
const labelEntry = labels[i];
lines.push(`${_labelToShorthand(labelEntry.label)}: ${labelEntry.value}`);
}
return lines;
}
// This tool should not inherit from AnnotationTool and we should not need
// to add the following lines.
isPointNearTool = () => null;
getHandleNearImagePoint = () => null;
renderAnnotation = (enabledElement: Types.IEnabledElement, svgDrawingHelper: any): void => {
const { viewport } = enabledElement;
const { element } = viewport;
let annotations = annotation.state.getAnnotations(this.getToolName(), element);
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
if (!annotations?.length) {
return;
}
annotations = this.filterInteractableAnnotationsForElement(element, annotations);
if (!annotations?.length) {
return;
}
const trackingUniqueIdentifiersForElement = getTrackingUniqueIdentifiersForElement(element);
const { activeIndex, trackingUniqueIdentifiers } = trackingUniqueIdentifiersForElement;
const activeTrackingUniqueIdentifier = trackingUniqueIdentifiers[activeIndex];
// Filter toolData to only render the data for the active SR.
const filteredAnnotations = annotations.filter(annotation =>
trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier)
);
if (!viewport._actors?.size) {
return;
}
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
toolGroupId: this.toolGroupId,
toolName: this.getToolName(),
viewportId: enabledElement.viewport.id,
};
const { style: annotationStyle } = annotation.config;
for (let i = 0; i < filteredAnnotations.length; i++) {
const annotation = filteredAnnotations[i];
const annotationUID = annotation.annotationUID;
const { renderableData, TrackingUniqueIdentifier } = annotation.data;
const { referencedImageId } = annotation.metadata;
styleSpecifier.annotationUID = annotationUID;
const groupStyle = annotationStyle.getToolGroupToolStyles(this.toolGroupId)[
this.getToolName()
];
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
const color =
TrackingUniqueIdentifier === activeTrackingUniqueIdentifier
? 'rgb(0, 255, 0)'
: this.getStyle('color', styleSpecifier, annotation);
const options = {
color,
lineDash,
lineWidth,
...groupStyle,
};
Object.keys(renderableData).forEach(GraphicType => {
const renderableDataForGraphicType = renderableData[GraphicType];
let renderMethod;
let canvasCoordinatesAdapter;
switch (GraphicType) {
case SCOORDTypes.POINT:
renderMethod = this.renderPoint;
break;
case SCOORDTypes.MULTIPOINT:
renderMethod = this.renderMultipoint;
break;
case SCOORDTypes.POLYLINE:
renderMethod = this.renderPolyLine;
break;
case SCOORDTypes.CIRCLE:
renderMethod = this.renderEllipse;
break;
case SCOORDTypes.ELLIPSE:
renderMethod = this.renderEllipse;
canvasCoordinatesAdapter = utilities.math.ellipse.getCanvasEllipseCorners;
break;
default:
throw new Error(`Unsupported GraphicType: ${GraphicType}`);
}
const canvasCoordinates = renderMethod(
svgDrawingHelper,
viewport,
renderableDataForGraphicType,
annotationUID,
referencedImageId,
options
);
this.renderTextBox(
svgDrawingHelper,
viewport,
canvasCoordinates,
canvasCoordinatesAdapter,
annotation,
styleSpecifier,
options
);
});
}
};
renderPolyLine(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
const drawingOptions = {
color: options.color,
width: options.lineWidth,
lineDash: options.lineDash,
};
let allCanvasCoordinates = [];
renderableData.map((data, index) => {
const canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
const lineUID = `${index}`;
if (canvasCoordinates.length === 2) {
drawing.drawLine(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCoordinates[0],
canvasCoordinates[1],
drawingOptions
);
} else {
drawing.drawPolyline(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCoordinates,
drawingOptions
);
}
allCanvasCoordinates = allCanvasCoordinates.concat(canvasCoordinates);
});
return allCanvasCoordinates; // used for drawing textBox
}
renderMultipoint(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
let canvasCoordinates;
renderableData.map((data, index) => {
canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
const handleGroupUID = '0';
drawing.drawHandles(svgDrawingHelper, annotationUID, handleGroupUID, canvasCoordinates, {
color: options.color,
});
});
}
renderPoint(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
const canvasCoordinates = [];
renderableData.map((data, index) => {
const point = data[0];
// This gives us one point for arrow
canvasCoordinates.push(viewport.worldToCanvas(point));
if (data[1] !== undefined) {
canvasCoordinates.push(viewport.worldToCanvas(data[1]));
}
else{
// We get the other point for the arrow by using the image size
const imagePixelModule = metaData.get('imagePixelModule', referencedImageId);
let xOffset = 10;
let yOffset = 10;
if (imagePixelModule) {
const { columns, rows } = imagePixelModule;
xOffset = columns / 10;
yOffset = rows / 10;
}
const imagePoint = csUtils.worldToImageCoords(referencedImageId, point);
const arrowEnd = csUtils.imageToWorldCoords(referencedImageId, [
imagePoint[0] + xOffset,
imagePoint[1] + yOffset,
]);
canvasCoordinates.push(viewport.worldToCanvas(arrowEnd));
}
const arrowUID = `${index}`;
// Todo: handle drawing probe as probe, currently we are drawing it as an arrow
drawing.drawArrow(
svgDrawingHelper,
annotationUID,
arrowUID,
canvasCoordinates[1],
canvasCoordinates[0],
{
color: options.color,
width: options.lineWidth,
}
);
});
return canvasCoordinates; // used for drawing textBox
}
renderEllipse(
svgDrawingHelper,
viewport,
renderableData,
annotationUID,
referencedImageId,
options
) {
let canvasCoordinates;
renderableData.map((data, index) => {
if (data.length === 0) {
// since oblique ellipse is not supported for hydration right now
// we just return
return;
}
const ellipsePointsWorld = data;
const rotation = viewport.getRotation();
canvasCoordinates = ellipsePointsWorld.map(p => viewport.worldToCanvas(p));
let canvasCorners;
if (rotation == 90 || rotation == 270) {
canvasCorners = utilities.math.ellipse.getCanvasEllipseCorners([
canvasCoordinates[2],
canvasCoordinates[3],
canvasCoordinates[0],
canvasCoordinates[1],
]) as Array<Types.Point2>;
} else {
canvasCorners = utilities.math.ellipse.getCanvasEllipseCorners(
canvasCoordinates
) as Array<Types.Point2>;
}
const lineUID = `${index}`;
drawing.drawEllipse(
svgDrawingHelper,
annotationUID,
lineUID,
canvasCorners[0],
canvasCorners[1],
{
color: options.color,
width: options.lineWidth,
lineDash: options.lineDash,
}
);
});
return canvasCoordinates;
}
renderTextBox(
svgDrawingHelper,
viewport,
canvasCoordinates,
canvasCoordinatesAdapter,
annotation,
styleSpecifier,
options = {}
) {
if (!canvasCoordinates || !annotation) {
return;
}
const { annotationUID, data = {} } = annotation;
const { labels } = 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(labels);
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(adaptedCanvasCoordinates);
if (!annotation.data?.handles?.textBox?.worldPosition) {
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 = {
'Short Axis': 'W: ',
'Long Axis': 'L: ',
AREA: 'Area: ',
Length: '',
CORNERSTONEFREETEXT: '',
};
function _labelToShorthand(label) {
const shortHand = SHORT_HAND_MAP[label];
if (shortHand !== undefined) {
return shortHand;
}
return label;
}

View File

@@ -0,0 +1,203 @@
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
import {
annotation,
drawing,
utilities,
Types as cs3DToolsTypes,
AnnotationDisplayTool,
} from '@cornerstonejs/tools';
import toolNames from './toolNames';
import { Annotation } from '@cornerstonejs/tools/dist/types/types';
export default class SCOORD3DPointTool extends AnnotationDisplayTool {
static toolName = toolNames.SRSCOORD3DPoint;
constructor(
toolProps = {},
defaultToolProps = {
configuration: {},
}
) {
super(toolProps, defaultToolProps);
}
_getTextBoxLinesFromLabels(labels) {
// TODO -> max 5 for now (label + shortAxis + longAxis), need a generic solution for this!
const labelLength = Math.min(labels.length, 5);
const lines = [];
return lines;
}
// This tool should not inherit from AnnotationTool and we should not need
// to add the following lines.
isPointNearTool = () => null;
getHandleNearImagePoint = () => null;
renderAnnotation = (enabledElement: Types.IEnabledElement, svgDrawingHelper: any): void => {
const { viewport } = enabledElement;
const { element } = viewport;
const annotations = annotation.state.getAnnotations(this.getToolName(), element);
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
if (!annotations?.length) {
return;
}
// Filter toolData to only render the data for the active SR.
const filteredAnnotations = annotations;
if (!viewport._actors?.size) {
return;
}
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
toolGroupId: this.toolGroupId,
toolName: this.getToolName(),
viewportId: enabledElement.viewport.id,
};
for (let i = 0; i < filteredAnnotations.length; i++) {
const annotation = filteredAnnotations[i];
const annotationUID = annotation.annotationUID;
const { renderableData } = annotation.data;
const { POINT: points } = renderableData;
styleSpecifier.annotationUID = annotationUID;
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
const color = this.getStyle('color', styleSpecifier, annotation);
const options = {
color,
lineDash,
lineWidth,
};
const point = points[0][0];
// check if viewport can render it
const viewable = viewport.isReferenceViewable(
{ FrameOfReferenceUID: annotation.metadata.FrameOfReferenceUID, cameraFocalPoint: point },
{ asNearbyProjection: true }
);
if (!viewable) {
continue;
}
// render the point
const arrowPointCanvas = viewport.worldToCanvas(point);
// Todo: configure this
const arrowEndCanvas = [arrowPointCanvas[0] + 20, arrowPointCanvas[1] + 20];
const canvasCoordinates = [arrowPointCanvas, arrowEndCanvas];
drawing.drawArrow(
svgDrawingHelper,
annotationUID,
'1',
canvasCoordinates[1],
canvasCoordinates[0],
{
color: options.color,
width: options.lineWidth,
}
);
this.renderTextBox(
svgDrawingHelper,
viewport,
canvasCoordinates,
annotation,
styleSpecifier,
options
);
}
};
renderTextBox(
svgDrawingHelper,
viewport,
canvasCoordinates,
annotation,
styleSpecifier,
options = {}
) {
if (!canvasCoordinates || !annotation) {
return;
}
const { annotationUID, data = {} } = annotation;
const { labels } = data;
const textLines = [];
for (const label of labels) {
// make this generic
// fix this
if (label.label === '363698007') {
textLines.push(`Finding Site: ${label.value}`);
}
}
const { color } = options;
const adaptedCanvasCoordinates = canvasCoordinates;
// adapt coordinates if there is an adapter
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(adaptedCanvasCoordinates);
if (!annotation.data?.handles?.textBox?.worldPosition) {
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]),
};
}
public getLinkedTextBoxStyle(
specifications: cs3DToolsTypes.AnnotationStyle.StyleSpecifier,
annotation?: Annotation
): Record<string, unknown> {
// Todo: this function can be used to set different styles for different toolMode
// for the textBox.
return {
visibility: this.getStyle('textBoxVisibility', specifications, annotation),
fontFamily: this.getStyle('textBoxFontFamily', specifications, annotation),
fontSize: this.getStyle('textBoxFontSize', specifications, annotation),
color: this.getStyle('textBoxColor', specifications, annotation),
shadow: this.getStyle('textBoxShadow', specifications, annotation),
background: this.getStyle('textBoxBackground', specifications, annotation),
lineWidth: this.getStyle('textBoxLinkLineWidth', specifications, annotation),
lineDash: this.getStyle('textBoxLinkLineDash', specifications, annotation),
};
}
}

View File

@@ -0,0 +1,60 @@
import { getEnabledElement } from '@cornerstonejs/core';
const state = {
TrackingUniqueIdentifier: null,
trackingIdentifiersByViewportId: {},
};
/**
* This file is being used to store the per-viewport state of the SR tools,
* Since, all the toolStates are added to the cornerstoneTools, when displaying the SRTools,
* if there are two viewports rendering the same imageId, we don't want to show
* the same SR annotation twice on irrelevant viewport, hence, we are storing the state
* of the SR tools in state here, so that we can filter them later.
*/
function setTrackingUniqueIdentifiersForElement(
element,
trackingUniqueIdentifiers,
activeIndex = 0
) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
state.trackingIdentifiersByViewportId[viewport.id] = {
trackingUniqueIdentifiers,
activeIndex,
};
}
function setActiveTrackingUniqueIdentifierForElement(element, TrackingUniqueIdentifier) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
const trackingIdentifiersForElement = state.trackingIdentifiersByViewportId[viewport.id];
if (trackingIdentifiersForElement) {
const activeIndex = trackingIdentifiersForElement.trackingUniqueIdentifiers.findIndex(
tuid => tuid === TrackingUniqueIdentifier
);
trackingIdentifiersForElement.activeIndex = activeIndex;
}
}
function getTrackingUniqueIdentifiersForElement(element) {
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
if (state.trackingIdentifiersByViewportId[viewport.id]) {
return state.trackingIdentifiersByViewportId[viewport.id];
}
return { trackingUniqueIdentifiers: [] };
}
export {
setTrackingUniqueIdentifiersForElement,
setActiveTrackingUniqueIdentifierForElement,
getTrackingUniqueIdentifiersForElement,
};

View File

@@ -0,0 +1,15 @@
const toolNames = {
DICOMSRDisplay: 'DICOMSRDisplay',
SRLength: 'SRLength',
SRBidirectional: 'SRBidirectional',
SREllipticalROI: 'SREllipticalROI',
SRCircleROI: 'SRCircleROI',
SRArrowAnnotate: 'SRArrowAnnotate',
SRAngle: 'SRAngle',
SRCobbAngle: 'SRCobbAngle',
SRRectangleROI: 'SRRectangleROI',
SRPlanarFreehandROI: 'SRPlanarFreehandROI',
SRSCOORD3DPoint: 'SRSCOORD3DPoint',
};
export default toolNames;

View File

@@ -0,0 +1,62 @@
const SRSCOOR3DProbe = {
toAnnotation: measurement => {},
/**
* Maps cornerstone annotation event data to measurement service format.
*
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
toMeasurement: (
csToolsEventDetail,
displaySetService,
CornerstoneViewportService,
getValueTypeFromToolType,
customizationService
) => {
const { annotation } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
if (!metadata || !data) {
console.warn('Probe tool: Missing metadata or data');
return null;
}
const { toolName } = metadata;
const { points } = data.handles;
const displayText = getDisplayText(annotation);
return {
uid: annotationUID,
points,
metadata,
toolName: metadata.toolName,
label: data.label,
displayText: displayText,
data: data.cachedStats,
type: getValueTypeFromToolType?.(toolName) ?? null,
};
},
};
function getDisplayText(annotation) {
const { data } = annotation;
if (!data) {
return [''];
}
const { labels } = data;
const displayText = [];
for (const label of labels) {
// make this generic
if (label.label === '33636980076') {
displayText.push(`Finding Site: ${label.value}`);
}
}
return displayText;
}
export default SRSCOOR3DProbe;

View File

@@ -0,0 +1,67 @@
import { Types, annotation } from '@cornerstonejs/tools';
import { metaData } from '@cornerstonejs/core';
import getRenderableData from './getRenderableData';
import toolNames from '../tools/toolNames';
export default function addSRAnnotation(measurement, imageId, frameNumber) {
let toolName = toolNames.DICOMSRDisplay;
const renderableData = measurement.coords.reduce((acc, coordProps) => {
acc[coordProps.GraphicType] = acc[coordProps.GraphicType] || [];
acc[coordProps.GraphicType].push(getRenderableData({ ...coordProps, imageId }));
return acc;
}, {});
const { TrackingUniqueIdentifier } = measurement;
const { ValueType: valueType, GraphicType: graphicType } = measurement.coords[0];
const graphicTypePoints = renderableData[graphicType];
/** TODO: Read the tool name from the DICOM SR identification type in the future. */
let frameOfReferenceUID = null;
if (imageId) {
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
frameOfReferenceUID = imagePlaneModule?.frameOfReferenceUID;
}
if (valueType === 'SCOORD3D') {
toolName = toolNames.SRSCOORD3DPoint;
// get the ReferencedFrameOfReferenceUID from the measurement
frameOfReferenceUID = measurement.coords[0].ReferencedFrameOfReferenceSequence;
}
const SRAnnotation: Types.Annotation = {
annotationUID: TrackingUniqueIdentifier,
highlighted: false,
isLocked: false,
invalidated: false,
metadata: {
toolName,
valueType,
graphicType,
FrameOfReferenceUID: frameOfReferenceUID,
referencedImageId: imageId,
},
data: {
label: measurement.labels?.[0]?.value || undefined,
displayText: measurement.displayText || undefined,
handles: {
textBox: measurement.textBox ?? {},
points: graphicTypePoints[0],
},
cachedStats: {},
frameNumber,
renderableData,
TrackingUniqueIdentifier,
labels: measurement.labels,
},
};
/**
* const annotationManager = annotation.annotationState.getAnnotationManager();
* was not triggering annotation_added events.
*/
annotation.state.addAnnotation(SRAnnotation);
console.debug('Adding SR annotation:', SRAnnotation);
}

View File

@@ -0,0 +1,14 @@
import { addTool } from '@cornerstonejs/tools';
export default function addToolInstance(name: string, toolClass, configuration = {}): void {
class InstanceClass extends toolClass {
static toolName = name;
constructor(toolProps, defaultToolProps) {
toolProps.configuration = toolProps.configuration
? { ...toolProps.configuration, ...configuration }
: configuration;
super(toolProps, defaultToolProps);
}
}
addTool(InstanceClass);
}

View File

@@ -0,0 +1,95 @@
import { DisplaySetService, classes } from '@ohif/core';
const ImageSet = classes.ImageSet;
const findInstance = (measurement, displaySetService: DisplaySetService) => {
const { displaySetInstanceUID, ReferencedSOPInstanceUID: sopUid } = measurement;
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (!referencedDisplaySet.images) {
return;
}
return referencedDisplaySet.images.find(it => it.SOPInstanceUID === sopUid);
};
/** Finds references to display sets inside the measurements
* contained within the provided display set.
* @return an array of instances referenced.
*/
const findReferencedInstances = (displaySetService: DisplaySetService, displaySet) => {
const instances = [];
const instanceById = {};
for (const measurement of displaySet.measurements) {
const { imageId } = measurement;
if (!imageId) {
continue;
}
if (instanceById[imageId]) {
continue;
}
const instance = findInstance(measurement, displaySetService);
if (!instance) {
console.log('Measurement', measurement, 'had no instances found');
continue;
}
instanceById[imageId] = instance;
instances.push(instance);
}
return instances;
};
/**
* Creates a new display set containing a single image instance for each
* referenced image.
*
* @param displaySetService
* @param displaySet - containing measurements referencing images.
* @returns A new (registered/active) display set containing the referenced images
*/
const createReferencedImageDisplaySet = (displaySetService, displaySet) => {
const instances = findReferencedInstances(displaySetService, displaySet);
// This will be a member function of the created image set
const updateInstances = function () {
this.images.splice(
0,
this.images.length,
...findReferencedInstances(displaySetService, displaySet)
);
this.numImageFrames = this.images.length;
};
const imageSet = new ImageSet(instances);
const instance = instances[0];
if (!instance) {
return;
}
imageSet.setAttributes({
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
SeriesInstanceUID: imageSet.uid,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: `${displaySet.SeriesDescription} KO ${displaySet.instance.SeriesNumber}`,
Modality: 'KO',
isMultiFrame: false,
numImageFrames: instances.length,
SOPClassHandlerId: `@ohif/extension-default.sopClassHandlerModule.stack`,
isReconstructable: false,
// This object is made of multiple instances from other series
isCompositeStack: true,
madeInClient: true,
excludeFromThumbnailBrowser: true,
updateInstances,
});
displaySetService.addDisplaySets(imageSet);
return imageSet;
};
export default createReferencedImageDisplaySet;

View File

@@ -0,0 +1,26 @@
/**
* Should Find the requested instance metadata into the displaySets and return
*
* @param {Array} displaySets - List of displaySets
* @param {string} SOPInstanceUID - sopInstanceUID to look for
* @returns {Object} - instance metadata found
*/
const findInstanceMetadataBySopInstanceUID = (displaySets, SOPInstanceUID) => {
let instanceFound;
displaySets.find(displaySet => {
if (!displaySet.images) {
return false;
}
instanceFound = displaySet.images.find(
instanceMetadata => instanceMetadata.getSOPInstanceUID() === SOPInstanceUID
);
return !!instanceFound;
});
return instanceFound;
};
export default findInstanceMetadataBySopInstanceUID;

View File

@@ -0,0 +1,61 @@
/**
* Should find the most recent Structured Report metadata
*
* @param {Array} studies
* @returns {Object} Series
*/
const findMostRecentStructuredReport = studies => {
let mostRecentStructuredReport;
studies.forEach(study => {
const allSeries = study.getSeries ? study.getSeries() : [];
allSeries.forEach(series => {
// Skip series that may not have instances yet
// This can happen if we have retrieved just the initial
// details about the series via QIDO-RS, but not the full metadata
if (!series.instances.length) {
return;
}
if (isStructuredReportSeries(series)) {
if (!mostRecentStructuredReport || compareSeriesDate(series, mostRecentStructuredReport)) {
mostRecentStructuredReport = series;
}
}
});
});
return mostRecentStructuredReport;
};
/**
* Checks if series sopClassUID matches with the supported Structured Reports sopClassUID
*
* @param {Object} series - Series metadata
* @returns {boolean}
*/
const isStructuredReportSeries = series => {
const supportedSopClassUIDs = ['1.2.840.10008.5.1.4.1.1.88.22', '1.2.840.10008.5.1.4.1.1.11.1'];
const firstInstance = series.getFirstInstance();
const SOPClassUID = firstInstance.getData().metadata.SOPClassUID;
return supportedSopClassUIDs.includes(SOPClassUID);
};
/**
* Checks if series1 is newer than series2
*
* @param {Object} series1 - Series Metadata 1
* @param {Object} series2 - Series Metadata 2
* @returns {boolean} true/false if series1 is newer than series2
*/
const compareSeriesDate = (series1, series2) => {
return (
series1._data.SeriesDate > series2._data.SeriesDate ||
(series1._data.SeriesDate === series2._data.SeriesDate &&
series1._data.SeriesTime > series2._data.SeriesTime)
);
};
export default findMostRecentStructuredReport;

View File

@@ -0,0 +1,67 @@
import { utils } from '@ohif/core';
/**
* Formatters used to format each of the content items (SR "nodes") which can be
* text, code, UID ref, number, person name, date, time and date time. Each
* formatter must be a function with the following signature:
*
* [VALUE_TYPE]: (contentItem) => string
*
*/
const contentItemFormatters = {
TEXT: contentItem => contentItem.TextValue,
CODE: contentItem => contentItem.ConceptCodeSequence?.[0]?.CodeMeaning,
UIDREF: contentItem => contentItem.UID,
NUM: contentItem => {
const measuredValue = contentItem.MeasuredValueSequence?.[0];
if (!measuredValue) {
return;
}
const { NumericValue, MeasurementUnitsCodeSequence } = measuredValue;
const { CodeValue } = MeasurementUnitsCodeSequence;
return `${NumericValue} ${CodeValue}`;
},
PNAME: contentItem => {
const personName = contentItem.PersonName?.[0];
return personName ? utils.formatPN(personName) : undefined;
},
DATE: contentItem => {
const { Date } = contentItem;
return Date ? utils.formatDate(Date) : undefined;
},
TIME: contentItem => {
const { Time } = contentItem;
return Time ? utils.formatTime(Time) : undefined;
},
DATETIME: contentItem => {
const { DateTime } = contentItem;
if (typeof DateTime !== 'string') {
return;
}
// 14 characters because it should be something like 20180614113714
if (DateTime.length < 14) {
return DateTime;
}
const dicomDate = DateTime.substring(0, 8);
const dicomTime = DateTime.substring(8, 14);
const formattedDate = utils.formatDate(dicomDate);
const formattedTime = utils.formatTime(dicomTime);
return `${formattedDate} ${formattedTime}`;
},
};
function formatContentItemValue(contentItem) {
const { ValueType } = contentItem;
const fnFormat = contentItemFormatters[ValueType];
return fnFormat ? fnFormat(contentItem) : `[${ValueType} is not supported]`;
}
export { formatContentItemValue as default, formatContentItemValue };

View File

@@ -0,0 +1,19 @@
/**
* Retrieve a list of all displaySets of all studies
*
* @param {Object} studies - List of studies loaded into the viewer
* @returns {Object} List of DisplaySets
*/
const getAllDisplaySets = studies => {
let allDisplaySets = [];
studies.forEach(study => {
if (study.getDisplaySets) {
allDisplaySets = allDisplaySets.concat(study.getDisplaySets());
}
});
return allDisplaySets;
};
export default getAllDisplaySets;

View File

@@ -0,0 +1,103 @@
import OHIF from '@ohif/core';
import { annotation } from '@cornerstonejs/tools';
const { log } = OHIF;
function getFilteredCornerstoneToolState(measurementData, additionalFindingTypes) {
const filteredToolState = {};
function addToFilteredToolState(annotation, toolType) {
if (!annotation.metadata?.referencedImageId) {
log.warn(`[DICOMSR] No referencedImageId found for ${toolType} ${annotation.id}`);
return;
}
const imageId = annotation.metadata.referencedImageId;
if (!filteredToolState[imageId]) {
filteredToolState[imageId] = {};
}
const imageIdSpecificToolState = filteredToolState[imageId];
if (!imageIdSpecificToolState[toolType]) {
imageIdSpecificToolState[toolType] = {
data: [],
};
}
const measurementDataI = measurementData.find(md => md.uid === annotation.annotationUID);
const toolData = imageIdSpecificToolState[toolType].data;
let { finding } = measurementDataI;
const findingSites = [];
// NOTE -> We use the CORNERSTONEJS coding schemeDesignator which we have
// defined in the @cornerstonejs/adapters
if (measurementDataI.label) {
if (additionalFindingTypes.includes(toolType)) {
finding = {
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CORNERSTONEJS',
CodeMeaning: measurementDataI.label,
};
} else {
findingSites.push({
CodeValue: 'CORNERSTONEFREETEXT',
CodingSchemeDesignator: 'CORNERSTONEJS',
CodeMeaning: measurementDataI.label,
});
}
}
if (measurementDataI.findingSites) {
findingSites.push(...measurementDataI.findingSites);
}
const measurement = Object.assign({}, annotation, {
finding,
findingSites,
});
toolData.push(measurement);
}
const uidFilter = measurementData.map(md => md.uid);
const uids = uidFilter.slice();
const annotationManager = annotation.state.getAnnotationManager();
const framesOfReference = annotationManager.getFramesOfReference();
for (let i = 0; i < framesOfReference.length; i++) {
const frameOfReference = framesOfReference[i];
const frameOfReferenceAnnotations = annotationManager.getAnnotations(frameOfReference);
const toolTypes = Object.keys(frameOfReferenceAnnotations);
for (let j = 0; j < toolTypes.length; j++) {
const toolType = toolTypes[j];
const annotations = frameOfReferenceAnnotations[toolType];
if (annotations) {
for (let k = 0; k < annotations.length; k++) {
const annotation = annotations[k];
const uidIndex = uids.findIndex(uid => uid === annotation.annotationUID);
if (uidIndex !== -1) {
addToFilteredToolState(annotation, toolType);
uids.splice(uidIndex, 1);
if (!uids.length) {
return filteredToolState;
}
}
}
}
}
}
return filteredToolState;
}
export default getFilteredCornerstoneToolState;

View File

@@ -0,0 +1,27 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
/**
* Extracts the label from the toolData imported from dcmjs. We need to do this
* as dcmjs does not depeend on OHIF/the measurementService, it just produces data for cornestoneTools.
* This optional data is available for the consumer to process if they wish to.
* @param {object} toolData The tooldata relating to the
*
* @returns {string} The extracted label.
*/
export default function getLabelFromDCMJSImportedToolData(toolData) {
const { findingSites = [], finding } = toolData;
let freeTextLabel = findingSites.find(
fs => fs.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
);
if (freeTextLabel) {
return freeTextLabel.CodeMeaning;
}
if (finding && finding.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT) {
return finding.CodeMeaning;
}
}

View File

@@ -0,0 +1,142 @@
import { vec3 } from 'gl-matrix';
import { metaData, utilities, Types as csTypes } from '@cornerstonejs/core';
import { SCOORDTypes } from '../enums';
const EPSILON = 1e-4;
const getRenderableCoords = ({ GraphicData, ValueType, imageId }) => {
const renderableData = [];
if (ValueType === 'SCOORD3D') {
for (let i = 0; i < GraphicData.length; i += 3) {
renderableData.push([GraphicData[i], GraphicData[i + 1], GraphicData[i + 2]]);
}
} else {
for (let i = 0; i < GraphicData.length; i += 2) {
const worldPos = utilities.imageToWorldCoords(imageId, [GraphicData[i], GraphicData[i + 1]]);
renderableData.push(worldPos);
}
}
return renderableData;
};
function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
let renderableData = [];
switch (GraphicType) {
case SCOORDTypes.POINT:
case SCOORDTypes.MULTIPOINT:
case SCOORDTypes.POLYLINE: {
renderableData = getRenderableCoords({ GraphicData, ValueType, imageId });
break;
}
case SCOORDTypes.CIRCLE: {
const pointsWorld: csTypes.Point3[] = getRenderableCoords({
GraphicData,
ValueType,
imageId,
});
// We do not have an explicit draw circle svg helper in Cornerstone3D at
// this time, but we can use the ellipse svg helper to draw a circle, so
// here we reshape the data for that purpose.
const center = pointsWorld[0];
const onPerimeter = pointsWorld[1];
const radius = vec3.distance(center, onPerimeter);
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
if (!imagePlaneModule) {
throw new Error('No imagePlaneModule found');
}
const {
columnCosines,
rowCosines,
}: {
columnCosines: csTypes.Point3;
rowCosines: csTypes.Point3;
} = imagePlaneModule;
// we need to get major/minor axis (which are both the same size major = minor)
const firstAxisStart = vec3.create();
vec3.scaleAndAdd(firstAxisStart, center, columnCosines, radius);
const firstAxisEnd = vec3.create();
vec3.scaleAndAdd(firstAxisEnd, center, columnCosines, -radius);
const secondAxisStart = vec3.create();
vec3.scaleAndAdd(secondAxisStart, center, rowCosines, radius);
const secondAxisEnd = vec3.create();
vec3.scaleAndAdd(secondAxisEnd, center, rowCosines, -radius);
renderableData = [
firstAxisStart as csTypes.Point3,
firstAxisEnd as csTypes.Point3,
secondAxisStart as csTypes.Point3,
secondAxisEnd as csTypes.Point3,
];
break;
}
case SCOORDTypes.ELLIPSE: {
// GraphicData is ordered as [majorAxisStartX, majorAxisStartY, majorAxisEndX, majorAxisEndY, minorAxisStartX, minorAxisStartY, minorAxisEndX, minorAxisEndY]
// But Cornerstone3D points are ordered as top, bottom, left, right for the
// ellipse so we need to identify if the majorAxis is horizontal or vertical
// and then choose the correct points to use for the ellipse.
const pointsWorld: csTypes.Point3[] = getRenderableCoords({
GraphicData,
ValueType,
imageId,
});
const majorAxisStart = vec3.fromValues(...pointsWorld[0]);
const majorAxisEnd = vec3.fromValues(...pointsWorld[1]);
const minorAxisStart = vec3.fromValues(...pointsWorld[2]);
const minorAxisEnd = vec3.fromValues(...pointsWorld[3]);
const majorAxisVec = vec3.create();
vec3.sub(majorAxisVec, majorAxisEnd, majorAxisStart);
// normalize majorAxisVec to avoid scaling issues
vec3.normalize(majorAxisVec, majorAxisVec);
const minorAxisVec = vec3.create();
vec3.sub(minorAxisVec, minorAxisEnd, minorAxisStart);
vec3.normalize(minorAxisVec, minorAxisVec);
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
if (!imagePlaneModule) {
throw new Error('imageId does not have imagePlaneModule metadata');
}
const { columnCosines }: { columnCosines: csTypes.Point3 } = imagePlaneModule;
// find which axis is parallel to the columnCosines
const columnCosinesVec = vec3.fromValues(...columnCosines);
const projectedMajorAxisOnColVec = Math.abs(vec3.dot(columnCosinesVec, majorAxisVec));
const projectedMinorAxisOnColVec = Math.abs(vec3.dot(columnCosinesVec, minorAxisVec));
const absoluteOfMajorDotProduct = Math.abs(projectedMajorAxisOnColVec);
const absoluteOfMinorDotProduct = Math.abs(projectedMinorAxisOnColVec);
renderableData = [];
if (Math.abs(absoluteOfMajorDotProduct - 1) < EPSILON) {
renderableData = [pointsWorld[0], pointsWorld[1], pointsWorld[2], pointsWorld[3]];
} else if (Math.abs(absoluteOfMinorDotProduct - 1) < EPSILON) {
renderableData = [pointsWorld[2], pointsWorld[3], pointsWorld[0], pointsWorld[1]];
} else {
console.warn('OBLIQUE ELLIPSE NOT YET SUPPORTED');
}
break;
}
default:
console.warn('Unsupported GraphicType:', GraphicType);
}
return renderableData;
}
export default getRenderableData;

View File

@@ -0,0 +1,298 @@
import { utilities, metaData } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import getLabelFromDCMJSImportedToolData from './getLabelFromDCMJSImportedToolData';
import { adaptersSR } from '@cornerstonejs/adapters';
import { annotation as CsAnnotation } from '@cornerstonejs/tools';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
const { locking } = CsAnnotation;
const { guid } = OHIF.utils;
const { MeasurementReport, CORNERSTONE_3D_TAG } = adaptersSR.Cornerstone3D;
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const convertCode = (codingValues, code) => {
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') {
return;
}
const ref = `${code.CodingSchemeDesignator}:${code.CodeValue}`;
const ret = { ...codingValues[ref], ref, ...code, text: code.CodeMeaning };
return ret;
};
const convertSites = (codingValues, sites) => {
if (!sites || !sites.length) {
return;
}
const ret = [];
// Do as a loop to convert away from Proxy instances
for (let i = 0; i < sites.length; i++) {
// Deal with irregular conversion from dcmjs
const site = convertCode(codingValues, sites[i][0] || sites[i]);
if (site) {
ret.push(site);
}
}
return (ret.length && ret) || undefined;
};
/**
* Hydrates a structured report, for default viewports.
*
*/
export default function hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager }: withAppTypes,
displaySetInstanceUID
) {
const dataSource = extensionManager.getActiveDataSource()[0];
const { measurementService, displaySetService, customizationService } = servicesManager.services;
const codingValues = customizationService.getCustomization('codingValues');
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
// TODO -> We should define a strict versioning somewhere.
const mappings = measurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
if (!mappings || !mappings.length) {
throw new Error(
`Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.`
);
}
const instance = DicomMetadataStore.getInstance(
displaySet.StudyInstanceUID,
displaySet.SeriesInstanceUID,
displaySet.SOPInstanceUID
);
const sopInstanceUIDToImageId = {};
const imageIdsForToolState = {};
displaySet.measurements.forEach(measurement => {
const { ReferencedSOPInstanceUID, imageId, frameNumber } = measurement;
if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) {
sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId;
imageIdsForToolState[ReferencedSOPInstanceUID] = [];
}
if (!imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber]) {
imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber] = imageId;
}
});
const datasetToUse = _mapLegacyDataSet(instance);
// Use dcmjs to generate toolState.
let storedMeasurementByAnnotationType = MeasurementReport.generateToolState(
datasetToUse,
// NOTE: we need to pass in the imageIds to dcmjs since the we use them
// for the imageToWorld transformation. The following assumes that the order
// that measurements were added to the display set are the same order as
// the measurementGroups in the instance.
sopInstanceUIDToImageId,
utilities.imageToWorldCoords,
metaData
);
const onBeforeSRHydration = customizationService.getCustomization('onBeforeSRHydration')?.value;
if (typeof onBeforeSRHydration === 'function') {
storedMeasurementByAnnotationType = onBeforeSRHydration({
storedMeasurementByAnnotationType,
displaySet,
});
}
// Filter what is found by DICOM SR to measurements we support.
const mappingDefinitions = mappings.map(m => m.annotationType);
const hydratableMeasurementsInSR = {};
Object.keys(storedMeasurementByAnnotationType).forEach(key => {
if (mappingDefinitions.includes(key)) {
hydratableMeasurementsInSR[key] = storedMeasurementByAnnotationType[key];
}
});
// Set the series touched as tracked.
const imageIds = [];
// TODO: notification if no hydratable?
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
const imageId =
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
if (!imageIds.includes(imageId)) {
imageIds.push(imageId);
}
});
});
let targetStudyInstanceUID;
const SeriesInstanceUIDs = [];
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) {
SeriesInstanceUIDs.push(SeriesInstanceUID);
}
if (!targetStudyInstanceUID) {
targetStudyInstanceUID = StudyInstanceUID;
} else if (targetStudyInstanceUID !== StudyInstanceUID) {
console.warn('NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.');
}
}
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
const imageId =
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
toolData.uid = guid();
const instance = metaData.get('instance', imageId);
const {
FrameOfReferenceUID,
// SOPInstanceUID,
// SeriesInstanceUID,
// StudyInstanceUID,
} = instance;
const annotation = {
annotationUID: toolData.annotation.annotationUID,
data: toolData.annotation.data,
metadata: {
toolName: annotationType,
referencedImageId: imageId,
FrameOfReferenceUID,
},
};
const source = measurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
annotation.data.label = getLabelFromDCMJSImportedToolData(toolData);
annotation.data.finding = convertCode(codingValues, toolData.finding?.[0]);
annotation.data.findingSites = convertSites(codingValues, toolData.findingSites);
annotation.data.findingSites?.forEach(site => {
if (site.type) {
annotation.data[site.type] = site;
}
});
const matchingMapping = mappings.find(m => m.annotationType === annotationType);
const newAnnotationUID = measurementService.addRawMeasurement(
source,
annotationType,
{ annotation },
matchingMapping.toMeasurementSchema,
dataSource
);
commandsManager.runCommand('updateMeasurement', {
uid: newAnnotationUID,
code: annotation.data.finding,
});
if (disableEditing) {
locking.setAnnotationLocked(newAnnotationUID, true);
}
if (!imageIds.includes(imageId)) {
imageIds.push(imageId);
}
});
});
displaySet.isHydrated = true;
return {
StudyInstanceUID: targetStudyInstanceUID,
SeriesInstanceUIDs,
};
}
function _mapLegacyDataSet(dataset) {
const REPORT = 'Imaging Measurements';
const GROUP = 'Measurement Group';
const TRACKING_IDENTIFIER = 'Tracking Identifier';
// Identify the Imaging Measurements
const imagingMeasurementContent = toArray(dataset.ContentSequence).find(
codeMeaningEquals(REPORT)
);
// Retrieve the Measurements themselves
const measurementGroups = toArray(imagingMeasurementContent.ContentSequence).filter(
codeMeaningEquals(GROUP)
);
// For each of the supported measurement types, compute the measurement data
const measurementData = {};
const cornerstoneToolClasses = MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
const registeredToolClasses = [];
Object.keys(cornerstoneToolClasses).forEach(key => {
registeredToolClasses.push(cornerstoneToolClasses[key]);
measurementData[key] = [];
});
measurementGroups.forEach((measurementGroup, index) => {
const measurementGroupContentSequence = toArray(measurementGroup.ContentSequence);
const TrackingIdentifierGroup = measurementGroupContentSequence.find(
contentItem => contentItem.ConceptNameCodeSequence.CodeMeaning === TRACKING_IDENTIFIER
);
const TrackingIdentifier = TrackingIdentifierGroup.TextValue;
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
cornerstoneTag = CORNERSTONE_3D_TAG;
}
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
TrackingIdentifierGroup.TextValue = mappedTrackingIdentifier;
});
return dataset;
}
const toArray = function (x) {
return Array.isArray(x) ? x : [x];
};
const codeMeaningEquals = codeMeaningName => {
return contentItem => {
return contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName;
};
};

View File

@@ -0,0 +1,60 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const cornerstoneAdapters =
adaptersSR.Cornerstone3D.MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const CORNERSTONE_3D_TAG = adaptersSR.Cornerstone3D.CORNERSTONE_3D_TAG;
/**
* Checks if the given `displaySet`can be rehydrated into the `measurementService`.
*
* @param {object} displaySet The SR `displaySet` to check.
* @param {object[]} mappings The CornerstoneTools 4 mappings to the `measurementService`.
* @returns {boolean} True if the SR can be rehydrated into the `measurementService`.
*/
export default function isRehydratable(displaySet, mappings) {
if (!mappings || !mappings.length) {
return false;
}
const mappingDefinitions = mappings.map(m => m.annotationType);
const { measurements } = displaySet;
const adapterKeys = Object.keys(cornerstoneAdapters).filter(
adapterKey =>
typeof cornerstoneAdapters[adapterKey].isValidCornerstoneTrackingIdentifier === 'function'
);
const adapters = [];
adapterKeys.forEach(key => {
if (mappingDefinitions.includes(key)) {
// Must have both a dcmjs adapter and a measurementService
// Definition in order to be a candidate for import.
adapters.push(cornerstoneAdapters[key]);
}
});
for (let i = 0; i < measurements.length; i++) {
const { TrackingIdentifier } = measurements[i] || {};
const hydratable = adapters.some(adapter => {
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
cornerstoneTag = CORNERSTONE_3D_TAG;
}
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
return adapter.isValidCornerstoneTrackingIdentifier(mappedTrackingIdentifier);
});
if (hydratable) {
return true;
}
console.log('Measurement is not rehydratable', TrackingIdentifier, measurements[i]);
}
console.log('No measurements found which were rehydratable');
return false;
}

View File

@@ -0,0 +1,14 @@
import { adaptersSR } from '@cornerstonejs/adapters';
/**
* Checks if dcmjs has support to determined tool
*
* @param {string} toolName
* @returns {boolean}
*/
const isToolSupported = toolName => {
const adapter = adaptersSR.Cornerstone3D;
return !!adapter[toolName];
};
export default isToolSupported;

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,54 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.ts`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-cornerstone',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
MIT License
Copyright (c) 2023 cornerstone-dynamic-volume ()
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,8 @@
# cornerstone-dynamic-volume
## Description
## Author
OHIF
## License
MIT

View File

@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View File

@@ -0,0 +1,49 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.10.0-beta.111",
"description": "OHIF extension for 4D volumes data",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dynamic-volume.umd.js",
"module": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/extension-cornerstone": "3.10.0-beta.111",
"@ohif/extension-default": "3.10.0-beta.111",
"@ohif/i18n": "3.10.0-beta.111",
"@ohif/ui": "3.10.0-beta.111",
"dcmjs": "*",
"dicom-parser": "^1.8.21",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^18.3.1"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "^2.19.14",
"@cornerstonejs/tools": "^2.19.14",
"classnames": "^2.3.2"
}
}

View File

@@ -0,0 +1,3 @@
import updateSegmentationsChartDisplaySet from './updateSegmentationsChartDisplaySet';
export { updateSegmentationsChartDisplaySet };

View File

@@ -0,0 +1,278 @@
import { DicomMetadataStore, utils } from '@ohif/core';
import * as cs from '@cornerstonejs/core';
import * as csTools from '@cornerstonejs/tools';
const CHART_MODALITY = 'CHT';
const SEG_CHART_INSTANCE_UID = utils.guid();
// Private SOPClassUid for chart data
const ChartDataSOPClassUid = '1.9.451.13215.7.3.2.7.6.1';
const { utilities: csToolsUtils } = csTools;
function _getDateTimeStr() {
const now = new Date();
const date =
now.getFullYear() + ('0' + now.getUTCMonth()).slice(-2) + ('0' + now.getUTCDate()).slice(-2);
const time =
('0' + now.getUTCHours()).slice(-2) +
('0' + now.getUTCMinutes()).slice(-2) +
('0' + now.getUTCSeconds()).slice(-2);
return { date, time };
}
function _getTimePointsDataByTagName(volume, timePointsTag) {
const uniqueTimePoints = volume.imageIds.reduce((timePoints, imageId) => {
const instance = DicomMetadataStore.getInstanceByImageId(imageId);
const timePointValue = instance[timePointsTag];
if (timePointValue !== undefined) {
timePoints.add(timePointValue);
}
return timePoints;
}, new Set());
return Array.from(uniqueTimePoints).sort((a: number, b: number) => a - b);
}
function _convertTimePointsUnit(timePoints, timePointsUnit) {
const validUnits = ['ms', 's', 'm', 'h'];
const divisors = [1000, 60, 60];
const currentUnitIndex = validUnits.indexOf(timePointsUnit);
let divisor = 1;
if (currentUnitIndex !== -1) {
for (let i = currentUnitIndex; i < validUnits.length - 1; i++) {
const newDivisor = divisor * divisors[i];
const greaterThanDivisorCount = timePoints.filter(timePoint => timePoint > newDivisor).length;
// Change the scale only if more than 50% of the time points are
// greater than the new divisor.
if (greaterThanDivisorCount <= timePoints.length / 2) {
break;
}
divisor = newDivisor;
timePointsUnit = validUnits[i + 1];
}
if (divisor > 1) {
timePoints = timePoints.map(timePoint => timePoint / divisor);
}
}
return { timePoints, timePointsUnit };
}
// It currently supports only one tag but a few other will be added soon
// Supported 4D Tags
// (0018,1060) Trigger Time [NOK]
// (0018,0081) Echo Time [NOK]
// (0018,0086) Echo Number [NOK]
// (0020,0100) Temporal Position Identifier [NOK]
// (0054,1300) FrameReferenceTime [OK]
function _getTimePointsData(volume) {
const timePointsTags = {
FrameReferenceTime: {
unit: 'ms',
},
};
const timePointsTagNames = Object.keys(timePointsTags);
let timePoints;
let timePointsUnit;
for (let i = 0; i < timePointsTagNames.length; i++) {
const tagName = timePointsTagNames[i];
const curTimePoints = _getTimePointsDataByTagName(volume, tagName);
if (curTimePoints.length) {
timePoints = curTimePoints;
timePointsUnit = timePointsTags[tagName].unit;
break;
}
}
if (!timePoints.length) {
const concatTagNames = timePointsTagNames.join(', ');
throw new Error(`Could not extract time points data for the following tags: ${concatTagNames}`);
}
const convertedTimePoints = _convertTimePointsUnit(timePoints, timePointsUnit);
timePoints = convertedTimePoints.timePoints;
timePointsUnit = convertedTimePoints.timePointsUnit;
return { timePoints, timePointsUnit };
}
function _getSegmentationData(
segmentation,
volumesTimePointsCache,
{ servicesManager }: { servicesManager: AppTypes.ServicesManager }
) {
const { displaySetService, segmentationService, viewportGridService } = servicesManager.services;
const displaySets = displaySetService.getActiveDisplaySets();
const dynamic4DDisplaySet = displaySets.find(displaySet => {
const anInstance = displaySet.instances?.[0];
if (anInstance) {
return (
anInstance.FrameReferenceTime !== undefined || anInstance.NumberOfTimeSlices !== undefined
);
}
return false;
});
// const referencedDynamicVolume = cs.cache.getVolume(dynamic4DDisplaySet.displaySetInstanceUID);
let volumeCacheKey: string | undefined;
const volumeId = dynamic4DDisplaySet.displaySetInstanceUID;
for (const [key] of cs.cache._volumeCache) {
if (key.includes(volumeId)) {
volumeCacheKey = key;
break;
}
}
let referencedDynamicVolume;
if (volumeCacheKey) {
referencedDynamicVolume = cs.cache.getVolume(volumeCacheKey);
}
const { StudyInstanceUID, StudyDescription } = DicomMetadataStore.getInstanceByImageId(
referencedDynamicVolume.imageIds[0]
);
const segmentationVolume = segmentationService.getLabelmapVolume(segmentation.segmentationId);
const maskVolumeId = segmentationVolume?.volumeId;
const [timeData, _] = csToolsUtils.dynamicVolume.getDataInTime(referencedDynamicVolume, {
maskVolumeId,
}) as number[][];
const pixelCount = timeData.length;
if (pixelCount === 0) {
return [];
}
// Todo: this is useless we should be able to grab color with just segRepUID and segmentIndex
// const color = csTools.segmentation.config.color.getSegmentIndexColor(
// segmentationRepresentationUID,
// 1 // segmentIndex
// );
const viewportId = viewportGridService.getActiveViewportId();
const color = segmentationService.getSegmentColor(viewportId, segmentation.segmentationId, 1);
const hexColor = cs.utilities.color.rgbToHex(color[0], color[1], color[2]);
let timePointsData = volumesTimePointsCache.get(referencedDynamicVolume);
if (!timePointsData) {
timePointsData = _getTimePointsData(referencedDynamicVolume);
volumesTimePointsCache.set(referencedDynamicVolume, timePointsData);
}
const { timePoints, timePointsUnit } = timePointsData;
if (timePoints.length !== timeData[0].length) {
throw new Error('Invalid number of time points returned');
}
const timepointsCount = timePoints.length;
const chartSeriesData = new Array(timepointsCount);
for (let i = 0; i < timepointsCount; i++) {
const average = timeData.reduce((acc, cur) => acc + cur[i] / pixelCount, 0);
chartSeriesData[i] = [timePoints[i], average];
}
return {
StudyInstanceUID,
StudyDescription,
chartData: {
series: {
label: segmentation.label,
points: chartSeriesData,
color: hexColor,
},
axis: {
x: {
label: `Time (${timePointsUnit})`,
},
y: {
label: `Vl (Bq/ml)`,
},
},
},
};
}
function _getInstanceFromSegmentations(segmentations, { servicesManager }) {
if (!segmentations.length) {
return;
}
const volumesTimePointsCache = new WeakMap();
const segmentationsData = segmentations.map(segmentation =>
_getSegmentationData(segmentation, volumesTimePointsCache, { servicesManager })
);
const { date: seriesDate, time: seriesTime } = _getDateTimeStr();
const series = segmentationsData.reduce((allSeries, curSegData) => {
return [...allSeries, curSegData.chartData.series];
}, []);
const instance = {
SOPClassUID: ChartDataSOPClassUid,
Modality: CHART_MODALITY,
SOPInstanceUID: utils.guid(),
SeriesDate: seriesDate,
SeriesTime: seriesTime,
SeriesInstanceUID: SEG_CHART_INSTANCE_UID,
StudyInstanceUID: segmentationsData[0].StudyInstanceUID,
StudyDescription: segmentationsData[0].StudyDescription,
SeriesNumber: 100,
SeriesDescription: 'Segmentation chart series data',
chartData: {
series,
axis: { ...segmentationsData[0].chartData.axis },
},
};
const seriesMetadata = {
StudyInstanceUID: instance.StudyInstanceUID,
StudyDescription: instance.StudyDescription,
SeriesInstanceUID: instance.SeriesInstanceUID,
SeriesDescription: instance.SeriesDescription,
SeriesNumber: instance.SeriesNumber,
SeriesTime: instance.SeriesTime,
SOPClassUID: instance.SOPClassUID,
Modality: instance.Modality,
};
return { seriesMetadata, instance };
}
function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void {
debugger;
const { segmentationService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } =
_getInstanceFromSegmentations(segmentations, { servicesManager }) ?? {};
if (seriesMetadata && instance) {
// An event is triggered after adding the instance and the displaySet is created
DicomMetadataStore.addSeriesMetadata([seriesMetadata], true);
DicomMetadataStore.addInstances([instance], true);
}
}
export { updateSegmentationsChartDisplaySet as default };

View File

@@ -0,0 +1,412 @@
import * as importedActions from './actions';
import { utilities, Enums } from '@cornerstonejs/tools';
import { cache } from '@cornerstonejs/core';
const LABELMAP = Enums.SegmentationRepresentations.Labelmap;
const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
const services = servicesManager.services;
const { displaySetService, viewportGridService, segmentationService } = services;
const actions = {
...importedActions,
getDynamic4DDisplaySet: () => {
const displaySets = displaySetService.getActiveDisplaySets();
const dynamic4DDisplaySet = displaySets.find(displaySet => {
const anInstance = displaySet.instances?.[0];
if (anInstance) {
return (
anInstance.FrameReferenceTime !== undefined ||
anInstance.NumberOfTimeSlices !== undefined ||
anInstance.TemporalPositionIdentifier !== undefined
);
}
return false;
});
return dynamic4DDisplaySet;
},
getComputedDisplaySets: () => {
const displaySetCache = displaySetService.getDisplaySetCache();
const cachedDisplaySets = [...displaySetCache.values()];
const computedDisplaySets = cachedDisplaySets.filter(displaySet => {
return displaySet.isDerived;
});
return computedDisplaySets;
},
exportTimeReportCSV: ({ segmentations, config, options, summaryStats }) => {
const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet();
const volumeId = dynamic4DDisplaySet?.displaySetInstanceUID;
// cache._volumeCache is a map that has a key that includes the volumeId
// it is not exactly the volumeId, but it is the key that includes the volumeId
// so we can't do cache._volumeCache.get(volumeId) we should iterate
// over the keys and find the one that includes the volumeId
let volumeCacheKey: string | undefined;
for (const [key] of cache._volumeCache) {
if (key.includes(volumeId)) {
volumeCacheKey = key;
break;
}
}
let dynamicVolume;
if (volumeCacheKey) {
dynamicVolume = cache.getVolume(volumeCacheKey);
}
const instance = dynamic4DDisplaySet.instances[0];
const csv = [];
// CSV header information with placeholder empty values for the metadata lines
csv.push(`Patient ID,${instance.PatientID},`);
csv.push(`Study Date,${instance.StudyDate},`);
csv.push(`StudyInstanceUID,${instance.StudyInstanceUID},`);
csv.push(`StudyDescription,${instance.StudyDescription},`);
csv.push(`SeriesInstanceUID,${instance.SeriesInstanceUID},`);
// empty line
csv.push('');
csv.push('');
// Helper function to calculate standard deviation
function calculateStandardDeviation(data) {
const n = data.length;
const mean = data.reduce((acc, value) => acc + value, 0) / n;
const squaredDifferences = data.map(value => (value - mean) ** 2);
const variance = squaredDifferences.reduce((acc, value) => acc + value, 0) / n;
const stdDeviation = Math.sqrt(variance);
return stdDeviation;
}
// Iterate through each segmentation to get the timeData and ijkCoords
segmentations.forEach(segmentation => {
const volume = segmentationService.getLabelmapVolume(segmentation.segmentationId);
const [timeData, ijkCoords] = utilities.dynamicVolume.getDataInTime(dynamicVolume, {
maskVolumeId: volume.volumeId,
}) as number[][];
if (summaryStats) {
// Adding column headers for pixel identifier and segmentation label ids
let headers = 'Operation,Segmentation Label ID';
const maxLength = dynamicVolume.numTimePoints;
for (let t = 0; t < maxLength; t++) {
headers += `,Time Point ${t}`;
}
csv.push(headers);
// // perform summary statistics on the timeData including for each time point, mean, median, min, max, and standard deviation for
// // all the voxels in the ROI
const mean = [];
const min = [];
const minIJK = [];
const max = [];
const maxIJK = [];
const std = [];
const numVoxels = timeData.length;
// Helper function to calculate standard deviation
for (let timeIndex = 0; timeIndex < maxLength; timeIndex++) {
// for each voxel in the ROI, get the value at the current time point
const voxelValues = [];
let sum = 0;
let minValue = Infinity;
let maxValue = -Infinity;
let minIndex = 0;
let maxIndex = 0;
// Single pass through the data to collect all needed values
for (let voxelIndex = 0; voxelIndex < numVoxels; voxelIndex++) {
const value = timeData[voxelIndex][timeIndex];
voxelValues.push(value);
sum += value;
if (value < minValue) {
minValue = value;
minIndex = voxelIndex;
}
if (value > maxValue) {
maxValue = value;
maxIndex = voxelIndex;
}
}
mean.push(sum / numVoxels);
min.push(minValue);
minIJK.push(ijkCoords[minIndex]);
max.push(maxValue);
maxIJK.push(ijkCoords[maxIndex]);
std.push(calculateStandardDeviation(voxelValues));
}
let row = `Mean,${segmentation.label}`;
// Generate separate rows for each statistic
for (let t = 0; t < maxLength; t++) {
row += `,${mean[t]}`;
}
csv.push(row);
row = `Standard Deviation,${segmentation.label}`;
for (let t = 0; t < maxLength; t++) {
row += `,${std[t]}`;
}
csv.push(row);
row = `Min,${segmentation.label}`;
for (let t = 0; t < maxLength; t++) {
row += `,${min[t]}`;
}
csv.push(row);
row = `Max,${segmentation.label}`;
for (let t = 0; t < maxLength; t++) {
row += `,${max[t]}`;
}
csv.push(row);
} else {
// Adding column headers for pixel identifier and segmentation label ids
let headers = 'Pixel Identifier (IJK),Segmentation Label ID';
const maxLength = dynamicVolume.numTimePoints;
for (let t = 0; t < maxLength; t++) {
headers += `,Time Point ${t}`;
}
csv.push(headers);
// Assuming timeData and ijkCoords are of the same length
for (let i = 0; i < timeData.length; i++) {
// Generate the pixel identifier
const pixelIdentifier = `${ijkCoords[i][0]}_${ijkCoords[i][1]}_${ijkCoords[i][2]}`;
// Start a new row for the current pixel
let row = `${pixelIdentifier},${segmentation.label}`;
// Add time data points for this pixel
for (let t = 0; t < timeData[i].length; t++) {
row += `,${timeData[i][t]}`;
}
// Append the row to the CSV array
csv.push(row);
}
}
});
// Convert to CSV string
const csvContent = csv.join('\n');
// Generate filename and trigger download
const filename = `${instance.PatientID}.csv`;
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
swapDynamicWithComputedDisplaySet: ({ displaySet }) => {
const computedDisplaySet = displaySet;
const displaySetCache = displaySetService.getDisplaySetCache();
const cachedDisplaySetKeys = [displaySetCache.keys()];
const { displaySetInstanceUID } = computedDisplaySet;
// Check to see if computed display set is already in cache
if (!cachedDisplaySetKeys.includes(displaySetInstanceUID)) {
displaySetCache.set(displaySetInstanceUID, computedDisplaySet);
}
// Get all viewports and their corresponding indices
const { viewports } = viewportGridService.getState();
// get the viewports in the grid
// iterate over them and find the ones that are showing a dynamic
// volume (displaySet), and replace that exact displaySet with the
// computed displaySet
const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet();
const viewportsToUpdate = [];
for (const [key, value] of viewports) {
const viewport = value;
const viewportOptions = viewport.viewportOptions;
const { displaySetInstanceUIDs } = viewport;
const displaySetInstanceUIDIndex = displaySetInstanceUIDs.indexOf(
dynamic4DDisplaySet.displaySetInstanceUID
);
if (displaySetInstanceUIDIndex !== -1) {
const newViewport = {
viewportId: viewport.viewportId,
// merge the other displaySetInstanceUIDs with the new one
displaySetInstanceUIDs: [
...displaySetInstanceUIDs.slice(0, displaySetInstanceUIDIndex),
displaySetInstanceUID,
...displaySetInstanceUIDs.slice(displaySetInstanceUIDIndex + 1),
],
viewportOptions: {
initialImageOptions: viewportOptions.initialImageOptions,
viewportType: 'volume',
orientation: viewportOptions.orientation,
background: viewportOptions.background,
},
};
viewportsToUpdate.push(newViewport);
}
}
viewportGridService.setDisplaySetsForViewports(viewportsToUpdate);
},
swapComputedWithDynamicDisplaySet: () => {
// Todo: this assumes there is only one dynamic display set in the viewer
const dynamicDisplaySet = actions.getDynamic4DDisplaySet();
const displaySetCache = displaySetService.getDisplaySetCache();
const cachedDisplaySetKeys = [...displaySetCache.keys()]; // Fix: Spread to get the array
const { displaySetInstanceUID } = dynamicDisplaySet;
// Check to see if dynamic display set is already in cache
if (!cachedDisplaySetKeys.includes(displaySetInstanceUID)) {
displaySetCache.set(displaySetInstanceUID, dynamicDisplaySet);
}
// Get all viewports and their corresponding indices
const { viewports } = viewportGridService.getState();
// Get the computed 4D display set
const computed4DDisplaySet = actions.getComputedDisplaySets()[0];
const viewportsToUpdate = [];
for (const [key, value] of viewports) {
const viewport = value;
const viewportOptions = viewport.viewportOptions;
const { displaySetInstanceUIDs } = viewport;
const displaySetInstanceUIDIndex = displaySetInstanceUIDs.indexOf(
computed4DDisplaySet.displaySetInstanceUID
);
if (displaySetInstanceUIDIndex !== -1) {
const newViewport = {
viewportId: viewport.viewportId,
// merge the other displaySetInstanceUIDs with the new one
displaySetInstanceUIDs: [
...displaySetInstanceUIDs.slice(0, displaySetInstanceUIDIndex),
displaySetInstanceUID,
...displaySetInstanceUIDs.slice(displaySetInstanceUIDIndex + 1),
],
viewportOptions: {
initialImageOptions: viewportOptions.initialImageOptions,
viewportType: 'volume',
orientation: viewportOptions.orientation,
background: viewportOptions.background,
},
};
viewportsToUpdate.push(newViewport);
}
}
viewportGridService.setDisplaySetsForViewports(viewportsToUpdate);
},
createNewLabelMapForDynamicVolume: async ({ label }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
// get the dynamic 4D display set
const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet();
const dynamic4DDisplaySetInstanceUID = dynamic4DDisplaySet.displaySetInstanceUID;
// check if the dynamic 4D display set is in the display, if not we might have
// the computed volumes and we should choose them for the segmentation
// creation
let referenceDisplaySet;
const activeViewport = viewports.get(activeViewportId);
const activeDisplaySetInstanceUIDs = activeViewport.displaySetInstanceUIDs;
const dynamicIsInActiveViewport = activeDisplaySetInstanceUIDs.includes(
dynamic4DDisplaySetInstanceUID
);
if (dynamicIsInActiveViewport) {
referenceDisplaySet = dynamic4DDisplaySet;
}
if (!referenceDisplaySet) {
// try to see if there is any derived displaySet in the active viewport
// which is referencing the dynamic 4D display set
// Todo: this is wrong but I don't have time to fix it now
const cachedDisplaySets = displaySetService.getDisplaySetCache();
for (const [key, displaySet] of cachedDisplaySets) {
if (displaySet.referenceDisplaySetUID === dynamic4DDisplaySetInstanceUID) {
referenceDisplaySet = displaySet;
break;
}
}
}
if (!referenceDisplaySet) {
throw new Error('No reference display set found based on the dynamic data');
}
const displaySet = displaySetService.getDisplaySetByUID(
referenceDisplaySet.displaySetInstanceUID
);
const segmentationId = await segmentationService.createLabelmapForDisplaySet(displaySet, {
label,
});
const firstViewport = viewports.values().next().value;
await segmentationService.addSegmentationRepresentation(firstViewport.viewportId, {
segmentationId,
});
return segmentationId;
},
};
const definitions = {
updateSegmentationsChartDisplaySet: {
commandFn: actions.updateSegmentationsChartDisplaySet,
storeContexts: [],
options: {},
},
exportTimeReportCSV: {
commandFn: actions.exportTimeReportCSV,
storeContexts: [],
options: {},
},
swapDynamicWithComputedDisplaySet: {
commandFn: actions.swapDynamicWithComputedDisplaySet,
storeContexts: [],
options: {},
},
createNewLabelMapForDynamicVolume: {
commandFn: actions.createNewLabelMapForDynamicVolume,
storeContexts: [],
options: {},
},
swapComputedWithDynamicDisplaySet: {
commandFn: actions.swapComputedWithDynamicDisplaySet,
storeContexts: [],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'DYNAMIC-VOLUME:CORNERSTONE',
};
};
export default commandsModule;

View File

@@ -0,0 +1,681 @@
const DEFAULT_COLORMAP = '2hot';
const toolGroupIds = {
pt: 'dynamic4D-pt',
fusion: 'dynamic4D-fusion',
ct: 'dynamic4D-ct',
};
function getPTOptions({
colormap,
voiInverted,
}: {
colormap?: {
name: string;
opacity:
| number
| {
value: number;
opacity: number;
}[];
};
voiInverted?: boolean;
} = {}) {
return {
blendMode: 'MIP',
colormap,
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted,
};
}
function getPTViewports() {
const ptOptionsParams = {
colormap: {
name: DEFAULT_COLORMAP,
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.1, opacity: 1 },
{ value: 1, opacity: 1 },
],
},
voiInverted: false,
};
return [
{
viewportOptions: {
viewportId: 'ptAxial',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: toolGroupIds.pt,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ptDisplaySet',
options: { ...getPTOptions(ptOptionsParams) },
},
],
},
{
viewportOptions: {
viewportId: 'ptSagittal',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: toolGroupIds.pt,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ptDisplaySet',
options: { ...getPTOptions(ptOptionsParams) },
},
],
},
{
viewportOptions: {
viewportId: 'ptCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: toolGroupIds.pt,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ptDisplaySet',
options: { ...getPTOptions(ptOptionsParams) },
},
],
},
];
}
function getFusionViewports() {
const ptOptionsParams = {
colormap: {
name: DEFAULT_COLORMAP,
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.1, opacity: 0.8 },
{ value: 1, opacity: 0.8 },
],
},
};
return [
{
viewportOptions: {
viewportId: 'fusionAxial',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: toolGroupIds.fusion,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncInvertState: false,
},
},
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: { ...getPTOptions(ptOptionsParams) },
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionSagittal',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: toolGroupIds.fusion,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncInvertState: false,
},
},
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: { ...getPTOptions(ptOptionsParams) },
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: toolGroupIds.fusion,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncInvertState: false,
},
},
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: { ...getPTOptions(ptOptionsParams) },
id: 'ptDisplaySet',
},
],
},
];
}
function getSeriesChartViewport() {
return {
viewportOptions: {
viewportId: 'seriesChart',
},
displaySets: [
{
id: 'chartDisplaySet',
options: {
// This dataset does not require the download of any instance since it is pre-computed locally,
// but interleaveTopToBottom.ts was not loading any series because it consider that all viewports
// are a Cornerstone viewport which is not true in this case and it waits for all viewports to
// have called interleaveTopToBottom(...).
skipLoading: true,
},
},
],
};
}
function getCTViewports() {
return [
{
viewportOptions: {
viewportId: 'ctAxial',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: toolGroupIds.ct,
initialImageOptions: {
preset: 'middle', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ctSagittal',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: toolGroupIds.ct,
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ctCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: toolGroupIds.ct,
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
];
}
const defaultProtocol = {
id: 'default4D',
locked: true,
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2023-01-01T00:00:00.000Z',
modifiedDate: '2023-01-01T00:00:00.000Z',
availableTo: {},
editableBy: {},
imageLoadStrategy: 'default', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
attribute: 'ModalitiesInStudy',
constraint: {
contains: ['CT', 'PT'],
},
},
],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: -1,
displaySetSelectors: {
defaultDisplaySetId: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [
// Try to match series with images by default, to prevent weird display
// on SEG/SR containing studies
{
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
},
],
// Can be used to select matching studies
// studyMatchingRules: [],
},
ctDisplaySet: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: {
value: 'CT',
},
},
required: true,
},
{
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
// Can be used to select matching studies
// studyMatchingRules: [],
},
ptDisplaySet: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'PT',
},
required: true,
},
{
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'SeriesDescription',
constraint: {
contains: 'Corrected',
},
},
{
weight: 2,
attribute: 'SeriesDescription',
constraint: {
doesNotContain: {
value: 'Uncorrected',
},
},
},
// Should we check if CorrectedImage contains ATTN?
// (0028,0051) (CorrectedImage): NORM\DTIM\ATTN\SCAT\RADL\DECY
],
// Can be used to select matching studies
// studyMatchingRules: [],
},
chartDisplaySet: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: {
value: 'CHT',
},
},
required: true,
},
],
},
},
stages: [
{
id: 'dataPreparation',
name: 'Data Preparation',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
},
},
viewports: [...getPTViewports()],
createdDate: '2023-01-01T00:00:00.000Z',
},
{
id: 'registration',
name: 'Registration',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 3,
},
},
viewports: [...getFusionViewports(), ...getCTViewports(), ...getPTViewports()],
createdDate: '2023-01-01T00:00:00.000Z',
},
{
id: 'roiQuantification',
name: 'ROI Quantification',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
},
},
viewports: [...getFusionViewports()],
createdDate: '2023-01-01T00:00:00.000Z',
},
{
id: 'kineticAnalysis',
name: 'Kinetic Analysis',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 3,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 3,
height: 1 / 2,
},
{
x: 1 / 3,
y: 0,
width: 1 / 3,
height: 1 / 2,
},
{
x: 2 / 3,
y: 0,
width: 1 / 3,
height: 1 / 2,
},
{
x: 0,
y: 1 / 2,
width: 1,
height: 1 / 2,
},
],
},
},
viewports: [...getFusionViewports(), getSeriesChartViewport()],
createdDate: '2023-01-01T00:00:00.000Z',
},
],
};
/**
* HangingProtocolModule should provide a list of hanging protocols that will be
* available in OHIF for Modes to use to decide on the structure of the viewports
* and also the series that hung in the viewports. Each hanging protocol is defined by
* { name, protocols}. Examples include the default hanging protocol provided by
* the default extension that shows 2x2 viewports.
*/
function getHangingProtocolModule() {
return [
{
name: defaultProtocol.id,
protocol: defaultProtocol,
},
];
}
export default getHangingProtocolModule;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { DynamicDataPanel } from './panels';
import { Toolbox } from '@ohif/ui-next';
import { PanelSegmentation } from '@ohif/extension-cornerstone';
import DynamicExport from './panels/DynamicExport';
function getPanelModule({ commandsManager, extensionManager, servicesManager, configuration }) {
const wrappedDynamicDataPanel = () => {
return (
<DynamicDataPanel
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
const wrappedDynamicSegmentation = () => {
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="dynamic-toolbox"
title="Threshold Tools"
/>
<PanelSegmentation
servicesManager={servicesManager}
commandsManager={commandsManager}
extensionManager={extensionManager}
configuration={configuration}
>
<DynamicExport
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
</PanelSegmentation>
</>
);
};
return [
{
name: 'dynamic-volume',
iconName: 'tab-4d',
iconLabel: '4D Workflow',
label: '4D Workflow',
component: wrappedDynamicDataPanel,
},
{
name: 'dynamic-segmentation',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedDynamicSegmentation,
},
];
}
export default getPanelModule;

View File

@@ -0,0 +1,6 @@
import packageJson from '../package.json';
const id = packageJson.name;
const SOPClassHandlerName = 'dynamic-volume';
export { id, SOPClassHandlerName };

View File

@@ -0,0 +1,57 @@
import { id } from './id';
import commandsModule from './commandsModule';
import getPanelModule from './getPanelModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import { cache } from '@cornerstonejs/core';
/**
* You can remove any of the following modules if you don't need them.
*/
const dynamicVolumeExtension = {
/**
* 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 = {} }) => {
// TODO: look for the right fix
cache.setMaxCacheSize(5 * 1024 * 1024 * 1024);
},
/**
* 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,
/**
* ViewportModule should provide a list of viewports that will be available in OHIF
* for Modes to consume and use in the viewports. Each viewport is defined by
* {name, component} object. Example of a viewport module is the CornerstoneViewport
* that is provided by the Cornerstone extension in OHIF.
*/
getHangingProtocolModule,
/**
* CommandsModule should provide a list of commands that will be available in OHIF
* for Modes to consume and use in the viewports. Each command is defined by
* an object of { actions, definitions, defaultContext } where actions is an
* object of functions, definitions is an object of available commands, their
* options, and defaultContext is the default context for the command to run against.
*/
getCommandsModule: ({ servicesManager, commandsManager, extensionManager }) => {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
};
export { dynamicVolumeExtension as default };

View File

@@ -0,0 +1,20 @@
import React from 'react';
import PanelGenerateImage from './PanelGenerateImage';
function DynamicDataPanel({ servicesManager, commandsManager, tab }: withAppTypes) {
return (
<>
<div
className="flex flex-col text-white"
data-cy={'dynamic-volume-panel'}
>
<PanelGenerateImage
commandsManager={commandsManager}
servicesManager={servicesManager}
></PanelGenerateImage>
</div>
</>
);
}
export default DynamicDataPanel;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Button, Icons } from '@ohif/ui-next';
import { useSegmentations } from '@ohif/extension-cornerstone';
function DynamicExport({ commandsManager, servicesManager }: withAppTypes) {
const segmentations = useSegmentations({ servicesManager });
if (!segmentations?.length) {
return null;
}
return (
<div className="flex gap-2">
<div className="flex h-8 w-full items-center rounded pr-0.5">
<Button
size="sm"
variant="ghost"
className="pl-1.5"
onClick={() => {
commandsManager.runCommand('exportTimeReportCSV', {
segmentations,
options: {
filename: 'TimeData.csv',
},
});
}}
>
<Icons.Export />
<span className="pl-1">Time Data</span>
</Button>
</div>
<div className="flex h-8 w-full items-center rounded pr-0.5">
<Button
size="sm"
variant="ghost"
className="pl-1.5"
onClick={() => {
commandsManager.runCommand('exportTimeReportCSV', {
segmentations,
summaryStats: true,
options: {
filename: 'ROIStats.csv',
},
});
}}
>
<Icons.Export />
<span className="pl-1">ROI Stats</span>
</Button>
</div>
</div>
);
}
export default DynamicExport;

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