feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664)

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Neil <neil.a.macphee@gmail.com>
This commit is contained in:
Leonardo Campos 2024-04-10 00:57:34 -03:00 committed by GitHub
parent dc0b183dd4
commit d57e8bc157
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
200 changed files with 9589 additions and 2178 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ yarn-error.log
.DS_Store
.env
*.code-workspace
.directory
# Common Example Data Directories
sampledata/

View File

@ -1 +1 @@
dc37802ec1f739a6ed602363bdf231d6fe58827e
dc37802ec1f739a6ed602363bdf231d6fe58827e

View File

@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.68.1",
"@cornerstonejs/core": "^1.68.1",
"@kitware/vtk.js": "29.7.0",
"@cornerstonejs/adapters": "^1.70.0",
"@cornerstonejs/core": "^1.70.0",
"@kitware/vtk.js": "30.3.1",
"react-color": "^2.19.3"
}
}

View File

@ -3,7 +3,6 @@ import React from 'react';
import { useAppConfig } from '@state';
import { Toolbox } from '@ohif/ui';
import PanelSegmentation from './panels/PanelSegmentation';
import { SegmentationPanelMode } from './types/segmentation';
const getPanelModule = ({
commandsManager,
@ -17,11 +16,6 @@ const getPanelModule = ({
const wrappedPanelSegmentation = configuration => {
const [appConfig] = useAppConfig();
const disableEditingForMode = customizationService.get('segmentation.disableEditing');
const segmentationPanelMode =
customizationService.get('segmentation.segmentationPanelMode')?.value ||
SegmentationPanelMode.Dropdown;
return (
<PanelSegmentation
commandsManager={commandsManager}
@ -29,8 +23,8 @@ const getPanelModule = ({
extensionManager={extensionManager}
configuration={{
...configuration,
disableEditing: appConfig.disableEditing || disableEditingForMode?.value,
segmentationPanelMode: segmentationPanelMode,
disableEditing: appConfig.disableEditing,
...customizationService.get('segmentation.panel'),
}}
/>
);
@ -38,9 +32,6 @@ const getPanelModule = ({
const wrappedPanelSegmentationWithTools = configuration => {
const [appConfig] = useAppConfig();
const segmentationPanelMode =
customizationService.get('segmentation.segmentationPanelMode')?.value ||
SegmentationPanelMode.Dropdown;
return (
<>
@ -60,7 +51,8 @@ const getPanelModule = ({
extensionManager={extensionManager}
configuration={{
...configuration,
segmentationPanelMode: segmentationPanelMode,
disableEditing: appConfig.disableEditing,
...customizationService.get('segmentation.panel'),
}}
/>
</>

View File

@ -52,16 +52,22 @@ export function getToolbarModule({ commandsManager, servicesManager }) {
];
}
// Todo: this is duplicate, we should move it to a shared location
function getToolNameForButton(button) {
const { props } = button;
const commands = props?.commands || button.commands;
if (commands && commands.length) {
const command = commands[0];
const { commandOptions } = command;
const { toolName } = commandOptions || { toolName: props?.id ?? button.id };
return toolName;
const commandsArray = Array.isArray(commands) ? commands : [commands];
const firstCommand = commandsArray[0];
if (typeof firstCommand === 'string') {
// likely not a cornerstone tool
return null;
}
return null;
if ('commandOptions' in firstCommand) {
return firstCommand.commandOptions.toolName ?? props?.id ?? button.id;
}
// use id as a fallback for toolName
return props?.id ?? button.id;
}

View File

@ -6,7 +6,6 @@ import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import getCommandsModule from './commandsModule';
import { getToolbarModule } from './getToolbarModule';
import preRegistration from './init';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSEGViewport');
@ -29,7 +28,6 @@ const extension = {
* You ID can be anything you want, but it should be unique.
*/
id,
preRegistration,
/**
* 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,

View File

@ -1,5 +0,0 @@
import { addTool, BrushTool } from '@cornerstonejs/tools';
export default function init({ servicesManager }): void {
addTool(BrushTool);
}

View File

@ -242,7 +242,13 @@ export default function PanelSegmentation({
});
};
const SegmentationGroupTableComponent = components[configuration?.segmentationPanelMode];
const SegmentationGroupTableComponent =
components[configuration?.segmentationPanelMode] || SegmentationGroupTable;
const allowAddSegment = configuration?.addSegment;
const onSegmentationAddWrapper =
configuration?.onSegmentationAdd && typeof configuration?.onSegmentationAdd === 'function'
? configuration?.onSegmentationAdd
: onSegmentationAdd;
return (
<SegmentationGroupTableComponent
@ -250,7 +256,8 @@ export default function PanelSegmentation({
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAdd}
onSegmentationAdd={onSegmentationAddWrapper}
showAddSegment={allowAddSegment}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}

View File

@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.68.1",
"@cornerstonejs/core": "^1.68.1",
"@cornerstonejs/tools": "^1.68.1",
"@cornerstonejs/adapters": "^1.70.0",
"@cornerstonejs/core": "^1.70.0",
"@cornerstonejs/tools": "^1.70.0",
"classnames": "^2.3.2"
}
}

View File

@ -378,10 +378,13 @@ async function _getViewportReferencedDisplaySetData(
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);
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);

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

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,50 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.8.0-beta.72",
"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.7.0-beta.76",
"@ohif/ui": "3.7.0-beta.76",
"@ohif/extension-default": "3.7.0-beta.76",
"@ohif/extension-cornerstone": "3.7.0-beta.76",
"@ohif/i18n": "3.7.0-beta.76",
"dcmjs": "^0.29.5",
"dicom-parser": "^1.8.21",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/tools": "^1.70.0",
"@cornerstonejs/core": "^1.70.0",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.0",
"classnames": "^2.3.2"
}
}

View File

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

View File

@ -0,0 +1,281 @@
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, displaySetService) {
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 [timeData, _] = csToolsUtils.dynamicVolume.getDataInTime(referencedDynamicVolume, {
maskVolumeId: segmentation.id,
}) as number[][];
const pixelCount = timeData.length;
if (pixelCount === 0) {
return [];
}
// since we only use one segmentation representation per segmentationId
// it is fine to pick the first one
const segmentationRepresentations = csTools.segmentation.state.getSegmentationIdRepresentations(
segmentation.id
);
const segmentationRepresentationUID =
segmentationRepresentations[0].segmentationRepresentationUID;
const toolGroupId = csTools.segmentation.state.getToolGroupIdFromSegmentationRepresentationUID(
segmentationRepresentationUID
);
// Todo: this is useless we should be able to grab color with just segRepUID and segmentIndex
const color = csTools.segmentation.config.color.getColorForSegmentIndex(
toolGroupId,
segmentationRepresentationUID,
1 // segmentIndex
);
const hexColor = cs.utilities.color.rgbToHex(...color);
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, displaySetService) {
if (!segmentations.length) {
return;
}
const volumesTimePointsCache = new WeakMap();
const segmentationsData = segmentations.map(segmentation =>
_getSegmentationData(segmentation, volumesTimePointsCache, displaySetService)
);
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 }): void {
const { segmentationService, displaySetService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } =
_getInstanceFromSegmentations(segmentations, displaySetService) ?? {};
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,407 @@
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 }) => {
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, segmentationIndex) => {
const [timeData, ijkCoords] = utilities.dynamicVolume.getDataInTime(dynamicVolume, {
maskVolumeId: segmentation.id,
}) 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 = [];
for (let voxelIndex = 0; voxelIndex < numVoxels; voxelIndex++) {
voxelValues.push(timeData[voxelIndex][timeIndex]);
}
mean.push(voxelValues.reduce((acc, value) => acc + value, 0) / numVoxels);
const minimum = Math.min(...voxelValues);
min.push(minimum);
minIJK.push(ijkCoords[voxelValues.indexOf(minimum)]);
const maximum = Math.max(...voxelValues);
max.push(maximum);
maxIJK.push(ijkCoords[voxelValues.indexOf(maximum)]);
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 segmentationId = await segmentationService.createSegmentationForDisplaySet(
referenceDisplaySet.displaySetInstanceUID,
{ label }
);
// Add Segmentation to all toolGroupIds in the viewer
const toolGroupIds = Array.from(
viewports.values(),
viewport => viewport.viewportOptions.toolGroupId
);
const representationType = LABELMAP;
for (const toolGroupId of toolGroupIds) {
const hydrateSegmentation = true;
await segmentationService.addSegmentationRepresentationToToolGroup(
toolGroupId,
segmentationId,
hydrateSegmentation,
representationType
);
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
}
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,654 @@
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.3 },
{ value: 1, opacity: 0.3 },
],
},
};
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,
},
},
],
},
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,
},
},
],
},
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,
},
},
],
},
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,68 @@
import React from 'react';
import { DynamicDataPanel } from './panels';
import { Toolbox } from '@ohif/ui';
import DynamicExport from './panels/DynamicExport';
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedDynamicDataPanel = () => {
return (
<DynamicDataPanel
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
const wrappedDynamicToolbox = () => {
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="dynamic-toolbox"
title="Threshold Tools"
/>
</>
);
};
const wrappedDynamicExport = () => {
return (
<>
<DynamicExport
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
</>
);
};
return [
{
name: 'dynamic-volume',
iconName: 'group-layers',
iconLabel: '4D Workflow',
label: '4D Workflow',
component: wrappedDynamicDataPanel,
},
{
name: 'dynamic-toolbox',
iconName: 'group-layers',
iconLabel: '4D Workflow',
label: 'Dynamic Toolbox',
component: wrappedDynamicToolbox,
},
{
name: 'dynamic-export',
iconName: 'group-layers',
iconLabel: '4D Workflow',
label: '4D Workflow',
component: wrappedDynamicExport,
},
];
}
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,18 @@
import React from 'react';
import PanelGenerateImage from './PanelGenerateImage';
function DynamicDataPanel({ servicesManager, commandsManager }) {
return (
<div
className="flex flex-auto flex-col text-white"
data-cy={'dynamic-volume-panel'}
>
<PanelGenerateImage
commandsManager={commandsManager}
servicesManager={servicesManager}
></PanelGenerateImage>
</div>
);
}
export default DynamicDataPanel;

View File

@ -0,0 +1,76 @@
import React, { useState, useEffect } from 'react';
import { ActionButtons } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
function DynamicExport({ commandsManager, servicesManager, extensionManager }) {
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('dynamicExport');
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const actions = [
{
label: 'Export Time Data',
onClick: () => {
commandsManager.runCommand('exportTimeReportCSV', {
segmentations,
options: {
filename: 'TimeData.csv',
},
});
},
disabled: !segmentations?.length,
},
{
label: 'Export ROI Stats',
onClick: () => {
commandsManager.runCommand('exportTimeReportCSV', {
segmentations,
summaryStats: true,
options: {
filename: 'ROIStats.csv',
},
});
},
disabled: !segmentations?.length,
},
];
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED;
const subscriptions = [];
[added, updated, removed].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
return (
<div>
<div className="mt-3 flex justify-center px-2">
<ActionButtons
actions={actions}
t={t}
/>
</div>
</div>
);
}
export default DynamicExport;

View File

@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import {
InputDoubleRange,
Button,
PanelSection,
ButtonGroup,
IconButton,
InputNumber,
Icon,
Tooltip,
} from '@ohif/ui';
import { Enums } from '@cornerstonejs/core';
const controlClassNames = {
sizeClassName: 'w-[58px] h-[28px]',
arrowsDirection: 'horizontal',
labelPosition: 'bottom',
};
const Header = ({ title, tooltip }) => (
<div className="flex items-center space-x-1">
<Tooltip
content={<div className="text-white">{tooltip}</div>}
position="bottom"
tight={true}
tooltipBoxClassName="max-w-xs"
>
<Icon
name="info-link"
className="text-primary-active h-[14px] w-[14px]"
/>
</Tooltip>
<span className="text-aqua-pale text-[11px] uppercase">{title}</span>
</div>
);
const DynamicVolumeControls = ({
isPlaying,
onPlayPauseChange,
// fps
fps,
onFpsChange,
minFps,
maxFps,
// Frames
currentFrameIndex,
onFrameChange,
framesLength,
onGenerate,
onDoubleRangeChange,
onDynamicClick,
}) => {
const [computedView, setComputedView] = useState(false);
const [computeViewMode, setComputeViewMode] = useState(Enums.DynamicOperatorType.SUM);
const [sliderRangeValues, setSliderRangeValues] = useState([framesLength / 4, framesLength / 2]);
useEffect(() => {
setSliderRangeValues([framesLength / 4, framesLength / 2]);
}, [framesLength]);
const handleSliderChange = newValues => {
onDoubleRangeChange(newValues);
if (newValues[0] === sliderRangeValues[0] && newValues[1] === sliderRangeValues[1]) {
return;
}
setSliderRangeValues(newValues);
};
return (
<div className="flex select-none flex-col">
<PanelSection
title="Controls"
childrenClassName="space-y-4 pb-5 px-5"
>
<div className="mt-2">
<Header
title="View"
tooltip={
'Select the view mode, 4D to view the dynamic volume or Computed to view the computed volume'
}
/>
<ButtonGroup className="mt-2 w-full">
<button
className="w-1/2"
onClick={() => {
setComputedView(false);
onDynamicClick();
}}
>
4D
</button>
<button
className="w-1/2"
onClick={() => {
setComputedView(true);
}}
>
Computed
</button>
</ButtonGroup>
</div>
<div>
<FrameControls
onPlayPauseChange={onPlayPauseChange}
isPlaying={isPlaying}
computedView={computedView}
// fps
fps={fps}
onFpsChange={onFpsChange}
minFps={minFps}
maxFps={maxFps}
//
framesLength={framesLength}
onFrameChange={onFrameChange}
currentFrameIndex={currentFrameIndex}
/>
</div>
<div className={`mt-6 flex flex-col ${computedView ? '' : 'ohif-disabled'}`}>
<Header title="Computed Operation" />
<ButtonGroup
className={`mt-2 w-full `}
separated={true}
>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.SUM)}
>
{Enums.DynamicOperatorType.SUM.toString().toUpperCase()}
</button>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.AVERAGE)}
>
{Enums.DynamicOperatorType.AVERAGE.toString().toUpperCase()}
</button>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.SUBTRACT)}
>
{Enums.DynamicOperatorType.SUBTRACT.toString().toUpperCase()}
</button>
</ButtonGroup>
<div className="w-ful mt-2">
<InputDoubleRange
values={sliderRangeValues}
onChange={handleSliderChange}
minValue={0}
showLabel={true}
allowNumberEdit={true}
maxValue={framesLength}
step={1}
/>
</div>
<Button
className="mt-2 !h-[26px] !w-[115px] self-start !p-0"
onClick={() => {
onGenerate(computeViewMode);
}}
>
Generate
</Button>
</div>
</PanelSection>
</div>
);
};
export default DynamicVolumeControls;
function FrameControls({
isPlaying,
onPlayPauseChange,
fps,
minFps,
maxFps,
onFpsChange,
framesLength,
onFrameChange,
currentFrameIndex,
computedView,
}) {
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
return (
<div className={computedView && 'ohif-disabled'}>
<Header title="4D Controls" />
<div className="mt-3 flex justify-between">
<IconButton
className="bg-customblue-30 h-[26px] w-[58px] rounded-[4px]"
onClick={() => onPlayPauseChange(!isPlaying)}
>
<Icon
name={getPlayPauseIconName()}
className=" active:text-primary-light hover:bg-customblue-300 h-[24px] w-[24px] cursor-pointer text-white"
/>
</IconButton>
<InputNumber
value={currentFrameIndex}
onChange={onFrameChange}
minValue={0}
maxValue={framesLength}
label="Frame"
{...controlClassNames}
/>
<InputNumber
value={fps}
onChange={onFpsChange}
minValue={minFps}
maxValue={maxFps}
{...controlClassNames}
label="FPS"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import React from 'react';
import { InputDoubleRange } from '@ohif/ui';
import { Select } from '@ohif/ui';
import { Button } from '@ohif/ui';
import PropTypes from 'prop-types';
const GenerateVolume = ({
rangeValues,
handleSliderChange,
operationsUI,
options,
handleGenerateOptionsChange,
onGenerateImage,
returnTo4D,
displayingComputedVolume,
}) => {
return (
<>
<div>
<div className="mb-2 text-white">Computed Image</div>
<Select
closeMenuOnSelect={true}
className="border-primary-main mr-2 bg-black text-white "
options={operationsUI}
placeholder={operationsUI.find(option => option.value === options.Operation).placeHolder}
value={options.Operation}
onChange={({ value }) => {
handleGenerateOptionsChange({
Operation: value,
});
}}
/>
<InputDoubleRange
values={rangeValues}
onChange={handleSliderChange}
minValue={rangeValues[0] || 1}
maxValue={rangeValues[1] || 2}
showLabel={true}
step={1}
/>
<div className="flex space-x-2">
<Button
onClick={onGenerateImage}
className="w-1/2"
>
Generate
</Button>
<Button
onClick={returnTo4D}
disabled={!displayingComputedVolume}
className="w-1/2"
>
Return To 4D
</Button>
</div>
</div>
</>
);
};
GenerateVolume.propTypes = {
rangeValues: PropTypes.array.isRequired,
handleSliderChange: PropTypes.func.isRequired,
operationsUI: PropTypes.array.isRequired,
options: PropTypes.object.isRequired,
handleGenerateOptionsChange: PropTypes.func.isRequired,
onGenerateImage: PropTypes.func.isRequired,
returnTo4D: PropTypes.func.isRequired,
};
export default GenerateVolume;

View File

@ -0,0 +1,275 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useCine, useViewportGrid } from '@ohif/ui';
import { cache, utilities as csUtils, volumeLoader, eventTarget } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/streaming-image-volume-loader';
import { utilities as cstUtils } from '@cornerstonejs/tools';
import DynamicVolumeControls from './DynamicVolumeControls';
const SOPClassHandlerId = '@ohif/extension-default.sopClassHandlerModule.stack';
export default function PanelGenerateImage({ servicesManager, commandsManager }) {
const { cornerstoneViewportService, viewportGridService, displaySetService } =
servicesManager.services;
const [{ isCineEnabled }, cineService] = useCine();
const [{ activeViewportId }] = useViewportGrid();
//
const [timePointsRange, setTimePointsRange] = useState([]);
const [timePointsRangeToUseForGenerate, setTimePointsRangeToUseForGenerate] = useState([]);
const [computedDisplaySet, setComputedDisplaySet] = useState(null);
const [dynamicVolume, setDynamicVolume] = useState(null);
const [frameRate, setFrameRate] = useState(20);
const [isPlaying, setIsPlaying] = useState(isCineEnabled);
const [timePointRendered, setTimePointRendered] = useState(null);
const [displayingComputed, setDisplayingComputed] = useState(false);
//
const uuidComputedVolume = useRef(csUtils.uuidv4());
const uuidDynamicVolume = useRef(null);
const computedVolumeId = `cornerstoneStreamingImageVolume:${uuidComputedVolume.current}`;
useEffect(() => {
const evt = cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED;
const { unsubscribe } = cornerstoneViewportService.subscribe(evt, evtDetails => {
evtDetails.viewportData.data.forEach(volumeData => {
if (volumeData.volume.isDynamicVolume()) {
setDynamicVolume(volumeData.volume);
uuidDynamicVolume.current = volumeData.displaySetInstanceUID;
setTimePointsRange([1, volumeData.volume.numTimePoints]);
}
});
});
return () => {
unsubscribe();
};
}, [cornerstoneViewportService]);
useEffect(() => {
const { unsubscribe } = servicesManager.services.cineService.subscribe(
servicesManager.services.cineService.EVENTS.CINE_STATE_CHANGED,
evt => {
setIsPlaying(evt.isPlaying);
}
);
return () => {
unsubscribe();
};
}, [cineService]);
useEffect(() => {
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(activeViewportId);
if (!displaySetUIDs || displaySetUIDs.length === 0) {
return;
}
const displaySets = displaySetUIDs.map(displaySetUID =>
displaySetService.getDisplaySetByUID(displaySetUID)
);
const dynamicVolumeDisplaySet = displaySets.find(displaySet => displaySet.isDynamicVolume);
if (!dynamicVolumeDisplaySet) {
return;
}
const dynamicVolume = cache
.getVolumes()
.find(volume => volume.volumeId.includes(dynamicVolumeDisplaySet.displaySetInstanceUID));
if (!dynamicVolume) {
return;
}
setDynamicVolume(dynamicVolume);
uuidDynamicVolume.current = dynamicVolumeDisplaySet.displaySetInstanceUID;
setTimePointsRange([1, dynamicVolume.numTimePoints]);
}, [activeViewportId, cornerstoneViewportService]);
useEffect(() => {
// ~~ Subscription
const evt = Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED;
const callback = evt => {
setTimePointRendered(evt.detail.timePointIndex);
};
eventTarget.addEventListener(evt, callback);
return () => {
eventTarget.removeEventListener(evt, callback);
};
}, [cornerstoneViewportService]);
function renderGeneratedImage(displaySet) {
commandsManager.runCommand('swapDynamicWithComputedDisplaySet', {
displaySet,
});
setDisplayingComputed(true);
}
function renderDynamicImage(displaySet) {
commandsManager.runCommand('swapComputedWithDynamicDisplaySet');
}
// Get computed volume from cache, calculate the data across the time frames,
// set the scalar data to the computedVolume, and create displaySet
async function onGenerateImage(operationName) {
const dynamicVolumeId = dynamicVolume.volumeId;
if (!dynamicVolumeId) {
return;
}
let computedVolume = cache.getVolume(computedVolumeId);
if (!computedVolume) {
await createComputedVolume(dynamicVolumeId, computedVolumeId);
computedVolume = cache.getVolume(computedVolumeId);
}
const vals = timePointsRangeToUseForGenerate;
const targets = Array.from({ length: vals[1] - vals[0] + 1 }, (_, i) => i + vals[0]);
const dataInTime = cstUtils.dynamicVolume.generateImageFromTimeData(
dynamicVolume,
operationName,
operationName === 'SUBTRACT' ? vals : targets
);
// Add loadStatus.loaded to computed volume and set to true
computedVolume.loadStatus = {};
computedVolume.loadStatus.loaded = true;
// Set computed scalar data to volume
const scalarData = computedVolume.getScalarData();
scalarData.set(dataInTime);
// If computed display set does not exist, create an object to be used as
// the displaySet. If it does exist, update the image data and vtkTexture
if (!computedDisplaySet) {
const displaySet = {
volumeLoaderSchema: computedVolume.volumeId.split(':')[0],
displaySetInstanceUID: uuidComputedVolume.current,
SOPClassHandlerId: SOPClassHandlerId,
Modality: dynamicVolume.metadata.Modality,
isMultiFrame: false,
numImageFrames: 1,
uid: uuidComputedVolume.current,
referenceDisplaySetUID: dynamicVolume.volumeId.split(':')[1],
madeInClient: true,
FrameOfReferenceUID: dynamicVolume.metadata.FrameOfReferenceUID,
isDerived: true,
};
setComputedDisplaySet(displaySet);
renderGeneratedImage(displaySet);
} else {
commandsManager.runCommand('updateVolumeData', {
volume: computedVolume,
});
// Check if viewport is currently displaying the computed volume, if so,
// call render on the viewports to update the image, if not, call
// renderGeneratedImage
// if (!cache.getVolume(dynamicVolumeId)) {
// for (const viewportId of viewports.keys()) {
// const viewportForRendering =
// cornerstoneViewportService.getCornerstoneViewport(viewportId);
// viewportForRendering.render();
// }
// } else {
cornerstoneViewportService.getRenderingEngine().render();
renderGeneratedImage(computedDisplaySet);
// }
}
}
const onPlayPauseChange = isPlaying => {
isPlaying ? handlePlay() : handleStop();
};
const handlePlay = () => {
setIsPlaying(true);
const viewportInfo = cornerstoneViewportService.getViewportInfo(activeViewportId);
if (!viewportInfo) {
return;
}
const { element } = viewportInfo;
cineService.playClip(element, { framesPerSecond: frameRate });
};
const handleStop = () => {
setIsPlaying(false);
const { element } = cornerstoneViewportService.getViewportInfo(activeViewportId);
cineService.stopClip(element);
};
const handleSetFrameRate = newFrameRate => {
setFrameRate(newFrameRate);
handleStop();
handlePlay();
};
function handleSliderChange(newValues) {
if (
newValues[0] === timePointsRangeToUseForGenerate[0] &&
newValues[1] === timePointsRangeToUseForGenerate[1]
) {
return;
}
setTimePointsRangeToUseForGenerate(newValues);
}
if (!dynamicVolume || timePointsRange.length === 0) {
return null;
}
return (
<DynamicVolumeControls
fps={frameRate}
isPlaying={isPlaying}
onPlayPauseChange={onPlayPauseChange}
minFps={1}
maxFps={50}
currentFrameIndex={timePointRendered}
onFpsChange={handleSetFrameRate}
framesLength={timePointsRange[1]}
onFrameChange={timePointIndex => {
dynamicVolume.timePointIndex = timePointIndex;
}}
onGenerate={onGenerateImage}
onDynamicClick={displayingComputed ? () => renderDynamicImage(computedDisplaySet) : null}
onDoubleRangeChange={handleSliderChange}
/>
);
}
async function createComputedVolume(dynamicVolumeId, computedVolumeId) {
if (!cache.getVolume(computedVolumeId)) {
const computedVolume = await volumeLoader.createAndCacheDerivedVolume(dynamicVolumeId, {
volumeId: computedVolumeId,
});
return computedVolume;
}
}
PanelGenerateImage.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
measurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -0,0 +1,27 @@
import React, { useMemo } from 'react';
function WorkflowPanel({ servicesManager, extensionManager }) {
const ProgressDropdownWithService = useMemo(() => {
const defaultComponents = extensionManager.getModuleEntry(
'@ohif/extension-default.customizationModule.default'
).value;
return defaultComponents.find(
component => component.id === 'progressDropdownWithServiceComponent'
).component;
}, [extensionManager]);
return (
<div
data-cy={'workflow-panel'}
className="bg-secondary-dark mb-1 px-3 py-4"
>
<div className="mb-1">Workflow</div>
<div>
<ProgressDropdownWithService servicesManager={servicesManager} />
</div>
</div>
);
}
export default WorkflowPanel;

View File

@ -0,0 +1,5 @@
import DynamicDataPanel from './DynamicDataPanel';
import WorkflowPanel from './WorkflowPanel';
import PanelGenerateImage from './PanelGenerateImage';
export { DynamicDataPanel, WorkflowPanel, PanelGenerateImage };

View File

@ -38,7 +38,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.2",
"@cornerstonejs/codec-openjph": "^2.4.2",
"@cornerstonejs/dicom-image-loader": "^1.68.1",
"@cornerstonejs/dicom-image-loader": "^1.70.0",
"@icr/polyseg-wasm": "^0.4.0",
"@ohif/core": "3.8.0-beta.73",
"@ohif/ui": "3.8.0-beta.73",
@ -55,11 +55,12 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^1.68.1",
"@cornerstonejs/core": "^1.68.1",
"@cornerstonejs/streaming-image-volume-loader": "^1.68.1",
"@cornerstonejs/tools": "^1.68.1",
"@kitware/vtk.js": "29.7.0",
"@cornerstonejs/adapters": "^1.70.0",
"@cornerstonejs/core": "^1.70.0",
"@cornerstonejs/streaming-image-volume-loader": "^1.70.0",
"@cornerstonejs/tools": "^1.70.0",
"@icr/polyseg-wasm": "^0.4.0",
"@kitware/vtk.js": "30.3.1",
"html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8",
"lodash.merge": "^4.6.2",

View File

@ -118,6 +118,14 @@ const OHIFCornerstoneViewport = React.memo(props => {
} = props;
const viewportId = viewportOptions.viewportId;
// Since we only have support for dynamic data in volume viewports, we should
// handle this case here and set the viewportType to volume if any of the
// displaySets are dynamic volumes
viewportOptions.viewportType = displaySets.some(ds => ds.isDynamicVolume)
? 'volume'
: viewportOptions.viewportType;
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [enabledVPElement, setEnabledVPElement] = useState(null);
const elementRef = useRef();

View File

@ -322,7 +322,7 @@ function _getViewportInstances(viewportData) {
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
const volumes = viewportData.data;
volumes.forEach(volume => {
if (!volume?.imageIds) {
if (!volume?.imageIds || volume.imageIds.length === 0) {
return;
}
imageIds.push(volume.imageIds[0]);

View File

@ -126,16 +126,12 @@ function commandsModule({
? nearbyToolData
: null;
},
// Measurement tool commands:
/** Delete the given measurement */
deleteMeasurement: ({ uid }) => {
if (uid) {
measurementServiceSource.remove(uid);
}
},
/**
* Show the measurement labelling input dialog and update the label
* on the measurement with a response if not cancelled.
@ -302,6 +298,43 @@ function commandsModule({
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
toggleEnabledDisabledToolbar({ value, itemId, toolGroupIds = [] }) {
const toolName = itemId || value;
toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return;
}
const toolIsEnabled = toolGroup.getToolOptions(toolName).mode === Enums.ToolModes.Enabled;
toolIsEnabled ? toolGroup.setToolDisabled(toolName) : toolGroup.setToolEnabled(toolName);
});
},
toggleActiveDisabledToolbar({ value, itemId, toolGroupIds = [] }) {
const toolName = itemId || value;
toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (!toolGroup || !toolGroup.hasTool(toolName)) {
return;
}
const toolIsActive = toolGroup.getToolOptions(toolName).mode === Enums.ToolModes.Active;
toolIsActive
? toolGroup.setToolDisabled(toolName)
: actions.setToolActive({ toolName, toolGroupId });
// we should set the previously active tool to active after we set the
// current tool disabled
if (toolIsActive) {
const prevToolName = toolGroup.getPrevActivePrimaryToolName();
actions.setToolActive({ toolName: prevToolName, toolGroupId });
}
});
},
setToolActiveToolbar: ({ value, itemId, toolGroupIds = [] }) => {
// Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons)
const toolName = itemId || value;
@ -639,6 +672,17 @@ function commandsModule({
storePresentation: ({ viewportId }) => {
cornerstoneViewportService.storePresentation({ viewportId });
},
updateVolumeData: ({ volume }) => {
// update vtkOpenGLTexture and imageData of computed volume
const { imageData, vtkOpenGLTexture } = volume;
const numSlices = imageData.getDimensions()[2];
const slicesToUpdate = [...Array(numSlices).keys()];
slicesToUpdate.forEach(i => {
vtkOpenGLTexture.setUpdatedFrame(i);
});
imageData.modified();
},
attachProtocolViewportDataListener: ({ protocol, stageIndex }) => {
const EVENT = cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED;
const command = protocol.callbacks.onViewportDataInitialized;
@ -928,6 +972,15 @@ function commandsModule({
toggleSynchronizer: {
commandFn: actions.toggleSynchronizer,
},
updateVolumeData: {
commandFn: actions.updateVolumeData,
},
toggleEnabledDisabledToolbar: {
commandFn: actions.toggleEnabledDisabledToolbar,
},
toggleActiveDisabledToolbar: {
commandFn: actions.toggleActiveDisabledToolbar,
},
};
return {

View File

@ -0,0 +1,31 @@
import React, { ReactElement } from 'react';
import PropTypes from 'prop-types';
import { ServicesManager } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui';
import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel';
const ActiveViewportWindowLevel = ({
servicesManager,
}: {
servicesManager: ServicesManager;
}): ReactElement => {
const [viewportGrid] = useViewportGrid();
const { activeViewportId } = viewportGrid;
return (
<>
{activeViewportId && (
<ViewportWindowLevel
servicesManager={servicesManager}
viewportId={activeViewportId}
/>
)}
</>
);
};
ActiveViewportWindowLevel.propTypes = {
servicesManager: PropTypes.instanceOf(ServicesManager),
};
export default ActiveViewportWindowLevel;

View File

@ -0,0 +1 @@
export { default } from './ActiveViewportWindowLevel';

View File

@ -1,73 +1,108 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { CinePlayer, useCine } from '@ohif/ui';
import { Enums, eventTarget } from '@cornerstonejs/core';
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
import { Enums as StreamingEnums } from '@cornerstonejs/streaming-image-volume-loader';
import { useAppConfig } from '@state';
function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
const { customizationService, displaySetService, viewportGridService } = servicesManager.services;
const [{ isCineEnabled, cines }, api] = useCine();
const [{ isCineEnabled, cines }, cineService] = useCine();
const [newStackFrameRate, setNewStackFrameRate] = useState(24);
const [dynamicInfo, setDynamicInfo] = useState(null);
const [appConfig] = useAppConfig();
const isMountedRef = useRef(null);
const { component: CinePlayerComponent = CinePlayer } =
customizationService.get('cinePlayer') ?? {};
const cineHandler = () => {
if (!cines || !cines[viewportId] || !enabledVPElement) {
if (!cines?.[viewportId] || !enabledVPElement) {
return;
}
const cine = cines[viewportId];
const isPlaying = cine.isPlaying || false;
const frameRate = cine.frameRate || 24;
const { isPlaying = false, frameRate = 24 } = cines[viewportId];
const validFrameRate = Math.max(frameRate, 1);
if (isPlaying) {
api.playClip(enabledVPElement, {
framesPerSecond: validFrameRate,
});
} else {
api.stopClip(enabledVPElement);
}
return isPlaying
? cineService.playClip(enabledVPElement, { framesPerSecond: validFrameRate })
: cineService.stopClip(enabledVPElement);
};
const newStackCineHandler = useCallback(() => {
const newDisplaySetHandler = useCallback(() => {
if (!enabledVPElement || !isCineEnabled) {
return;
}
const { viewports } = viewportGridService.getState();
const { displaySetInstanceUIDs } = viewports.get(viewportId);
let frameRate = 24;
let isPlaying = cines[viewportId].isPlaying;
let isPlaying = cines[viewportId]?.isPlaying || false;
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet.FrameRate) {
// displaySet.FrameRate corresponds to DICOM tag (0018,1063) which is defined as the the frame time in milliseconds
// So a bit of math to get the actual frame rate.
frameRate = Math.round(1000 / displaySet.FrameRate);
isPlaying ||= !!appConfig.autoPlayCine;
}
// check if the displaySet is dynamic and set the dynamic info
if (displaySet.isDynamicVolume) {
const { dynamicVolumeInfo } = displaySet;
const numTimePoints = dynamicVolumeInfo.timePoints.length;
const label = dynamicVolumeInfo.splittingTag;
const timePointIndex = dynamicVolumeInfo.timePointIndex || 0;
setDynamicInfo({
volumeId: displaySet.displaySetInstanceUID,
timePointIndex,
numTimePoints,
label,
});
} else {
setDynamicInfo(null);
}
});
if (isPlaying) {
api.setIsCineEnabled(isPlaying);
cineService.setIsCineEnabled(isPlaying);
}
api.setCine({ id: viewportId, isPlaying, frameRate });
cineService.setCine({ id: viewportId, isPlaying, frameRate });
setNewStackFrameRate(frameRate);
}, [displaySetService, viewportId, viewportGridService, cines]);
}, [displaySetService, viewportId, viewportGridService, cines, isCineEnabled]);
useEffect(() => {
isMountedRef.current = true;
eventTarget.addEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
newDisplaySetHandler();
return () => {
isMountedRef.current = false;
api.stopClip(enabledVPElement);
api.setCine({ id: viewportId, isPlaying: false });
eventTarget.removeEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newStackCineHandler);
};
}, [enabledVPElement, newStackCineHandler]);
}, [isCineEnabled, newDisplaySetHandler]);
/**
* Use effect for handling new display set
*/
useEffect(() => {
if (!enabledVPElement) {
return;
}
eventTarget.addEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newDisplaySetHandler);
// this doesn't makes sense that we are listening to this event on viewport element
enabledVPElement.addEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
newDisplaySetHandler
);
return () => {
cineService.setCine({ id: viewportId, isPlaying: false });
eventTarget.removeEventListener(Enums.Events.STACK_VIEWPORT_NEW_STACK, newDisplaySetHandler);
enabledVPElement.removeEventListener(
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
newDisplaySetHandler
);
};
}, [enabledVPElement, newDisplaySetHandler, viewportId]);
useEffect(() => {
if (!cines || !cines[viewportId] || !enabledVPElement || !isMountedRef.current) {
@ -77,41 +112,118 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) {
cineHandler();
return () => {
api.stopClip(enabledVPElement);
cineService.stopClip(enabledVPElement);
};
}, [cines, viewportId, enabledVPElement, cineHandler]);
}, [cines, viewportId, cineService, enabledVPElement, cineHandler]);
if (!isCineEnabled) {
return null;
}
const cine = cines[viewportId];
const isPlaying = (cine && cine.isPlaying) || false;
const isPlaying = cine?.isPlaying || false;
return (
isCineEnabled && (
<CinePlayerComponent
className="absolute left-1/2 bottom-3 -translate-x-1/2"
frameRate={newStackFrameRate}
isPlaying={isPlaying}
onClose={() => {
// also stop the clip
api.setCine({
id: viewportId,
isPlaying: false,
});
api.setIsCineEnabled(false);
}}
onPlayPauseChange={isPlaying => {
api.setCine({
id: viewportId,
isPlaying,
});
}}
onFrameRateChange={frameRate =>
api.setCine({
id: viewportId,
frameRate,
})
}
/>
)
<RenderCinePlayer
viewportId={viewportId}
cineService={cineService}
newStackFrameRate={newStackFrameRate}
isPlaying={isPlaying}
dynamicInfo={dynamicInfo}
customizationService={customizationService}
/>
);
}
function RenderCinePlayer({
viewportId,
cineService,
newStackFrameRate,
isPlaying,
dynamicInfo: dynamicInfoProp,
customizationService,
}) {
const { component: CinePlayerComponent = CinePlayer } =
customizationService.get('cinePlayer') ?? {};
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);
useEffect(() => {
setDynamicInfo(dynamicInfoProp);
}, [dynamicInfoProp]);
/**
* Use effect for handling 4D time index changed
*/
useEffect(() => {
if (!dynamicInfo) {
return;
}
const handleTimePointIndexChange = evt => {
const { volumeId, timePointIndex, numTimePoints, splittingTag } = evt.detail;
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
};
eventTarget.addEventListener(
StreamingEnums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
return () => {
eventTarget.removeEventListener(
StreamingEnums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
};
}, [dynamicInfo]);
useEffect(() => {
if (!dynamicInfo) {
return;
}
const { volumeId, timePointIndex, numTimePoints, splittingTag } = dynamicInfo || {};
const volume = cache.getVolume(volumeId);
volume.timePointIndex = timePointIndex;
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
}, []);
const updateDynamicInfo = useCallback(props => {
const { volumeId, timePointIndex } = props;
const volume = cache.getVolume(volumeId);
volume.timePointIndex = timePointIndex;
}, []);
return (
<CinePlayerComponent
className="absolute left-1/2 bottom-3 -translate-x-1/2"
frameRate={newStackFrameRate}
isPlaying={isPlaying}
onClose={() => {
// also stop the clip
cineService.setCine({
id: viewportId,
isPlaying: false,
});
cineService.setIsCineEnabled(false);
}}
onPlayPauseChange={isPlaying => {
cineService.setCine({
id: viewportId,
isPlaying,
});
}}
onFrameRateChange={frameRate =>
cineService.setCine({
id: viewportId,
frameRate,
})
}
dynamicInfo={dynamicInfo}
updateDynamicInfo={updateDynamicInfo}
/>
);
}

View File

@ -0,0 +1,387 @@
import React, { useEffect, useCallback, useState, ReactElement } from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash.debounce';
import { ServicesManager } from '@ohif/core';
import { PanelSection, WindowLevel } from '@ohif/ui';
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
import { Enums, eventTarget, cache as cs3DCache, utilities as csUtils } from '@cornerstonejs/core';
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
const { Events } = Enums;
const ViewportWindowLevel = ({
servicesManager,
viewportId,
}: {
servicesManager: ServicesManager;
viewportId: string;
}): ReactElement => {
const { cornerstoneViewportService } = servicesManager.services;
const [windowLevels, setWindowLevels] = useState([]);
const [cachedHistograms, setCachedHistograms] = useState({});
/**
* Looks for all viewports that has exactly all volumeIds passed as parameter.
*/
const getViewportsWithVolumeIds = useCallback(
(volumeIds: string[]) => {
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const viewports = renderingEngine.getVolumeViewports();
return viewports.filter(vp => {
const viewportVolumeIds = vp.getActors().map(actor => actor.uid);
return (
volumeIds.length === viewportVolumeIds.length &&
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
);
});
},
[cornerstoneViewportService]
);
const getNodeOpacity = (volumeActor, nodeIndex) => {
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
const nodeValue = [];
volumeOpacity.getNodeValue(nodeIndex, nodeValue);
return nodeValue[1];
};
/**
* Checks if the opacity applied to the PET volume is something like
* [{x: 0, y: 0}, {x: 0.1, y: [C]}, {x: [ANY], y: [C]}] where C is a
* constant opacity value for all x's greater than 0.1
*/
const isPetVolumeWithDefaultOpacity = (volumeId, volumeActor) => {
const volume = cs3DCache.getVolume(volumeId);
if (!volume) {
return false;
}
const modality = volume.metadata.Modality;
if (modality !== 'PT') {
return false;
}
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
// It must have at least two points (0 and 0.1)
if (volumeOpacity.getSize() < 2) {
return false;
}
const node1Value = [];
const node2Value = [];
volumeOpacity.getNodeValue(0, node1Value);
volumeOpacity.getNodeValue(1, node2Value);
// First node must be (x:0, y:0} and the second one {x:0.1, y:any}
if (node1Value[0] !== 0 || node1Value[1] !== 0 || node2Value[0] !== 0.1) {
return false;
}
const expectedOpacity = node2Value[1];
const opacitySize = volumeOpacity.getSize();
const currentNodeValue = [];
// Any point after 0.1 must have the same opacity
for (let i = 2; i < opacitySize; i++) {
volumeOpacity.getNodeValue(i, currentNodeValue);
if (currentNodeValue[1] !== expectedOpacity) {
return false;
}
}
return true;
};
/**
* Checks if the opacity function has a constance opacity value for all x's
*/
const isVolumeWithConstantOpacity = volumeActor => {
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
const opacitySize = volumeOpacity.getSize();
const firstNodeValue = [];
volumeOpacity.getNodeValue(0, firstNodeValue);
const firstNodeOpacity = firstNodeValue[1];
for (let i = 0; i < opacitySize; i++) {
const currentNodeValue = [];
volumeOpacity.getNodeValue(0, currentNodeValue);
if (currentNodeValue[1] !== firstNodeOpacity) {
return false;
}
}
return true;
};
const getVolumeOpacity = useCallback((viewport, volumeId) => {
const volumeActor = viewport.getActor(volumeId).actor;
if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) {
// Get the opacity from the second node at 0.1
return getNodeOpacity(volumeActor, 1);
} else if (isVolumeWithConstantOpacity(volumeActor)) {
return getNodeOpacity(volumeActor, 0);
}
return undefined;
}, []);
const getWindowLevelsData = useCallback(
async (viewportId: number) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return [];
}
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
const volumeIds = viewport.getActors().map(actor => actor.uid);
const viewportProperties = viewport.getProperties();
const { voiRange } = viewportProperties;
const viewportVoi = voiRange
? {
windowWidth: voiRange.upper - voiRange.lower,
windowCenter: voiRange.lower + (voiRange.upper - voiRange.lower) / 2,
}
: undefined;
const windowLevels = await Promise.all(
volumeIds.map(async (volumeId, volumeIndex) => {
const volume = cs3DCache.getVolume(volumeId);
if (!volume) {
return null;
}
const opacity = getVolumeOpacity(viewport, volumeId);
const { metadata, scaling } = volume;
const modality = metadata.Modality;
// TODO: find a proper way to fix the histogram
const options = {
min: modality === 'PT' ? 0.1 : -999,
max: modality === 'PT' ? 5 : 10000,
};
const histogram =
cachedHistograms[volumeId] ??
(await getViewportVolumeHistogram(viewport, volume, options));
const { voi: displaySetVOI, colormap: displaySetColormap } =
viewportInfo.displaySetOptions[volumeIndex];
let colormap;
if (displaySetColormap) {
colormap =
csUtils.colormap.getColormap(displaySetColormap.name) ??
vtkColorMaps.getPresetByName(displaySetColormap.name);
}
const voi = !volumeIndex ? viewportVoi ?? displaySetVOI : displaySetVOI;
return {
viewportId,
modality,
volumeId,
volumeIndex,
voi,
histogram,
colormap,
step: scaling?.PT ? 0.05 : 1,
opacity,
showOpacitySlider: volumeIndex === 1 && opacity !== undefined,
};
})
);
const data = windowLevels.filter(Boolean);
return data;
},
[cachedHistograms, cornerstoneViewportService, getVolumeOpacity]
);
const updateViewportHistograms = useCallback(() => {
getWindowLevelsData(viewportId).then(windowLevels => {
setWindowLevels(windowLevels);
});
}, [viewportId, getWindowLevelsData]);
const handleCornerstoneVOIModified = useCallback(
e => {
const { detail } = e;
const { volumeId, range } = detail;
const oldWindowLevel = windowLevels.find(wl => wl.volumeId === volumeId);
if (!oldWindowLevel) {
return;
}
const oldVOI = oldWindowLevel.voi;
const windowWidth = range.upper - range.lower;
const windowCenter = range.lower + windowWidth / 2;
if (windowWidth === oldVOI.windowWidth && windowCenter === oldVOI.windowCenter) {
return;
}
const newWindowLevel = {
...oldWindowLevel,
voi: {
windowWidth,
windowCenter,
},
};
setWindowLevels(
windowLevels.map(windowLevel =>
windowLevel === oldWindowLevel ? newWindowLevel : windowLevel
)
);
},
[windowLevels]
);
const debouncedHandleCornerstoneVOIModified = useCallback(
debounce(handleCornerstoneVOIModified, 100),
[handleCornerstoneVOIModified]
);
const handleVOIChange = useCallback(
(volumeId, voi) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const newRange = {
lower: voi.windowCenter - voi.windowWidth / 2,
upper: voi.windowCenter + voi.windowWidth / 2,
};
viewport.setProperties({ voiRange: newRange }, volumeId);
viewport.render();
},
[cornerstoneViewportService, viewportId]
);
const handleOpacityChange = useCallback(
(viewportId, _volumeIndex, volumeId, opacity) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
const viewportVolumeIds = viewport.getActors().map(actor => actor.uid);
const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
viewports.forEach(vp => {
vp.setProperties({ colormap: { opacity } }, volumeId);
vp.render();
});
},
[getViewportsWithVolumeIds, cornerstoneViewportService]
);
// Listen to windowLevels changes and caches all the new ones
useEffect(() => {
const newVolumeHistograms = windowLevels
.filter(windowLevel => !cachedHistograms[windowLevel.volumeId] && windowLevel.histogram)
.reduce((volumeHistograms, windowLevel) => {
// If the histogram exists, add it to the volumeHistograms object
if (windowLevel?.histogram) {
volumeHistograms[windowLevel.volumeId] = windowLevel.histogram;
}
return volumeHistograms;
}, {});
if (Object.keys(newVolumeHistograms).length) {
setCachedHistograms(prev => ({ ...prev, ...newVolumeHistograms }));
}
}, [windowLevels, cachedHistograms]);
// Updates the histogram when the viewport index prop has changed
useEffect(() => updateViewportHistograms(), [viewportId, updateViewportHistograms]);
// Listen to cornerstone events on "eventTarget" and at the document level
useEffect(() => {
eventTarget.addEventListener(Events.IMAGE_VOLUME_LOADING_COMPLETED, updateViewportHistograms);
document.addEventListener(Events.VOI_MODIFIED, debouncedHandleCornerstoneVOIModified, true);
return () => {
eventTarget.removeEventListener(
Events.IMAGE_VOLUME_LOADING_COMPLETED,
updateViewportHistograms
);
document.removeEventListener(
Events.VOI_MODIFIED,
debouncedHandleCornerstoneVOIModified,
true
);
};
}, [updateViewportHistograms, debouncedHandleCornerstoneVOIModified]);
// Updates the viewport when the context of the viewport has changed. This is
// necessary when moving across different stages because the viewport index
// may not change but the volumes loaded on it may change.
useEffect(() => {
const { unsubscribe } = cornerstoneViewportService.subscribe(
cornerstoneViewportService.EVENTS.VIEWPORT_VOLUMES_CHANGED,
({ viewportInfo }) => {
if (viewportInfo.viewportId === viewportId) {
updateViewportHistograms();
}
}
);
return () => {
unsubscribe();
};
}, [viewportId, cornerstoneViewportService, updateViewportHistograms]);
return (
<PanelSection title="Window Level">
{windowLevels.map((windowLevel, i) => {
if (!windowLevel.histogram) {
return null;
}
return (
<WindowLevel
key={windowLevel.volumeId}
title={`${windowLevel.modality}`}
histogram={windowLevel.histogram}
voi={windowLevel.voi}
step={windowLevel.step}
showOpacitySlider={windowLevel.showOpacitySlider}
colormap={windowLevel.colormap}
onVOIChange={voi => handleVOIChange(windowLevel.volumeId, voi)}
opacity={windowLevel.opacity}
onOpacityChange={opacity =>
handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity)
}
/>
);
})}
</PanelSection>
);
};
ViewportWindowLevel.propTypes = {
servicesManager: PropTypes.instanceOf(ServicesManager),
viewportId: PropTypes.string.isRequired,
};
export default ViewportWindowLevel;

View File

@ -0,0 +1,70 @@
import { getWebWorkerManager } from '@cornerstonejs/core';
const workerManager = getWebWorkerManager();
const options = {
// maxWorkerInstances: 1,
// overwrite: false
autoTerminationOnIdle: 1000,
};
// Register the task
const workerFn = () => {
return new Worker(new URL('./histogramWorker.js', import.meta.url), {
name: 'histogram-worker', // name used by the browser to name the worker
});
};
workerManager.registerWorker('histogram-worker', workerFn, options);
const getViewportVolumeHistogram = async (viewport, volume, options?) => {
if (!volume?.loadStatus.loaded) {
return undefined;
}
const volumeImageData = viewport.getImageData(volume.volumeId);
if (!volumeImageData) {
return undefined;
}
let scalarData = volume.scalarData;
let prevTimePoint;
if (volume.numTimePoints > 1) {
prevTimePoint = volume.timePointIndex;
const middleTimePoint = Math.round(volume.numTimePoints / 2);
volume.timePointIndex = middleTimePoint;
scalarData = volume.getScalarData(middleTimePoint);
}
const { dimensions, origin, direction, spacing } = volume;
const range = await workerManager.executeTask('histogram-worker', 'getRange', {
dimensions,
origin,
direction,
spacing,
scalarData,
});
// after we calculate the range let's reset the timePointIndex
if (volume.numTimePoints > 1) {
volume.timePointIndex = prevTimePoint;
}
const { minimum: min, maximum: max } = range;
const calcHistOptions = {
numBins: 256,
min: Math.max(min, options?.min ?? min),
max: Math.min(max, options?.max ?? max),
};
const histogram = await workerManager.executeTask('histogram-worker', 'calcHistogram', {
data: scalarData,
options: calcHistOptions,
});
return histogram;
};
export { getViewportVolumeHistogram };

View File

@ -0,0 +1,97 @@
import { expose } from 'comlink';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
/**
* This object simulates a heavy task by implementing a sleep function and a recursive Fibonacci function.
* It's used for testing or demonstrating purposes where a heavy or time-consuming task is needed.
*/
const obj = {
getRange: ({ dimensions, origin, direction, spacing, scalarData }) => {
const imageData = vtkImageData.newInstance();
imageData.setDimensions(dimensions);
imageData.setOrigin(origin);
imageData.setDirection(direction);
imageData.setSpacing(spacing);
const scalarArray = vtkDataArray.newInstance({
name: 'Pixels',
numberOfComponents: 1,
values: scalarData,
});
imageData.getPointData().setScalars(scalarArray);
imageData.modified();
const range = imageData.computeHistogram(imageData.getBounds());
return range;
},
calcHistogram: ({ data, options }) => {
if (options === undefined) {
options = {};
}
const histogram = {
numBins: options.numBins || 256,
range: { min: 0, max: 0 },
bins: new Int32Array(1),
maxBin: 0,
maxBinValue: 0,
};
let minToUse = options.min;
let maxToUse = options.max;
if (minToUse === undefined || maxToUse === undefined) {
let min = Infinity;
let max = -Infinity;
let index = data.length;
while (index--) {
const value = data[index];
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
}
minToUse = min;
maxToUse = max;
}
histogram.range = { min: minToUse, max: maxToUse };
const bins = new Int32Array(histogram.numBins);
const binScale = histogram.numBins / (maxToUse - minToUse);
for (let index = 0; index < data.length; index++) {
const value = data[index];
if (value < minToUse) {
continue;
}
if (value > maxToUse) {
continue;
}
const bin = Math.floor((value - minToUse) * binScale);
bins[bin] += 1;
}
histogram.bins = bins;
histogram.maxBin = 0;
histogram.maxBinValue = 0;
for (let bin = 0; bin < histogram.numBins; bin++) {
if (histogram.bins[bin] > histogram.maxBinValue) {
histogram.maxBin = bin;
histogram.maxBinValue = histogram.bins[bin];
}
}
return histogram;
},
};
expose(obj);

View File

@ -0,0 +1 @@
export { default } from './ViewportWindowLevel';

View File

@ -192,7 +192,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
}
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
const isUS = displaySets.some(displaySet => displaySet.Modality === 'US');
const isUS = displaySets.some(displaySet => displaySet?.Modality === 'US');
if (!isUS) {
return {
disabled: true,

View File

@ -6,6 +6,7 @@ import {
imageLoadPoolManager,
imageRetrievalPoolManager,
} from '@cornerstonejs/core';
import * as csStreamingImageVolumeLoader from '@cornerstonejs/streaming-image-volume-loader';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { ServicesManager, Types } from '@ohif/core';
@ -34,6 +35,10 @@ import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
import { showLabelAnnotationPopup } from './utils/callInputDialog';
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
import { ViewportActionCornersProvider } from './contextProviders/ViewportActionCornersProvider';
import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel';
const { helpers: volumeLoaderHelpers } = csStreamingImageVolumeLoader;
const { getDynamicVolumeInfo } = volumeLoaderHelpers ?? {};
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './Viewport/OHIFCornerstoneViewport');
@ -90,6 +95,16 @@ const cornerstoneExtension: Types.Extensions.Extension = {
},
getToolbarModule,
getPanelModule({ servicesManager }) {
return [
{
name: 'activeViewportWindowLevel',
component: () => {
return <ActiveViewportWindowLevel servicesManager={servicesManager} />;
},
},
];
},
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
@ -127,7 +142,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
},
getEnabledElement,
dicomLoaderService,
showLabelAnnotationPopup
showLabelAnnotationPopup,
},
},
{
@ -143,6 +158,12 @@ const cornerstoneExtension: Types.Extensions.Extension = {
Enums: cs3DToolsEnums,
},
},
{
name: 'volumeLoader',
exports: {
getDynamicVolumeInfo,
},
},
];
},
};

View File

@ -15,7 +15,11 @@ import {
utilities as csUtilities,
Enums as csEnums,
} from '@cornerstonejs/core';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader';
import { utilities } from '@cornerstonejs/tools';
import {
cornerstoneStreamingImageVolumeLoader,
cornerstoneStreamingDynamicImageVolumeLoader,
} from '@cornerstonejs/streaming-image-volume-loader';
import initWADOImageLoader from './initWADOImageLoader';
import initCornerstoneTools from './initCornerstoneTools';
@ -29,10 +33,9 @@ import initContextMenu from './initContextMenu';
import initDoubleClick from './initDoubleClick';
import { CornerstoneServices } from './types';
import initViewTiming from './utils/initViewTiming';
import { utilities } from '@cornerstonejs/core';
import { colormaps } from './utils/colormaps';
const { registerColormap } = utilities.colormap;
const { registerColormap } = csUtilities.colormap;
// TODO: Cypress tests are currently grabbing this from the window?
window.cornerstone = cornerstone;
@ -93,7 +96,6 @@ export default async function init({
customizationService,
uiModalService,
uiNotificationService,
cineService,
cornerstoneViewportService,
hangingProtocolService,
toolbarService,
@ -172,6 +174,11 @@ export default async function init({
cornerstoneStreamingImageVolumeLoader
);
volumeLoader.registerVolumeLoader(
'cornerstoneStreamingDynamicImageVolume',
cornerstoneStreamingDynamicImageVolumeLoader
);
hangingProtocolService.registerImageLoadStrategy('interleaveCenter', interleaveCenterLoader);
hangingProtocolService.registerImageLoadStrategy('interleaveTopToBottom', interleaveTopToBottom);
hangingProtocolService.registerImageLoadStrategy('nth', nthLoader);
@ -195,7 +202,7 @@ export default async function init({
/* Measurement Service */
this.measurementServiceSource = connectToolsToMeasurementService(servicesManager);
initCineService(cineService);
initCineService(servicesManager);
// When a custom image load is performed, update the relevant viewports
hangingProtocolService.subscribe(

View File

@ -1,6 +1,60 @@
import { cache } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
function initCineService(cineService) {
function _getVolumesFromViewport(viewport) {
return viewport ? viewport.getActors().map(actor => cache.getVolume(actor.uid)) : [];
}
function _getVolumeFromViewport(viewport) {
const volumes = _getVolumesFromViewport(viewport);
const dynamicVolume = volumes.find(volume => volume.isDynamicVolume());
return dynamicVolume ?? volumes[0];
}
/**
* Return all viewports that needs to be synchronized with the source
* viewport passed as parameter when cine is updated.
* @param servicesManager ServiceManager
* @param srcViewportIndex Source viewport index
* @returns array with viewport information.
*/
function _getSyncedViewports(servicesManager, srcViewportId) {
const { viewportGridService, cornerstoneViewportService } = servicesManager.services;
const { viewports: viewportsStates } = viewportGridService.getState();
const srcViewportState = viewportsStates.get(srcViewportId);
if (srcViewportState?.viewportOptions?.viewportType !== 'volume') {
return [];
}
const srcViewport = cornerstoneViewportService.getCornerstoneViewport(srcViewportId);
const srcVolume = srcViewport ? _getVolumeFromViewport(srcViewport) : null;
if (!srcVolume?.isDynamicVolume()) {
return [];
}
const { volumeId: srcVolumeId } = srcVolume;
return Array.from(viewportsStates.values())
.filter(({ viewportId }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
return viewportId !== srcViewportId && viewport?.hasVolumeId(srcVolumeId);
})
.map(({ viewportId }) => ({ viewportId }));
}
function initCineService(servicesManager) {
const { cineService } = servicesManager.services;
const getSyncedViewports = viewportId => {
return _getSyncedViewports(servicesManager, viewportId);
};
const playClip = (element, playClipOptions) => {
return utilities.cine.playClip(element, playClipOptions);
};
@ -9,7 +63,11 @@ function initCineService(cineService) {
return utilities.cine.stopClip(element);
};
cineService.setServiceImplementation({ playClip, stopClip });
cineService.setServiceImplementation({
getSyncedViewports,
playClip,
stopClip,
});
}
export default initCineService;

View File

@ -8,6 +8,7 @@ import {
MIPJumpToClickTool,
LengthTool,
RectangleROITool,
RectangleROIThresholdTool,
EllipticalROITool,
CircleROITool,
BidirectionalTool,
@ -19,14 +20,16 @@ import {
MagnifyTool,
CrosshairsTool,
SegmentationDisplayTool,
RectangleScissorsTool,
SphereScissorsTool,
CircleScissorsTool,
BrushTool,
PaintFillTool,
init,
addTool,
annotation,
ReferenceLinesTool,
TrackballRotateTool,
CircleScissorsTool,
RectangleScissorsTool,
SphereScissorsTool,
AdvancedMagnifyTool,
UltrasoundDirectionalTool,
PlanarFreehandROITool,
@ -52,6 +55,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(MIPJumpToClickTool);
addTool(LengthTool);
addTool(RectangleROITool);
addTool(RectangleROIThresholdTool);
addTool(EllipticalROITool);
addTool(CircleROITool);
addTool(BidirectionalTool);
@ -62,12 +66,14 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(MagnifyTool);
addTool(CrosshairsTool);
addTool(SegmentationDisplayTool);
addTool(RectangleScissorsTool);
addTool(SphereScissorsTool);
addTool(CircleScissorsTool);
addTool(BrushTool);
addTool(PaintFillTool);
addTool(ReferenceLinesTool);
addTool(CalibrationLineTool);
addTool(TrackballRotateTool);
addTool(CircleScissorsTool);
addTool(RectangleScissorsTool);
addTool(SphereScissorsTool);
addTool(ImageOverlayViewerTool);
addTool(AdvancedMagnifyTool);
addTool(UltrasoundDirectionalTool);
@ -103,6 +109,7 @@ const toolNames = {
DragProbe: DragProbeTool.toolName,
Probe: ProbeTool.toolName,
RectangleROI: RectangleROITool.toolName,
RectangleROIThreshold: RectangleROIThresholdTool.toolName,
EllipticalROI: EllipticalROITool.toolName,
CircleROI: CircleROITool.toolName,
Bidirectional: BidirectionalTool.toolName,
@ -111,6 +118,8 @@ const toolNames = {
Magnify: MagnifyTool.toolName,
Crosshairs: CrosshairsTool.toolName,
SegmentationDisplay: SegmentationDisplayTool.toolName,
Brush: BrushTool.toolName,
PaintFill: PaintFillTool.toolName,
ReferenceLines: ReferenceLinesTool.toolName,
CalibrationLine: CalibrationLineTool.toolName,
TrackballRotateTool: TrackballRotateTool.toolName,

View File

@ -49,6 +49,20 @@ const initMeasurementService = (
Length.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Crosshairs',
Length.matchingCriteria,
() => {
console.warn('Crosshairs mapping not implemented.');
return {};
},
() => {
console.warn('Crosshairs mapping not implemented.');
return {};
}
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'Bidirectional',

View File

@ -1,6 +1,9 @@
import * as cornerstone from '@cornerstonejs/core';
import { volumeLoader } from '@cornerstonejs/core';
import { cornerstoneStreamingImageVolumeLoader } from '@cornerstonejs/streaming-image-volume-loader';
import {
cornerstoneStreamingImageVolumeLoader,
cornerstoneStreamingDynamicImageVolumeLoader,
} from '@cornerstonejs/streaming-image-volume-loader';
import dicomImageLoader, { webWorkerManager } from '@cornerstonejs/dicom-image-loader';
import dicomParser from 'dicom-parser';
import { errorHandler, utils } from '@ohif/core';
@ -41,6 +44,11 @@ export default function initWADOImageLoader(
registerVolumeLoader('cornerstoneStreamingImageVolume', cornerstoneStreamingImageVolumeLoader);
registerVolumeLoader(
'cornerstoneStreamingDynamicImageVolume',
cornerstoneStreamingDynamicImageVolumeLoader
);
dicomImageLoader.configure({
decodeConfig: {
// !! IMPORTANT !!

View File

@ -200,6 +200,7 @@ class CornerstoneCacheService {
volume,
volumeId,
imageIds: volumeImageIds,
isDynamicVolume: displaySet.isDynamicVolume,
});
}
@ -228,7 +229,7 @@ class CornerstoneCacheService {
const shouldDisplaySeg = segmentationService.shouldRenderSegmentation(
viewportDisplaySetInstanceUIDs,
instance.FrameOfReferenceUID
instance?.FrameOfReferenceUID || segDisplaySet.FrameOfReferenceUID
);
if (shouldDisplaySeg) {

View File

@ -1289,7 +1289,7 @@ class SegmentationService extends PubSubService {
}
const { colorLUTIndex } = segmentation;
this._removeSegmentationFromCornerstone(segmentationId);
const { updatedToolGroupIds } = this._removeSegmentationFromCornerstone(segmentationId);
// Delete associated colormap
// Todo: bring this back
@ -1309,7 +1309,9 @@ class SegmentationService extends PubSubService {
if (remainingHydratedSegmentations.length) {
const { id } = remainingHydratedSegmentations[0];
this._setActiveSegmentationForToolGroup(id, this._getApplicableToolGroupId(), false);
updatedToolGroupIds.forEach(toolGroupId => {
this._setActiveSegmentationForToolGroup(id, toolGroupId, false);
});
}
}
@ -1966,6 +1968,7 @@ class SegmentationService extends PubSubService {
const removeFromCache = true;
const segmentationState = cstSegmentation.state;
const sourceSegState = segmentationState.getSegmentation(segmentationId);
const updatedToolGroupIds: Set<string> = new Set();
if (!sourceSegState) {
return;
@ -1981,6 +1984,7 @@ class SegmentationService extends PubSubService {
segmentationRepresentations.forEach(representation => {
if (representation.segmentationId === segmentationId) {
UIDsToRemove.push(representation.segmentationRepresentationUID);
updatedToolGroupIds.add(toolGroupId);
}
});
@ -1998,6 +2002,8 @@ class SegmentationService extends PubSubService {
if (removeFromCache && cache.getVolumeLoadObject(segmentationId)) {
cache.removeVolumeLoadObject(segmentationId);
}
return { updatedToolGroupIds: Array.from(updatedToolGroupIds) };
}
private _updateCornerstoneSegmentations({ segmentationId, notYetUpdatedAtSource }) {

View File

@ -1,4 +1,5 @@
import { ToolGroupManager, Enums, Types } from '@cornerstonejs/tools';
import { eventTarget } from '@cornerstonejs/core';
import { Types as OhifTypes, pubSubServiceInterface } from '@ohif/core';
import getActiveViewportEnabledElement from '../../utils/getActiveViewportEnabledElement';
@ -6,6 +7,8 @@ import getActiveViewportEnabledElement from '../../utils/getActiveViewportEnable
const EVENTS = {
VIEWPORT_ADDED: 'event::cornerstone::toolgroupservice:viewportadded',
TOOLGROUP_CREATED: 'event::cornerstone::toolgroupservice:toolgroupcreated',
TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:toolactivated',
PRIMARY_TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:primarytoolactivated',
};
type Tool = {
@ -38,18 +41,26 @@ export default class ToolGroupService {
EVENTS: { [key: string]: string };
constructor(serviceManager) {
const { cornerstoneViewportService, viewportGridService } = serviceManager.services;
const { cornerstoneViewportService, viewportGridService, uiNotificationService } =
serviceManager.services;
this.cornerstoneViewportService = cornerstoneViewportService;
this.viewportGridService = viewportGridService;
this.uiNotificationService = uiNotificationService;
this.listeners = {};
this.EVENTS = EVENTS;
Object.assign(this, pubSubServiceInterface);
this._init();
}
onModeExit() {
this.destroy();
}
private _init() {
eventTarget.addEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
}
/**
* Retrieves a tool group from the ToolGroupManager by tool group ID.
* If no tool group ID is provided, it retrieves the tool group of the active viewport.
@ -105,12 +116,14 @@ export default class ToolGroupService {
return toolGroup.getActivePrimaryMouseButtonTool();
}
public destroy() {
public destroy(): void {
ToolGroupManager.destroy();
this.toolGroupIds = new Set();
eventTarget.removeEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
}
public destroyToolGroup(toolGroupId: string) {
public destroyToolGroup(toolGroupId: string): void {
ToolGroupManager.destroyToolGroup(toolGroupId);
this.toolGroupIds.delete(toolGroupId);
}
@ -222,6 +235,10 @@ export default class ToolGroupService {
toolInstance.configuration = config;
}
public getActivePrimaryMouseButtonTool(toolGroupId?: string): string {
return this.getToolGroup(toolGroupId)?.getActivePrimaryMouseButtonTool();
}
private _setToolsMode(toolGroup, tools) {
const { active, passive, enabled, disabled } = tools;
@ -279,4 +296,23 @@ export default class ToolGroupService {
addTools(tools.disabled);
}
}
private _onToolActivated = (evt: Types.EventTypes.ToolActivatedEventType) => {
const { toolGroupId, toolName, toolBindingsOptions } = evt.detail;
const isPrimaryTool = toolBindingsOptions.bindings?.some(
binding => binding.mouseButton === Enums.MouseBindings.Primary
);
const callbackProps = {
toolGroupId,
toolName,
toolBindingsOptions,
};
this._broadcastEvent(EVENTS.TOOL_ACTIVATED, callbackProps);
if (isPrimaryTool) {
this._broadcastEvent(EVENTS.PRIMARY_TOOL_ACTIVATED, callbackProps);
}
};
}

View File

@ -24,6 +24,7 @@ import JumpPresets from '../../utils/JumpPresets';
const EVENTS = {
VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged',
VIEWPORT_VOLUMES_CHANGED: 'event::cornerstoneViewportService:viewportVolumesChanged',
};
/**
@ -793,6 +794,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
viewport.render();
this._broadcastEvent(this.EVENTS.VIEWPORT_VOLUMES_CHANGED, {
viewportInfo,
});
}
private _addSegmentationRepresentationToToolGroupIfNecessary(

View File

@ -37,6 +37,31 @@ export default function interleaveTopToBottom({
}
}
const filteredMatchDetails = [];
const displaySetsToLoad = new Set();
// Check all viewports that have a displaySet to be loaded. In some cases
// (eg: line chart viewports which is not a Cornerstone viewport) the
// displaySet is created on the client and there are no instances to be
// downloaded. For those viewports the displaySet may have the `skipLoading`
// option set to true otherwise it may block the download of all other
// instances resulting in blank viewports.
Array.from(matchDetails.values()).forEach(curMatchDetails => {
const { displaySetsInfo } = curMatchDetails;
let numDisplaySetsToLoad = 0;
displaySetsInfo.forEach(({ displaySetInstanceUID, displaySetOptions }) => {
if (!displaySetOptions?.options?.skipLoading) {
numDisplaySetsToLoad++;
displaySetsToLoad.add(displaySetInstanceUID);
}
});
if (numDisplaySetsToLoad) {
filteredMatchDetails.push(curMatchDetails);
}
});
/**
* The following is checking if all the viewports that were matched in the HP has been
* successfully created their cornerstone viewport or not. Todo: This can be
@ -50,16 +75,19 @@ export default function interleaveTopToBottom({
* listen to it and as the other viewports are created we can set the volumes for them
* since volumes are already started loading.
*/
if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) {
if (filteredMatchDetails.length !== viewportIdVolumeInputArrayMap.size) {
return;
}
// Check if all the matched volumes are loaded
for (const [_, details] of displaySetsMatchDetails.entries()) {
const { SeriesInstanceUID } = details;
const { SeriesInstanceUID, displaySetInstanceUID } = details;
// HangingProtocol has matched, but don't have all the volumes created yet, so return
if (!Array.from(volumeIdMapsToLoad.values()).includes(SeriesInstanceUID)) {
if (
displaySetsToLoad.has(displaySetInstanceUID) &&
!Array.from(volumeIdMapsToLoad.values()).includes(SeriesInstanceUID)
) {
return;
}
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import { LineChart } from '@ohif/ui';
const LineChartViewport = ({ displaySets }) => {
const displaySet = displaySets[0];
const { axis: chartAxis, series: chartSeries } = displaySet.instance.chartData;
return (
<LineChart
showLegend={true}
legendWidth={150}
axis={{
x: {
label: chartAxis.x.label,
indexRef: 0,
type: 'x',
range: {
min: 0,
},
},
y: {
label: chartAxis.y.label,
indexRef: 1,
type: 'y',
},
}}
series={chartSeries}
/>
);
};
export { LineChartViewport as default };

View File

@ -0,0 +1 @@
export { default } from './LineChartViewport';

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { SidePanel } from '@ohif/ui';
import { PanelService, ServicesManager } from '@ohif/core';
import { PanelService, ServicesManager, Types } from '@ohif/core';
export type SidePanelWithServicesProps = {
servicesManager: ServicesManager;
@ -14,10 +14,10 @@ export type SidePanelWithServicesProps = {
const SidePanelWithServices = ({
servicesManager,
side,
className,
activeTabIndex: activeTabIndexProp,
tabs,
tabs: tabsProp,
expandedWidth,
...props
}: SidePanelWithServicesProps) => {
const panelService: PanelService = servicesManager?.services?.panelService;
@ -25,36 +25,64 @@ const SidePanelWithServices = ({
// Thus going to the Study List page and back to the viewer resets this flag for a SidePanel.
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const handleSidePanelOpen = useCallback(() => {
setHasBeenOpened(true);
}, []);
const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => {
setActiveTabIndex(activeTabIndex);
}, []);
/** update the active tab index from outside */
useEffect(() => {
setActiveTabIndex(activeTabIndexProp);
}, [activeTabIndexProp]);
useEffect(() => {
if (panelService) {
const activatePanelSubscription = panelService.subscribe(
panelService.EVENTS.ACTIVATE_PANEL,
(activatePanelEvent: Types.ActivatePanelEvent) => {
if (!hasBeenOpened || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId);
if (tabIndex !== -1) {
setActiveTabIndex(tabIndex);
}
const { unsubscribe } = panelService.subscribe(
panelService.EVENTS.PANELS_CHANGED,
panelChangedEvent => {
if (panelChangedEvent.position !== side) {
return;
}
setTabs(panelService.getPanels(side));
}
);
return () => {
unsubscribe();
};
}, [panelService, side]);
useEffect(() => {
const activatePanelSubscription = panelService.subscribe(
panelService.EVENTS.ACTIVATE_PANEL,
(activatePanelEvent: Types.ActivatePanelEvent) => {
if (!hasBeenOpened || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId);
if (tabIndex !== -1) {
setActiveTabIndex(tabIndex);
}
}
);
}
);
return () => {
activatePanelSubscription.unsubscribe();
};
}
return () => {
activatePanelSubscription.unsubscribe();
};
}, [tabs, hasBeenOpened, panelService]);
return (
<SidePanel
{...props}
side={side}
className={className}
activeTabIndex={activeTabIndex}
tabs={tabs}
onOpen={() => {
setHasBeenOpened(true);
}}
activeTabIndex={activeTabIndex}
onOpen={handleSidePanelOpen}
onActiveTabIndexChange={handleActiveTabIndexChange}
expandedWidth={expandedWidth}
></SidePanel>
);

View File

@ -1,44 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { LegacyButton, LegacyButtonGroup } from '@ohif/ui';
function ActionButtons({ onExportClick, onCreateReportClick }) {
const { t } = useTranslation('MeasurementTable');
return (
<React.Fragment>
<LegacyButtonGroup
color="black"
size="inherit"
>
{/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/}
<LegacyButton
className="px-2 py-2 text-base"
onClick={onExportClick}
>
{t('Export CSV')}
</LegacyButton>
<LegacyButton
className="px-2 py-2 text-base"
onClick={onCreateReportClick}
>
{t('Create Report')}
</LegacyButton>
</LegacyButtonGroup>
</React.Fragment>
);
}
ActionButtons.propTypes = {
onExportClick: PropTypes.func,
onCreateReportClick: PropTypes.func,
};
ActionButtons.defaultProps = {
onExportClick: () => alert('Export'),
onCreateReportClick: () => alert('Create Report'),
};
export default ActionButtons;

View File

@ -2,8 +2,14 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { utils, ServicesManager } from '@ohif/core';
import { MeasurementTable, Dialog, Input, useViewportGrid, ButtonEnums } from '@ohif/ui';
import ActionButtons from './ActionButtons';
import {
MeasurementTable,
Dialog,
Input,
useViewportGrid,
ButtonEnums,
ActionButtons,
} from '@ohif/ui';
import debounce from 'lodash.debounce';
import createReportDialogPrompt, {
@ -212,7 +218,7 @@ export default function PanelMeasurementTable({
data-cy={'measurements-panel'}
>
<MeasurementTable
title={t("Measurements")}
title={t('Measurements')}
servicesManager={servicesManager}
data={displayMeasurements}
onClick={jumpToImage}
@ -221,9 +227,17 @@ export default function PanelMeasurementTable({
</div>
<div className="flex justify-center p-4">
<ActionButtons
onExportClick={exportReport}
onClearMeasurementsClick={clearMeasurements}
onCreateReportClick={createReport}
t={t('MeasurementTable')}
actions={[
{
label: 'Export',
onClick: exportReport,
},
{
label: 'Create Report',
onClick: createReport,
},
]}
/>
</div>
</>

View File

@ -0,0 +1,97 @@
import { Types, DisplaySetService, utils } from '@ohif/core';
import { id } from '../id';
type InstanceMetadata = Types.InstanceMetadata;
const SOPClassHandlerName = 'chart';
const CHART_MODALITY = 'CHT';
// Private SOPClassUid for chart data
const ChartDataSOPClassUid = '1.9.451.13215.7.3.2.7.6.1';
const sopClassUids = [ChartDataSOPClassUid];
const makeChartDataDisplaySet = (instance, sopClassUids) => {
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
} = instance;
return {
Modality: CHART_MODALITY,
loading: false,
isReconstructable: false,
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId: `${id}.sopClassHandlerModule.${SOPClassHandlerName}`,
SOPClassUID,
isDerivedDisplaySet: true,
isLoaded: true,
sopClassUids,
instance,
instances: [instance],
/**
* Adds instances to the chart displaySet, rather than creating a new one
* when user moves to a different workflow step and gets back to a step that
* recreates the chart
*/
addInstances: function (instances: InstanceMetadata[], _displaySetService: DisplaySetService) {
this.instances.push(...instances);
this.instance = this.instances[this.instances.length - 1];
return this;
},
};
};
function getSopClassUids(instances) {
const uniqueSopClassUidsInSeries = new Set();
instances.forEach(instance => {
uniqueSopClassUidsInSeries.add(instance.SOPClassUID);
});
const sopClassUids = Array.from(uniqueSopClassUidsInSeries);
return sopClassUids;
}
function _getDisplaySetsFromSeries(instances) {
debugger;
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const sopClassUids = getSopClassUids(instances);
const displaySets = instances.map(instance => {
if (instance.Modality === CHART_MODALITY) {
return makeChartDataDisplaySet(instance, sopClassUids);
}
throw new Error('Unsupported modality');
});
return displaySets;
}
const chartHandler = {
name: SOPClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries: instances => {
return _getDisplaySetsFromSeries(instances);
},
};
export { chartHandler };

View File

@ -3,10 +3,10 @@ import { Tooltip } from '@ohif/ui';
import classnames from 'classnames';
import { useToolbar } from '@ohif/core';
export function Toolbar({ servicesManager }) {
export function Toolbar({ servicesManager, buttonSection = 'primary' }) {
const { toolbarButtons, onInteraction } = useToolbar({
servicesManager,
buttonSection: 'primary',
buttonSection,
});
if (!toolbarButtons.length) {

View File

@ -6,13 +6,11 @@ import { useLocation } from 'react-router';
import { ErrorBoundary, UserPreferences, AboutModal, Header, useModal } from '@ohif/ui';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { useAppConfig } from '@state';
import { Toolbar } from '../Toolbar/Toolbar';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
function ViewerHeader({ hotkeysManager, extensionManager, servicesManager }) {
const [appConfig] = useAppConfig();
function ViewerHeader({ hotkeysManager, extensionManager, servicesManager, appConfig }) {
const navigate = useNavigate();
const location = useLocation();
@ -107,6 +105,13 @@ function ViewerHeader({ hotkeysManager, extensionManager, servicesManager }) {
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={appConfig.showPatientInfo}
servicesManager={servicesManager}
Secondary={
<Toolbar
servicesManager={servicesManager}
buttonSection="secondary"
/>
}
appConfig={appConfig}
>
<ErrorBoundary context="Primary Toolbar">
<div className="relative flex justify-center">

View File

@ -1,12 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
SidePanel,
ErrorBoundary,
LoadingIndicatorProgress,
InvestigationalUseDialog,
} from '@ohif/ui';
import { ErrorBoundary, LoadingIndicatorProgress, InvestigationalUseDialog } from '@ohif/ui';
import { ServicesManager, HangingProtocolService, CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import ViewerHeader from './ViewerHeader';
@ -21,16 +16,24 @@ function ViewerLayout({
// From Modes
viewports,
ViewportGridComp,
leftPanels = [],
rightPanels = [],
leftPanelDefaultClosed = false,
rightPanelDefaultClosed = false,
leftPanelClosed = false,
rightPanelClosed = false,
}): React.FunctionComponent {
const [appConfig] = useAppConfig();
const { hangingProtocolService } = servicesManager.services;
const { panelService, hangingProtocolService } = servicesManager.services;
const [showLoadingIndicator, setShowLoadingIndicator] = useState(appConfig.showLoadingIndicator);
const hasPanels = useCallback(
(side): boolean => !!panelService.getPanels(side).length,
[panelService]
);
const [hasRightPanels, setHasRightPanels] = useState(hasPanels('right'));
const [hasLeftPanels, setHasLeftPanels] = useState(hasPanels('left'));
const [leftPanelClosedState, setLeftPanelClosed] = useState(leftPanelClosed);
const [rightPanelClosedState, setRightPanelClosed] = useState(rightPanelClosed);
/**
* Set body classes (tailwindcss) that don't allow vertical
* or horizontal overflow (no scrolling). Also guarantee window
@ -57,20 +60,6 @@ function ViewerLayout({
return { entry, content: entry.component };
};
const getPanelData = id => {
const { content, entry } = getComponent(id);
return {
id: entry.id,
iconName: entry.iconName,
iconLabel: entry.iconLabel,
label: entry.label,
name: entry.name,
content,
contexts: entry.contexts,
};
};
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
HangingProtocolService.EVENTS.PROTOCOL_CHANGED,
@ -97,8 +86,26 @@ function ViewerLayout({
};
};
const leftPanelComponents = leftPanels.map(getPanelData);
const rightPanelComponents = rightPanels.map(getPanelData);
useEffect(() => {
const { unsubscribe } = panelService.subscribe(
panelService.EVENTS.PANELS_CHANGED,
({ options }) => {
setHasLeftPanels(hasPanels('left'));
setHasRightPanels(hasPanels('right'));
if (options?.leftPanelClosed !== undefined) {
setLeftPanelClosed(options.leftPanelClosed);
}
if (options?.rightPanelClosed !== undefined) {
setRightPanelClosed(options.rightPanelClosed);
}
}
);
return () => {
unsubscribe();
};
}, [panelService, hasPanels]);
const viewportComponents = viewports.map(getViewportComponentData);
return (
@ -107,6 +114,7 @@ function ViewerLayout({
hotkeysManager={hotkeysManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
appConfig={appConfig}
/>
<div
className="relative flex w-full flex-row flex-nowrap items-stretch overflow-hidden bg-black"
@ -115,12 +123,11 @@ function ViewerLayout({
<React.Fragment>
{showLoadingIndicator && <LoadingIndicatorProgress className="h-full w-full bg-black" />}
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length ? (
{hasLeftPanels ? (
<ErrorBoundary context="Left Panel">
<SidePanelWithServices
side="left"
activeTabIndex={leftPanelDefaultClosed ? null : 0}
tabs={leftPanelComponents}
activeTabIndex={leftPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
</ErrorBoundary>
@ -137,12 +144,11 @@ function ViewerLayout({
</ErrorBoundary>
</div>
</div>
{rightPanelComponents.length ? (
{hasRightPanels ? (
<ErrorBoundary context="Right Panel">
<SidePanelWithServices
side="right"
activeTabIndex={rightPanelDefaultClosed ? null : 0}
tabs={rightPanelComponents}
activeTabIndex={rightPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
</ErrorBoundary>
@ -165,8 +171,8 @@ ViewerLayout.propTypes = {
// From modes
leftPanels: PropTypes.array,
rightPanels: PropTypes.array,
leftPanelDefaultClosed: PropTypes.bool.isRequired,
rightPanelDefaultClosed: PropTypes.bool.isRequired,
leftPanelClosed: PropTypes.bool.isRequired,
rightPanelClosed: PropTypes.bool.isRequired,
/** Responsible for rendering our grid of viewports; provided by consuming application */
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
viewports: PropTypes.array,

View File

@ -26,15 +26,6 @@ export type UpdateViewportDisplaySetParams = {
excludeNonImageModalities?: boolean;
};
/**
* Determine if a command is a hanging protocol one.
* For now, just use the two hanging protocol commands that are in this
* commands module, but if others get added elsewhere this may need enhancing.
*/
const isHangingProtocolCommand = command =>
command &&
(command.commandName === 'setHangingProtocol' || command.commandName === 'toggleHangingProtocol');
const commandsModule = ({
servicesManager,
commandsManager,
@ -47,7 +38,6 @@ const commandsModule = ({
viewportGridService,
displaySetService,
stateSyncService,
toolbarService,
} = (servicesManager as ServicesManager).services;
// Define a context menu controller for use with any context menus

View File

@ -0,0 +1,109 @@
import React, { useEffect, useState, useCallback, ReactElement } from 'react';
import { ServicesManager } from '@ohif/core';
import { ProgressDropdown } from '@ohif/ui';
const workflowStepsToDropdownOptions = (steps = []) =>
steps.map(step => ({
label: step.name,
value: step.id,
info: step.info,
activated: false,
completed: false,
}));
function ProgressDropdownWithService({
servicesManager,
}: {
servicesManager: ServicesManager;
}): ReactElement {
const { workflowStepsService } = servicesManager.services;
const [activeStepId, setActiveStepId] = useState(workflowStepsService.activeWorkflowStep?.id);
const [dropdownOptions, setDropdownOptions] = useState(
workflowStepsToDropdownOptions(workflowStepsService.workflowSteps)
);
const setCurrentAndPreviousOptionsAsCompleted = useCallback(currentOption => {
if (currentOption.completed) {
return;
}
setDropdownOptions(prevOptions => {
const newOptionsState = [...prevOptions];
const startIndex = newOptionsState.findIndex(option => option.value === currentOption.value);
for (let i = startIndex; i >= 0; i--) {
const option = newOptionsState[i];
if (option.completed) {
break;
}
newOptionsState[i] = {
...option,
completed: true,
};
}
return newOptionsState;
});
}, []);
const handleDropdownChange = useCallback(
({ selectedOption }) => {
if (!selectedOption) {
return;
}
// TODO: Steps should be marked as completed after user has
// completed some action when required (not implemented)
setCurrentAndPreviousOptionsAsCompleted(selectedOption);
setActiveStepId(selectedOption.value);
},
[setCurrentAndPreviousOptionsAsCompleted]
);
useEffect(() => {
let timeoutId;
if (activeStepId) {
// We've used setTimeout to give it more time to update the UI since
// create3DFilterableFromDataArray from Texture.js may take 600+ ms to run
// when there is a new series to load in the next step but that resulted
// in the followed React error when updating the content from left/right panels
// and all component states were being lost:
// Error: Can't perform a React state update on an unmounted component
workflowStepsService.setActiveWorkflowStep(activeStepId);
}
return () => clearTimeout(timeoutId);
}, [activeStepId, workflowStepsService]);
useEffect(() => {
const { unsubscribe: unsubStepsChanged } = workflowStepsService.subscribe(
workflowStepsService.EVENTS.STEPS_CHANGED,
() => setDropdownOptions(workflowStepsToDropdownOptions(workflowStepsService.workflowSteps))
);
const { unsubscribe: unsubActiveStepChanged } = workflowStepsService.subscribe(
workflowStepsService.EVENTS.ACTIVE_STEP_CHANGED,
() => setActiveStepId(workflowStepsService.activeWorkflowStep.id)
);
return () => {
unsubStepsChanged();
unsubActiveStepChanged();
};
}, [servicesManager, workflowStepsService]);
return (
<ProgressDropdown
options={dropdownOptions}
value={activeStepId}
onChange={handleDropdownChange}
/>
);
}
export default ProgressDropdownWithService;

View File

@ -0,0 +1 @@
export { default } from './ProgressDropdownWithService';

View File

@ -1,6 +1,7 @@
import { CustomizationService } from '@ohif/core';
import React from 'react';
import DataSourceSelector from './Panels/DataSourceSelector';
import ProgressDropdownWithService from './components/ProgressDropdownWithService';
import DataSourceConfigurationComponent from './Components/DataSourceConfigurationComponent';
import { GoogleCloudDataSourceConfigurationAPI } from './DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI';
@ -154,6 +155,11 @@ export default function getCustomizationModule({ servicesManager, extensionManag
extensionManager
),
},
{
id: 'progressDropdownWithServiceComponent',
component: ProgressDropdownWithService,
},
],
},
];

View File

@ -10,9 +10,15 @@ import checkSingleFrames from './utils/validations/checkSingleFrames';
*/
export default function getDisplaySetMessages(
instances: Array<any>,
isReconstructable: boolean
isReconstructable: boolean,
isDynamicVolume: boolean
): DisplaySetMessageList {
const messages = new DisplaySetMessageList();
if (isDynamicVolume) {
return messages;
}
if (!instances.length) {
messages.addMessage(DisplaySetMessage.CODES.NO_VALID_INSTANCES);
return;

View File

@ -5,23 +5,79 @@ import isDisplaySetReconstructable from '@ohif/core/src/utils/isDisplaySetRecons
import { id } from './id';
import getDisplaySetMessages from './getDisplaySetMessages';
import getDisplaySetsFromUnsupportedSeries from './getDisplaySetsFromUnsupportedSeries';
import { chartHandler } from './SOPClassHandlers/chartSOPClassHandler';
const DEFAULT_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
const DYNAMIC_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingDynamicImageVolume';
const sopClassHandlerName = 'stack';
let appContext = {};
const getDynamicVolumeInfo = instances => {
const { extensionManager } = appContext;
if (!extensionManager) {
throw new Error('extensionManager is not available');
}
const imageIds = instances.map(({ imageId }) => imageId);
const volumeLoaderUtility = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.volumeLoader'
);
const { getDynamicVolumeInfo: csGetDynamicVolumeInfo } = volumeLoaderUtility.exports;
return csGetDynamicVolumeInfo(imageIds);
};
const isMultiFrame = instance => {
return instance.NumberOfFrames > 1;
};
function getDisplaySetInfo(instances) {
const dynamicVolumeInfo = getDynamicVolumeInfo(instances);
const { isDynamicVolume, timePoints } = dynamicVolumeInfo;
let displaySetInfo;
if (isDynamicVolume) {
const timePoint = timePoints[0];
const instancesMap = new Map();
// O(n) to convert it into a map and O(1) to find each instance
instances.forEach(instance => instancesMap.set(instance.imageId, instance));
const firstTimePointInstances = timePoint.map(imageId => instancesMap.get(imageId));
displaySetInfo = isDisplaySetReconstructable(firstTimePointInstances);
} else {
displaySetInfo = isDisplaySetReconstructable(instances);
}
return {
isDynamicVolume,
...displaySetInfo,
dynamicVolumeInfo,
};
}
const makeDisplaySet = instances => {
const instance = instances[0];
const imageSet = new ImageSet(instances);
const { value: isReconstructable, averageSpacingBetweenFrames } =
isDisplaySetReconstructable(instances);
const {
isDynamicVolume,
value: isReconstructable,
averageSpacingBetweenFrames,
dynamicVolumeInfo,
} = getDisplaySetInfo(instances);
const volumeLoaderSchema = isDynamicVolume
? DYNAMIC_VOLUME_LOADER_SCHEME
: DEFAULT_VOLUME_LOADER_SCHEME;
// set appropriate attributes to image set...
const messages = getDisplaySetMessages(instances, isReconstructable);
const messages = getDisplaySetMessages(instances, isReconstructable, isDynamicVolume);
imageSet.setAttributes({
volumeLoaderSchema,
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
@ -39,6 +95,8 @@ const makeDisplaySet = instances => {
isReconstructable,
messages,
averageSpacingBetweenFrames: averageSpacingBetweenFrames || null,
isDynamicVolume,
dynamicVolumeInfo,
});
// Sort the images in this series if needed
@ -88,7 +146,6 @@ function getSopClassUids(instances) {
* - For all Image types that are stackable, create
* a displaySet with a stack of images
*
* @param {Array} sopClassHandlerModules List of SOP Class Modules
* @param {SeriesMetadata} series The series metadata object from which the display sets will be created
* @returns {Array} The list of display sets created for the given series object
*/
@ -203,7 +260,9 @@ const sopClassUids = [
sopClassDictionary.EnhancedUSVolumeStorage,
];
function getSopClassHandlerModule() {
function getSopClassHandlerModule(appContextParam) {
appContext = appContextParam;
return [
{
name: sopClassHandlerName,
@ -215,6 +274,11 @@ function getSopClassHandlerModule() {
sopClassUids: [],
getDisplaySetsFromSeries: getDisplaySetsFromUnsupportedSeries,
},
{
name: chartHandler.name,
sopClassUids: chartHandler.sopClassUids,
getDisplaySetsFromSeries: chartHandler.getDisplaySetsFromSeries,
},
];
}

View File

@ -3,6 +3,7 @@ import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
import ToolbarSplitButtonWithServices from './Toolbar/ToolbarSplitButtonWithServices';
import ToolbarButtonGroupWithServices from './Toolbar/ToolbarButtonGroupWithServices';
import { ToolbarButton } from '@ohif/ui';
import ProgressDropdownWithService from './components/ProgressDropdownWithService';
const getClassName = isToggled => {
return {
@ -36,6 +37,10 @@ export default function getToolbarModule({ commandsManager, servicesManager }) {
name: 'ohif.buttonGroup',
defaultComponent: ToolbarButtonGroupWithServices,
},
{
name: 'ohif.progressDropdown',
defaultComponent: ProgressDropdownWithService,
},
{
name: 'evaluate.group.promoteToPrimary',
evaluate: ({ viewportId, button, itemId }) => {

View File

@ -0,0 +1,21 @@
import { ServicesManager, CommandsManager, ExtensionManager } from '@ohif/core';
import LineChartViewport from './Components/LineChartViewport/index';
const getViewportModule = ({
servicesManager,
commandsManager,
extensionManager,
}: {
servicesManager: ServicesManager;
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
}) => {
return [
{
name: 'chartViewport',
component: LineChartViewport,
},
];
};
export { getViewportModule as default };

View File

@ -9,6 +9,7 @@ import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByMRN from './Panels/getStudiesForPatientByMRN';
import getCustomizationModule from './getCustomizationModule';
import getViewportModule from './getViewportModule';
import { id } from './id.js';
import preRegistration from './init';
import { ContextMenuController, CustomizableContextMenuTypes } from './CustomizableContextMenu';
@ -23,6 +24,7 @@ const defaultExtension: Types.Extensions.Extension = {
id,
preRegistration,
getDataSourcesModule,
getViewportModule,
getLayoutTemplateModule,
getPanelModule,
getHangingProtocolModule,

View File

@ -77,7 +77,7 @@ export default function init({ servicesManager, configuration = {}, commandsMana
toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, state => {
const { buttons } = state;
for (const [id, button] of Object.entries(buttons)) {
const { groupId, items, listeners } = button.props;
const { groupId, items, listeners } = button.props || {};
// Handle group items' listeners
if (groupId && items) {

View File

@ -21,6 +21,11 @@ const reuseCachedLayout = (
): ReturnType => {
const { activeViewportId } = state;
const { protocol } = hangingProtocolService.getActiveProtocol();
if (!protocol) {
return;
}
const hpInfo = hangingProtocolService.getState();
const { protocolId, stageIndex, activeStudyUID } = hpInfo;

View File

@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "^1.68.1",
"@cornerstonejs/tools": "^1.68.1",
"@cornerstonejs/core": "^1.70.0",
"@cornerstonejs/tools": "^1.70.0",
"@ohif/core": "3.8.0-beta.73",
"@ohif/extension-cornerstone-dicom-sr": "3.8.0-beta.73",
"@ohif/ui": "3.8.0-beta.73",

View File

@ -1,45 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, ButtonEnums } from '@ohif/ui';
function ActionButtons({ onExportClick, onCreateReportClick, disabled }) {
const { t } = useTranslation('MeasurementTable');
return (
<React.Fragment>
<Button
onClick={onExportClick}
disabled={disabled}
type={ButtonEnums.type.secondary}
size={ButtonEnums.size.small}
>
{t('Export')}
</Button>
<Button
className="ml-2"
onClick={onCreateReportClick}
type={ButtonEnums.type.secondary}
size={ButtonEnums.size.small}
disabled={disabled}
>
{t('Create Report')}
</Button>
</React.Fragment>
);
}
ActionButtons.propTypes = {
onExportClick: PropTypes.func,
onCreateReportClick: PropTypes.func,
disabled: PropTypes.bool,
};
ActionButtons.defaultProps = {
onExportClick: () => alert('Export'),
onCreateReportClick: () => alert('Create Report'),
disabled: false,
};
export default ActionButtons;

View File

@ -1,19 +1,12 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import {
StudySummary,
MeasurementTable,
Dialog,
Input,
useViewportGrid,
ButtonEnums,
} from '@ohif/ui';
import { StudySummary, MeasurementTable, useViewportGrid, ActionButtons } from '@ohif/ui';
import { DicomMetadataStore, utils } from '@ohif/core';
import { useDebounce } from '@hooks';
import { useAppConfig } from '@state';
import ActionButtons from './ActionButtons';
import { useTrackedMeasurements } from '../../getContextModule';
import debounce from 'lodash.debounce';
import { useTranslation } from 'react-i18next';
const { downloadCSVReport } = utils;
const { formatDate } = utils;
@ -27,9 +20,11 @@ const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable');
const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(Date.now().toString());
const debouncedMeasurementChangeTimestamp = useDebounce(measurementChangeTimestamp, 200);
const { measurementService, uiDialogService, displaySetService, customizationService } = servicesManager.services;
const { measurementService, uiDialogService, displaySetService, customizationService } =
servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState(
@ -172,6 +167,9 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
dm => dm.measurementType === measurementService.VALUE_TYPES.POINT
);
const disabled =
additionalFindings.length === 0 && displayMeasurementsWithoutFindings.length === 0;
return (
<>
<div
@ -206,16 +204,23 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
{!appConfig?.disableEditing && (
<div className="flex justify-center p-4">
<ActionButtons
onExportClick={exportReport}
onCreateReportClick={() => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
});
}}
disabled={
additionalFindings.length === 0 && displayMeasurementsWithoutFindings.length === 0
}
t={t}
actions={[
{
label: 'Export',
onClick: exportReport,
},
{
label: 'Create Report',
onClick: () => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
});
},
},
]}
disabled={disabled}
/>
</div>
)}

View File

@ -19,8 +19,13 @@ function PanelStudyBrowserTracking({
requestDisplaySetCreationForStudy,
dataSource,
}) {
const { displaySetService, uiDialogService, hangingProtocolService, uiNotificationService, measurementService } =
servicesManager.services;
const {
displaySetService,
uiDialogService,
hangingProtocolService,
uiNotificationService,
measurementService,
} = servicesManager.services;
const navigate = useNavigate();
const { t } = useTranslation('Common');
@ -129,7 +134,8 @@ function PanelStudyBrowserTracking({
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = imageIds[Math.floor(imageIds.length / 2)];
const imageId = getImageIdForThumbnail(displaySet, imageIds);
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
if (!imageId || displaySet?.unsupported) {
@ -195,7 +201,7 @@ function PanelStudyBrowserTracking({
}
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = imageIds[Math.floor(imageIds.length / 2)];
const imageId = getImageIdForThumbnail(displaySet, imageIds);
// TODO: Is it okay that imageIds are not returned here for SR displaysets?
if (!imageId) {
@ -366,6 +372,19 @@ PanelStudyBrowserTracking.propTypes = {
export default PanelStudyBrowserTracking;
function getImageIdForThumbnail(displaySet: any, imageIds: any) {
let imageId;
if (displaySet.isDynamicVolume) {
const timePoints = displaySet.dynamicVolumeInfo.timePoints;
const middleIndex = Math.floor(timePoints.length / 2);
const middleTimePointImageIds = timePoints[middleIndex];
imageId = middleTimePointImageIds[Math.floor(middleTimePointImageIds.length / 2)];
} else {
imageId = imageIds[Math.floor(imageIds.length / 2)];
}
return imageId;
}
/**
* Maps from the DataSource's format to a naturalized object
*
@ -631,4 +650,4 @@ function _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs) {
}
}
}
}
}

View File

@ -140,17 +140,18 @@ function TrackedCornerstoneViewport(props) {
// Only send the tracked measurements event for the active viewport to avoid
// sending it more than once.
if (viewportId === activeViewportId) {
const { referenceStudyUID: StudyInstanceUID,
const {
referenceStudyUID: StudyInstanceUID,
referenceSeriesUID: SeriesInstanceUID,
uid: measurementId } =
measurement;
uid: measurementId,
} = measurement;
sendTrackedMeasurementsEvent('SET_DIRTY', { SeriesInstanceUID });
sendTrackedMeasurementsEvent('TRACK_SERIES', {
viewportId,
StudyInstanceUID,
SeriesInstanceUID,
measurementId
measurementId,
});
}
}).unsubscribe

View File

@ -0,0 +1,17 @@
import hpMN from './hpMN';
const hangingProtocols = [
{
name: '@ohif/hp-extension.mn',
protocol: hpMN,
},
];
/**
* Registers a single study hanging protocol which can be referenced as
* `@ohif/hp-exgtension.mn`, that has initial layouts which show images
* only display sets, up to a 2x2 view.
*/
export default function getHangingProtocolModule() {
return hangingProtocols;
}

View File

@ -1,51 +0,0 @@
import React from 'react';
import { LegacyButton, LegacyButtonGroup } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
function ExportReports({ segmentations, tmtvValue, config, commandsManager }) {
const { t } = useTranslation('PanelSUVExport');
return (
<>
{segmentations?.length ? (
<div className="mt-4 flex justify-center space-x-2">
{/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/}
<LegacyButtonGroup
color="black"
size="inherit"
>
<LegacyButton
className="px-2 py-2 text-base"
disabled={tmtvValue === null}
onClick={() => {
commandsManager.runCommand('exportTMTVReportCSV', {
segmentations,
tmtv: tmtvValue,
config,
});
}}
>
{t('Export CSV')}
</LegacyButton>
</LegacyButtonGroup>
<LegacyButtonGroup
color="black"
size="inherit"
>
<LegacyButton
className="px-2 py-2 text-base"
onClick={() => {
commandsManager.runCommand('createTMTVRTReport');
}}
disabled={tmtvValue === null}
>
{t('Create RT Report')}
</LegacyButton>
</LegacyButtonGroup>
</div>
) : null}
</>
);
}
export default ExportReports;

View File

@ -1,287 +0,0 @@
import React, { useEffect, useState, useCallback, useReducer } from 'react';
import PropTypes from 'prop-types';
import { SegmentationTable, Button, Icon } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import segmentationEditHandler from './segmentationEditHandler';
import ExportReports from './ExportReports';
import ROIThresholdConfiguration, { ROI_STAT } from './ROIThresholdConfiguration';
const LOWER_CT_THRESHOLD_DEFAULT = -1024;
const UPPER_CT_THRESHOLD_DEFAULT = 1024;
const LOWER_PT_THRESHOLD_DEFAULT = 2.5;
const UPPER_PT_THRESHOLD_DEFAULT = 100;
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
const DEFAULT_STRATEGY = ROI_STAT;
function reducer(state, action) {
const { payload } = action;
const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
ctLower: ctLower ? ctLower : state.ctLower,
ctUpper: ctUpper ? ctUpper : state.ctUpper,
ptLower: ptLower ? ptLower : state.ptLower,
ptUpper: ptUpper ? ptUpper : state.ptUpper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
export default function LegacyPanelRoiThresholdSegmentation({ servicesManager, commandsManager }) {
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('PanelSUV');
const [showConfig, setShowConfig] = useState(false);
const [labelmapLoading, setLabelmapLoading] = useState(false);
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const [config, dispatch] = useReducer(reducer, {
strategy: DEFAULT_STRATEGY,
ctLower: LOWER_CT_THRESHOLD_DEFAULT,
ctUpper: UPPER_CT_THRESHOLD_DEFAULT,
ptLower: LOWER_PT_THRESHOLD_DEFAULT,
ptUpper: UPPER_PT_THRESHOLD_DEFAULT,
weight: WEIGHT_DEFAULT,
});
const [tmtvValue, setTmtvValue] = useState(null);
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
const handleTMTVCalculation = useCallback(() => {
const tmtv = runCommand('calculateTMTV', { segmentations });
if (tmtv !== undefined) {
setTmtvValue(tmtv.toFixed(2));
}
}, [segmentations, runCommand]);
const handleROIThresholding = useCallback(() => {
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId: selectedSegmentationId,
config,
});
const lesionStats = runCommand('getLesionStats', { labelmap });
const suvPeak = runCommand('calculateSuvPeak', { labelmap });
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const segmentation = segmentationService.getSegmentation(selectedSegmentationId);
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const notYetUpdatedAtSource = true;
segmentationService.addOrUpdateSegmentation(
{
...segmentation,
...Object.assign(segmentation.cachedStats, cachedStats),
displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`],
},
notYetUpdatedAtSource
);
handleTMTVCalculation();
}, [selectedSegmentationId, config]);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const subscriptions = [];
[added, updated].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
useEffect(() => {
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_REMOVED,
() => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
if (segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
handleTMTVCalculation();
} else {
setSelectedSegmentationId(null);
setTmtvValue(null);
}
}
);
return () => {
unsubscribe();
};
}, []);
/**
* Whenever the segmentations change, update the TMTV calculations
*/
useEffect(() => {
if (!selectedSegmentationId && segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
}
handleTMTVCalculation();
}, [segmentations, selectedSegmentationId]);
return (
<>
<div className="flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
<div className="mx-4 my-4 mb-4 flex space-x-4">
<Button
onClick={() => {
setLabelmapLoading(true);
setTimeout(() => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setLabelmapLoading(false);
setSelectedSegmentationId(segmentationId);
});
});
}}
>
{labelmapLoading ? 'loading ...' : 'New Label'}
</Button>
<Button onClick={handleROIThresholding}>Run</Button>
</div>
<div
className="bg-secondary-dark border-secondary-light mb-2 flex h-8 cursor-pointer select-none items-center justify-around border-t outline-none first:border-0"
onClick={() => {
setShowConfig(!showConfig);
}}
>
<div className="px-4 text-base text-white">{t('ROI Threshold Configuration')}</div>
</div>
{showConfig && (
<ROIThresholdConfiguration
config={config}
dispatch={dispatch}
runCommand={runCommand}
/>
)}
{/* show segmentation table */}
<div className="mt-4">
{segmentations?.length ? (
<SegmentationTable
title={t('Segmentations')}
segmentations={segmentations}
activeSegmentationId={selectedSegmentationId}
onClick={id => {
runCommand('setSegmentationActiveForToolGroups', {
segmentationId: id,
});
setSelectedSegmentationId(id);
}}
onToggleVisibility={id => {
segmentationService.toggleSegmentationVisibility(id);
}}
onToggleVisibilityAll={ids => {
ids.map(id => {
segmentationService.toggleSegmentationVisibility(id);
});
}}
onDelete={id => {
segmentationService.remove(id);
}}
onEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
/>
) : null}
</div>
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-4 flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue} mL`}</div>
</div>
) : null}
<ExportReports
segmentations={segmentations}
tmtvValue={tmtvValue}
config={config}
commandsManager={commandsManager}
/>
</div>
</div>
<div
className="mt-auto mb-4 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
onClick={() => {
// navigate to a url in a new tab
window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank');
}}
>
<Icon
width="15px"
height="15px"
name={'info'}
className={'text-primary-active ml-4 mr-3'}
/>
<span>{'User Guide'}</span>
</div>
</>
);
}
LegacyPanelRoiThresholdSegmentation.propTypes = {
commandsManager: PropTypes.shape({
runCommand: PropTypes.func.isRequired,
}),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
segmentationService: PropTypes.shape({
getSegmentation: PropTypes.func.isRequired,
getSegmentations: PropTypes.func.isRequired,
toggleSegmentationVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Icon, ActionButtons } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
export default function PanelRoiThresholdSegmentation({ servicesManager, commandsManager }) {
const { segmentationService } = servicesManager.services;
const { t } = useTranslation('PanelSUVExport');
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED;
const subscriptions = [];
[added, updated, removed].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
const tmtvValue = segmentations?.[0]?.cachedStats?.tmtv?.value || null;
const config = segmentations?.[0]?.cachedStats?.tmtv?.config || {};
const actions = [
{
label: 'Export CSV',
onClick: () => {
commandsManager.runCommand('exportTMTVReportCSV', {
segmentations,
tmtv: tmtvValue,
config,
});
},
disabled: tmtvValue === null,
},
{
label: 'Create RT Report',
onClick: () => {
commandsManager.runCommand('createTMTVRTReport');
},
disabled: tmtvValue === null,
},
];
return (
<>
<div className="mt-1 flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-1 flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue} mL`}</div>
</div>
) : null}
<div className="mt-1 flex justify-center">
<ActionButtons
actions={actions}
t={t}
/>
</div>
</div>
</div>
<div
className="absolute bottom-1 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
onClick={() => {
// navigate to a url in a new tab
window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank');
}}
>
<Icon
width="15px"
height="15px"
name={'info'}
className={'text-primary-active ml-4 mr-3'}
/>
<span>{'User Guide'}</span>
</div>
</>
);
}
PanelRoiThresholdSegmentation.propTypes = {
commandsManager: PropTypes.shape({
runCommand: PropTypes.func.isRequired,
}),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
segmentationService: PropTypes.shape({
getSegmentation: PropTypes.func.isRequired,
getSegmentations: PropTypes.func.isRequired,
toggleSegmentationVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -1,317 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { SegmentationGroupTableExpanded, Icon } from '@ohif/ui';
import { createReportAsync } from '@ohif/extension-default';
import segmentationEditHandler from './segmentationEditHandler';
import ExportReports from './ExportReports';
import callInputDialog from './callInputDialog';
import callColorPickerDialog from './colorPickerDialog';
export default function PanelRoiThresholdSegmentation({
servicesManager,
commandsManager,
extensionManager,
}) {
const { segmentationService, viewportGridService, uiDialogService } = servicesManager.services;
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations());
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const added = segmentationService.EVENTS.SEGMENTATION_ADDED;
const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED;
const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED;
const subscriptions = [];
[added, updated, removed].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentations();
setSegmentations(segmentations);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
const onSegmentationClick = (segmentationId: string) => {
segmentationService.setActiveSegmentationForToolGroup(segmentationId);
setSelectedSegmentationId(segmentationId);
};
const onSegmentationAdd = async () => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setSelectedSegmentationId(segmentationId);
});
};
const onSegmentAdd = segmentationId => {
segmentationService.addSegment(segmentationId);
};
const getToolGroupIds = segmentationId => {
const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId);
return toolGroupIds;
};
const onSegmentClick = (segmentationId, segmentIndex) => {
segmentationService.setActiveSegment(segmentationId, segmentIndex);
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
// const toolGroupId =
segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId);
segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex, toolGroupId);
});
};
const _setSegmentationConfiguration = useCallback(
(segmentationId, key, value) => {
segmentationService.setConfiguration({
segmentationId,
[key]: value,
});
},
[segmentationService]
);
const onToggleSegmentVisibility = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segmentInfo = segmentation.segments[segmentIndex];
const isVisible = !segmentInfo.isVisible;
const toolGroupIds = getToolGroupIds(segmentationId);
toolGroupIds.forEach(toolGroupId => {
segmentationService.setSegmentVisibility(
segmentationId,
segmentIndex,
isVisible,
toolGroupId
);
});
};
const onSegmentDelete = (segmentationId, segmentIndex) => {
segmentationService.removeSegment(segmentationId, segmentIndex);
};
const onSegmentEdit = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { label } = segment;
callInputDialog(uiDialogService, label, (label, actionId) => {
if (label === '') {
return;
}
segmentationService.setSegmentLabel(segmentationId, segmentIndex, label);
});
};
const onToggleSegmentLock = (segmentationId, segmentIndex) => {
segmentationService.toggleSegmentLocked(segmentationId, segmentIndex);
};
const onSegmentColorClick = (segmentationId, segmentIndex) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const segment = segmentation.segments[segmentIndex];
const { color, opacity } = segment;
const rgbaColor = {
r: color[0],
g: color[1],
b: color[2],
a: opacity / 255.0,
};
callColorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
segmentationService.setSegmentRGBAColor(segmentationId, segmentIndex, [
newRgbaColor.r,
newRgbaColor.g,
newRgbaColor.b,
newRgbaColor.a * 255.0,
]);
});
};
const storeSegmentation = async segmentationId => {
const datasources = extensionManager.getActiveDataSource();
const displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand('storeSegmentation', {
segmentationId,
dataSource: datasources[0],
}),
reportType: 'Segmentation',
});
// Show the exported report in the active viewport as read only (similar to SR)
if (displaySetInstanceUIDs) {
// clear the segmentation that we exported, similar to the storeMeasurement
// where we remove the measurements and prompt again the user if they would like
// to re-read the measurements in a SR read only viewport
segmentationService.remove(segmentationId);
viewportGridService.setDisplaySetsForViewport({
viewportId: viewportGridService.getActiveViewportId(),
displaySetInstanceUIDs,
});
}
};
const onSegmentationDownloadRTSS = segmentationId => {
commandsManager.runCommand('downloadRTSS', {
segmentationId,
});
};
const onSegmentationDownload = segmentationId => {
commandsManager.runCommand('downloadSegmentation', {
segmentationId,
});
};
const tmtvValue = segmentations?.[0]?.cachedStats?.tmtv?.value || null;
const config = segmentations?.[0]?.cachedStats?.tmtv?.config || {};
return (
<>
<div className="flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
{/* show segmentation table */}
<div className="flex min-h-0 flex-col bg-black text-[13px] font-[300]">
<SegmentationGroupTableExpanded
disableEditing={false}
showAddSegmentation={true}
showAddSegment={false}
showDeleteSegment={true}
segmentations={segmentations}
onSegmentationAdd={onSegmentationAdd}
onSegmentationClick={onSegmentationClick}
onToggleSegmentationVisibility={id => {
segmentationService.toggleSegmentationVisibility(id);
}}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onSegmentationDelete={id => {
segmentationService.remove(id);
}}
onSegmentationEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
segmentationConfig={{ initialConfig: segmentationService.getConfiguration() }}
onSegmentAdd={onSegmentAdd}
onSegmentClick={onSegmentClick}
onSegmentDelete={onSegmentDelete}
onSegmentEdit={onSegmentEdit}
onToggleSegmentLock={onToggleSegmentLock}
onSegmentColorClick={onSegmentColorClick}
storeSegmentation={storeSegmentation}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
onSegmentationDownload={onSegmentationDownload}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(
selectedSegmentationId,
'renderInactiveSegmentations',
value
)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
</div>
{tmtvValue !== null ? (
<div className="bg-secondary-dark mt-1 flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue} mL`}</div>
</div>
) : null}
<ExportReports
segmentations={segmentations}
tmtvValue={tmtvValue}
config={config}
commandsManager={commandsManager}
/>
</div>
</div>
<div
className="absolute bottom-1 flex cursor-pointer items-center justify-center text-blue-400 opacity-50 hover:opacity-80"
onClick={() => {
// navigate to a url in a new tab
window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank');
}}
>
<Icon
width="15px"
height="15px"
name={'info'}
className={'text-primary-active ml-4 mr-3'}
/>
<span>{'User Guide'}</span>
</div>
</>
);
}
PanelRoiThresholdSegmentation.propTypes = {
commandsManager: PropTypes.shape({
runCommand: PropTypes.func.isRequired,
}),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
segmentationService: PropTypes.shape({
getSegmentation: PropTypes.func.isRequired,
getSegmentations: PropTypes.func.isRequired,
toggleSegmentationVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -1,62 +0,0 @@
import React from 'react';
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
function callInputDialog(uiDialogService, label, callback) {
const dialogId = 'enter-segment-label';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.label, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment',
value: { label },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Confirm', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<Input
label="Enter the segment label"
labelClassName="text-white text-[14px] leading-[1.2]"
autoFocus
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
);
},
},
});
}
}
export default callInputDialog;

View File

@ -1,3 +0,0 @@
.chrome-picker {
background: #090c29 !important;
}

View File

@ -1,58 +0,0 @@
import React from 'react';
import { Dialog } from '@ohif/ui';
import { ChromePicker } from 'react-color';
import './colorPickerDialog.css';
function callColorPickerDialog(uiDialogService, rgbaColor, callback) {
const dialogId = 'pick-color';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.rgbaColor, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment Color',
value: { rgbaColor },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
const handleChange = color => {
setValue({ rgbaColor: color.rgb });
};
return (
<ChromePicker
color={value.rgbaColor}
onChange={handleChange}
presetColors={[]}
width={300}
/>
);
},
},
});
}
}
export default callColorPickerDialog;

View File

@ -1,3 +1,3 @@
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
import PanelROIThresholdExport from './PanelROIThresholdExport';
export default PanelROIThresholdSegmentation;
export default PanelROIThresholdExport;

View File

@ -1,4 +1,4 @@
import PanelPetSUV from './PanelPetSUV';
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
import PanelROIThresholdExport from './PanelROIThresholdSegmentation';
export { PanelPetSUV, PanelROIThresholdSegmentation };
export { PanelPetSUV, PanelROIThresholdExport };

View File

@ -11,7 +11,10 @@ import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
const metadataProvider = classes.MetadataProvider;
const RECTANGLE_ROI_THRESHOLD_MANUAL = 'RectangleROIStartEndThreshold';
const RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS = [
'RectangleROIStartEndThreshold',
'RectangleROIThreshold',
];
const LABELMAP = csTools.Enums.SegmentationRepresentations.Labelmap;
const commandsModule = ({ servicesManager, commandsManager, extensionManager }) => {
@ -52,6 +55,15 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
return toolGroupIds;
}
function _getAnnotationsSelectedByToolNames(toolNames) {
return toolNames.reduce((allAnnotationUIDs, toolName) => {
const annotationUIDs =
csTools.annotation.selection.getAnnotationsSelectedByToolName(toolName);
return allAnnotationUIDs.concat(annotationUIDs);
}, []);
}
const actions = {
getMatchingPTDisplaySet: ({ viewportMatchDetails }) => {
// Todo: this is assuming that the hanging protocol has successfully matched
@ -108,7 +120,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
return metadata;
},
createNewLabelmapFromPT: async () => {
createNewLabelmapFromPT: async ({ label }) => {
// Create a segmentation of the same resolution as the source data
// using volumeLoader.createAndCacheDerivedVolume.
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
@ -173,20 +185,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
const { volumeId: segVolumeId } = representationData[LABELMAP];
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
const labelmapVolume = cs.cache.getVolume(segmentationId);
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
const ctReferencedVolume = cs.cache.getVolume(ctVolumeId);
if (!referencedVolume) {
throw new Error('No Reference volume found');
}
if (!labelmapVolume) {
throw new Error('No Reference labelmap found');
}
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
const annotationUIDs = _getAnnotationsSelectedByToolNames(
RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS
);
if (annotationUIDs.length === 0) {
@ -198,6 +198,57 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
return;
}
const labelmapVolume = cs.cache.getVolume(segmentationId);
let referencedVolume = cs.cache.getVolume(referencedVolumeId);
const ctReferencedVolume = cs.cache.getVolume(ctVolumeId);
// check if viewport is
if (!referencedVolume) {
throw new Error('No Reference volume found');
}
if (!labelmapVolume) {
throw new Error('No Reference labelmap found');
}
const annotation = csTools.annotation.state.getAnnotation(annotationUIDs[0]);
const {
metadata: {
enabledElement: { viewport },
},
} = annotation;
const showingReferenceVolume = viewport.hasVolumeId(referencedVolumeId);
if (!showingReferenceVolume) {
// if the reference volume is not being displayed, we can't
// rely on it for thresholding, we have couple of options here
// 1. We choose whatever volume is being displayed
// 2. We check if it is a fusion viewport, we pick the volume
// that matches the size and dimensions of the labelmap. This might
// happen if the 4D PT is converted to a computed volume and displayed
// and wants to threshold the labelmap
// 3. We throw an error
const displaySetInstanceUIDs = viewportGridService.getDisplaySetsUIDsForViewport(
viewport.id
);
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
const volume = cs.cache
.getVolumes()
.find(volume => volume.volumeId.includes(displaySetInstanceUID));
if (
cs.utilities.isEqual(volume.dimensions, labelmapVolume.dimensions) &&
cs.utilities.isEqual(volume.spacing, labelmapVolume.spacing)
) {
referencedVolume = volume;
}
});
}
const { ptLower, ptUpper, ctLower, ctUpper } = getThresholdValues(
annotationUIDs,
[referencedVolume, ctReferencedVolume],
@ -218,8 +269,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
const { referencedVolumeId } = labelmap;
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
const annotationUIDs = _getAnnotationsSelectedByToolNames(
RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS
);
const annotations = annotationUIDs.map(annotationUID =>
@ -236,8 +287,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
},
getLesionStats: ({ labelmap, segmentIndex = 1 }) => {
const { scalarData, spacing } = labelmap;
const { scalarData: referencedScalarData } = cs.cache.getVolume(labelmap.referencedVolumeId);
const referencedScalarData = cs.cache.getVolume(labelmap.referencedVolumeId).getScalarData();
let segmentationMax = -Infinity;
let segmentationMin = Infinity;
@ -287,19 +337,25 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
return calculateTMTV(labelmaps);
},
exportTMTVReportCSV: ({ segmentations, tmtv, config }) => {
exportTMTVReportCSV: ({ segmentations, tmtv, config, options }) => {
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
segmentations,
});
const tlg = actions.getTotalLesionGlycolysis({ segmentations });
const additionalReportRows = [
{ key: 'Total Metabolic Tumor Volume', value: { tmtv } },
{ key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } },
{ key: 'Threshold Configuration', value: { ...config } },
];
createAndDownloadTMTVReport(segReport, additionalReportRows);
if (tmtv !== undefined) {
additionalReportRows.unshift({
key: 'Total Metabolic Tumor Volume',
value: { tmtv },
});
}
createAndDownloadTMTVReport(segReport, additionalReportRows, options);
},
getTotalLesionGlycolysis: ({ segmentations }) => {
const labelmapVolumes = segmentations.map(s => segmentationService.getLabelmapVolume(s.id));
@ -323,9 +379,9 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
}
const ptVolume = cs.cache.getVolume(referencedVolumeId);
const mergedLabelData = mergedLabelmap.scalarData;
const mergedLabelData = mergedLabelmap.getScalarData();
if (mergedLabelData.length !== ptVolume.scalarData.length) {
if (mergedLabelData.length !== ptVolume.getScalarData().length) {
console.error(
'commandsModule::getTotalLesionGlycolysis:Labelmap and ptVolume are not the same size'
);
@ -336,7 +392,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
for (let i = 0; i < mergedLabelData.length; i++) {
// if not background
if (mergedLabelData[i] !== 0) {
suv += ptVolume.scalarData[i];
suv += ptVolume.getScalarData()[i];
totalLesionVoxelCount += 1;
}
}
@ -351,8 +407,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
const { viewport } = _getActiveViewportsEnabledElement();
const { focalPoint, viewPlaneNormal } = viewport.getCamera();
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS
);
const annotationUID = selectedAnnotationUIDs[0];
@ -389,8 +445,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
setEndSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS
);
const annotationUID = selectedAnnotationUIDs[0];
@ -415,7 +471,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => {
const forAnnotations = stateManager.annotations[frameOfReferenceUID];
const ROIAnnotations = forAnnotations[RECTANGLE_ROI_THRESHOLD_MANUAL];
const ROIAnnotations = RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS.reduce(
(annotations, toolName) => [...annotations, ...(forAnnotations[toolName] ?? [])],
[]
);
annotations.push(...ROIAnnotations);
});
@ -483,7 +543,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager })
report[id] = {
...segReport,
PatientID: instance.PatientID,
PatientID: instance.PatientID ?? '000000',
PatientName: instance.PatientName.Alphabetic,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,

View File

@ -1,5 +1,5 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels';
import { PanelPetSUV, PanelROIThresholdExport } from './Panels';
import { Toolbox } from '@ohif/ui';
// TODO:
@ -17,20 +17,26 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
);
};
const wrappedROIThresholdSeg = () => {
const wrappedROIThresholdToolbox = () => {
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="tmtvToolbox"
buttonSectionId="ROIThresholdToolbox"
title="Threshold Tools"
/>
<PanelROIThresholdSegmentation
</>
);
};
const wrappedROIThresholdExport = () => {
return (
<>
<PanelROIThresholdExport
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
</>
);
@ -45,11 +51,18 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
component: wrappedPanelPetSuv,
},
{
name: 'ROIThresholdSeg',
name: 'tmtvBox',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedROIThresholdSeg,
label: 'Segmentation Toolbox',
component: wrappedROIThresholdToolbox,
},
{
name: 'tmtvExport',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation Export',
component: wrappedROIThresholdExport,
},
];
}

View File

@ -40,17 +40,19 @@ function calculateSuvPeak(
return;
}
if (labelmap.scalarData.length !== referenceVolume.scalarData.length) {
const labelmapData = labelmap.getScalarData();
const referenceVolumeData = referenceVolume.getScalarData();
if (labelmapData.length !== referenceVolumeData.length) {
throw new Error('labelmap and referenceVolume must have the same number of pixels');
}
const { scalarData: labelmapData, dimensions, imageData: labelmapImageData } = labelmap;
const { scalarData: referenceVolumeData, imageData: referenceVolumeImageData } = referenceVolume;
const { dimensions, imageData: labelmapImageData } = labelmap;
const { imageData: referenceVolumeImageData } = referenceVolume;
let boundsIJK;
// Todo: using the first annotation for now
if (annotations && annotations[0].data?.cachedStats) {
if (annotations?.length && annotations[0].data?.cachedStats) {
const { projectionPoints } = annotations[0].data.cachedStats;
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now

View File

@ -1,4 +1,4 @@
export default function createAndDownloadTMTVReport(segReport, additionalReportRows) {
export default function createAndDownloadTMTVReport(segReport, additionalReportRows, options = {}) {
const firstReport = segReport[Object.keys(segReport)[0]];
const columns = Object.keys(firstReport);
const csv = [columns.join(',')];
@ -40,6 +40,6 @@ export default function createAndDownloadTMTVReport(segReport, additionalReportR
const a = document.createElement('a');
a.href = url;
a.download = `${firstReport.PatientID}_tmtv.csv`;
a.download = options.filename ?? `${firstReport.PatientID}_tmtv.csv`;
a.click();
}

View File

@ -136,7 +136,7 @@ function modeFactory() {
props: {
leftPanels: [tracked.thumbnailList],
rightPanels: [dicomSeg.panel, tracked.measurements],
// rightPanelDefaultClosed: true, // optional prop to start with collapse panels
// rightPanelClosed: true, // optional prop to start with collapse panels
viewports: [
{
namespace: tracked.viewport,

View File

@ -11,6 +11,13 @@ const ReferenceLinesListeners: RunCommand = [
},
];
export const toggleEnabledDisabledToolbar = {
commandName: 'toggleEnabledDisabledToolbar',
commandOptions: {
toolGroupIds: ['default', 'mpr', 'SRToolGroup', 'volume3d'],
},
};
const moreTools = [
{
id: 'MoreTools',
@ -80,13 +87,7 @@ const moreTools = [
icon: 'tool-referenceLines',
label: 'Reference Lines',
tooltip: 'Show Reference Lines',
commands: {
commandName: 'setToolEnabled',
commandOptions: {
toolName: 'ReferenceLines',
toggle: true,
},
},
commands: toggleEnabledDisabledToolbar,
listeners: {
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners,
[ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners,
@ -94,17 +95,11 @@ const moreTools = [
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
createButton({
id: 'ImageOverlay',
id: 'ImageOverlayViewer',
icon: 'toggle-dicom-overlay',
label: 'Image Overlay',
tooltip: 'Toggle Image Overlay',
commands: {
commandName: 'setToolEnabled',
commandOptions: {
toolName: 'ImageOverlayViewer',
toggle: true,
},
},
commands: toggleEnabledDisabledToolbar,
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
createButton({

View File

@ -108,8 +108,8 @@ function modeFactory({ modeConfiguration }) {
customizationService.addModeCustomizations([
{
id: 'segmentation.disableEditing',
value: true,
id: 'segmentation.panel',
disableEditing: true,
},
]);
@ -183,7 +183,7 @@ function modeFactory({ modeConfiguration }) {
props: {
leftPanels: [tracked.thumbnailList],
rightPanels: [dicomSeg.panel, tracked.measurements],
rightPanelDefaultClosed: true,
rightPanelClosed: true,
viewports: [
{
namespace: tracked.viewport,

View File

@ -64,7 +64,12 @@ function initDefaultToolGroup(
{ toolName: toolNames.Magnify },
{ toolName: toolNames.SegmentationDisplay },
{ toolName: toolNames.CalibrationLine },
{ toolName: toolNames.AdvancedMagnify },
{
toolName: toolNames.AdvancedMagnify,
configuration: {
disableOnPassive: true,
},
},
{ toolName: toolNames.UltrasoundDirectional },
{ toolName: toolNames.PlanarFreehandROI },
{ toolName: toolNames.SplineROI },

View File

@ -11,6 +11,20 @@ const ReferenceLinesListeners: RunCommand = [
},
];
export const toggleEnabledDisabledToolbar = {
commandName: 'toggleEnabledDisabledToolbar',
commandOptions: {
toolGroupIds: ['default', 'mpr', 'SRToolGroup', 'volume3d'],
},
};
export const toggleActiveDisabledToolbar = {
commandName: 'toggleActiveDisabledToolbar',
commandOptions: {
toolGroupIds: ['default', 'mpr', 'SRToolGroup'],
},
};
const moreTools = [
{
id: 'MoreTools',
@ -80,13 +94,7 @@ const moreTools = [
icon: 'tool-referenceLines',
label: 'Reference Lines',
tooltip: 'Show Reference Lines',
commands: {
commandName: 'setToolEnabled',
commandOptions: {
toolName: 'ReferenceLines',
toggle: true, // Toggle the tool on/off upon click
},
},
commands: toggleEnabledDisabledToolbar,
listeners: {
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners,
[ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners,
@ -94,17 +102,11 @@ const moreTools = [
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
createButton({
id: 'ImageOverlay',
id: 'ImageOverlayViewer',
icon: 'toggle-dicom-overlay',
label: 'Image Overlay',
tooltip: 'Toggle Image Overlay',
commands: {
commandName: 'setToolEnabled',
commandOptions: {
toolName: 'ImageOverlayViewer',
toggle: true, // Toggle the tool on/off upon click
},
},
commands: toggleEnabledDisabledToolbar,
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
createButton({
@ -175,8 +177,8 @@ const moreTools = [
icon: 'icon-tool-loupe',
label: 'Loupe',
tooltip: 'Loupe',
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
commands: toggleActiveDisabledToolbar,
evaluate: 'evaluate.cornerstoneTool.toggle',
}),
createButton({
id: 'UltrasoundDirectionalTool',

View File

@ -1,10 +1,8 @@
// TODO: torn, can either bake this here; or have to create a whole new button type
// Only ways that you can pass in a custom React component for render :l
import { WindowLevelMenuItem } from '@ohif/ui';
import { defaults, ToolbarService } from '@ohif/core';
import type { Button } from '@ohif/core/types';
const { windowLevelPresets } = defaults;
const { createButton } = ToolbarService;
export const setToolActiveToolbar = {

View File

@ -87,8 +87,8 @@ function modeFactory({ modeConfiguration }) {
id: ohif.layout,
props: {
leftPanels: [ohif.leftPanel],
leftPanelDefaultClosed: true, // we have problem with rendering thumbnails for microscopy images
rightPanelDefaultClosed: true, // we do not have the save microscopy measurements yet
leftPanelClosed: true, // we have problem with rendering thumbnails for microscopy images
rightPanelClosed: true, // we do not have the save microscopy measurements yet
rightPanels: ['@ohif/extension-dicom-microscopy.panelModule.measure'],
viewports: [
{

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

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