feat: cornerstone3D stack and volume viewports (#2787)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

* fix handle the active tool when a new viewport is added

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

* feat: re-working measurement tracking mode with cornerstone3d (#2805)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: Add Measurement tracking mode with cs3D (#2789)

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

* feat: remove the need for separate mapper for SR hydration

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

* fix: integration cypress tests with cornerstone3D and add CINE tool (#2795)

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

* fix: drag and drop SR into SR viewport (#2803)

* update yarn lock

Co-authored-by: Erik <erik.sweed@gmail.com>

* update pathnames to match v3-stable

* update the e2e pathname

* fix: various bugs with regard to tracking workflow (#2811)

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

* feat: Add backward compatibility for SR hydration with legacy cornerstone

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

* feat: volume api and TMTV mode (#2817)

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

* feat: Add custom load strategy for volume viewports via HP

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

* fix: fusion to use pt in tmtv mode and measuremet mappings

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

* feat: use the new prefetch stack in the cs3d (#2820)

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

* fix: various bugs for tmtv mode thresholding and new icons (#2823)

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

* fix: Fix issues with CPU viewport flipping, including config files

* fix: bump cornerstone to fix magnify tool

* fix: Bump Cornerstone version to fix resetCamera issue in StackViewport

* ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys

* fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors

* feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838)

Also works with the compile time add that the existing cli uses, so that both build types work.
Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes.

* bump: dependency versions to fix hydration bugs (#2848)

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-07-27 12:39:04 -04:00
1 parent 86dcaf6293
commit bd1c38c00c
668 files changed
+41669 -13410

No files matched your search

+8
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 });
};
+43
View File
@@ -0,0 +1,43 @@
const webpack = require('webpack');
const merge = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, './..');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'OHIFExtDICOMSR',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
],
});
};
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
# TMTV Extension
+1
View File
@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@ohif/extension-tmtv",
"version": "3.0.1",
"description": "OHIF extension for Total Metabolic Tumore Volume",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.tsx",
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0",
"dcmjs": "0.22.0",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2"
},
"dependencies": {
"@babel/runtime": "7.7.6",
"classnames": "^2.2.6"
}
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { Input, Button } from '@ohif/ui';
import { classes, DicomMetadataStore } from '@ohif/core';
import { useTranslation } from 'react-i18next';
const DEFAULT_MEATADATA = {
PatientWeight: null,
PatientSex: null,
SeriesTime: null,
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: null,
RadionuclideHalfLife: null,
RadiopharmaceuticalStartTime: null,
},
};
/*
* PETSUV panel enables the user to modify the patient related information, such as
* patient sex, patientWeight. This is allowed since
* sometimes these metadata are missing or wrong. By changing them
* @param param0
* @returns
*/
export default function PanelPetSUV({ servicesManager, commandsManager }) {
const { t } = useTranslation('PanelSUV');
const { DisplaySetService } = servicesManager.services;
const [metadata, setMetadata] = useState(DEFAULT_MEATADATA);
const [ptDisplaySet, setPtDisplaySet] = useState(null);
const handleMetadataChange = useCallback(
metadata => {
setMetadata(prevState => {
const newState = { ...prevState };
Object.keys(metadata).forEach(key => {
if (typeof metadata[key] === 'object') {
newState[key] = {
...prevState[key],
...metadata[key],
};
} else {
newState[key] = metadata[key];
}
});
return newState;
});
},
[metadata]
);
const getMatchingPTDisplaySet = useCallback(() => {
const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet');
if (!ptDisplaySet) {
return;
}
const metadata = commandsManager.runCommand('getPTMetadata', {
ptDisplaySet,
});
return {
ptDisplaySet,
metadata,
};
}, []);
useEffect(() => {
const displaySets = DisplaySetService.activeDisplaySets;
if (!displaySets.length) {
return;
}
const displaySetInfo = getMatchingPTDisplaySet();
if (!displaySetInfo) {
return;
}
const { ptDisplaySet, metadata } = displaySetInfo;
setPtDisplaySet(ptDisplaySet);
setMetadata(metadata);
}, []);
// get the patientMetadata from the StudyInstanceUIDs and update the state
useEffect(() => {
const { unsubscribe } = DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SETS_ADDED,
() => {
const displaySetInfo = getMatchingPTDisplaySet();
if (!displaySetInfo) {
return;
}
const { ptDisplaySet, metadata } = displaySetInfo;
setPtDisplaySet(ptDisplaySet);
setMetadata(metadata);
}
);
return () => {
unsubscribe();
};
}, []);
function updateMetadata() {
if (!ptDisplaySet) {
throw new Error('No ptDisplaySet found');
}
// metadata should be dcmjs naturalized
DicomMetadataStore.updateMetadataForSeries(
ptDisplaySet.StudyInstanceUID,
ptDisplaySet.SeriesInstanceUID,
metadata
);
// update the displaySets
DisplaySetService.setDisplaySetMetadataInvalidated(
ptDisplaySet.displaySetInstanceUID
);
}
return (
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
{
<div className="flex flex-col">
<div className="flex flex-col p-4 space-y-2 bg-primary-dark">
<Input
label={t('Patient Sex')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
label={t('Patient Weight')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
/>
<Input
label={t('Total Dose')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={
metadata.RadiopharmaceuticalInformationSequence
.RadionuclideTotalDose || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
label={t('Half Life')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={
metadata.RadiopharmaceuticalInformationSequence
.RadionuclideHalfLife || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
label={t('Injection Time')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={
metadata.RadiopharmaceuticalInformationSequence
.RadiopharmaceuticalStartTime || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
label={t('Acquisition Time')}
labelClassName="text-white"
className="mt-1 mb-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
</div>
<Button
color="primary"
onClick={updateMetadata}
className="px-1 py-1 mx-4 mt-2 text-xs text-white border-b border-transparent"
>
Reload Data
</Button>
</div>
}
</div>
);
}
PanelPetSUV.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,
};
@@ -0,0 +1,44 @@
import React from 'react';
import { Button, ButtonGroup } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
function ExportReports({ segmentations, tmtvValue, config, commandsManager }) {
const { t } = useTranslation('PanelSUVExport');
return (
<>
{segmentations?.length ? (
<div className="flex justify-center mt-4 space-x-2">
<ButtonGroup color="black" size="inherit">
<Button
className="px-2 py-2 text-base"
disabled={tmtvValue === null}
onClick={() => {
commandsManager.runCommand('exportTMTVReportCSV', {
segmentations,
tmtv: tmtvValue,
config,
});
}}
>
{t('Export CSV')}
</Button>
</ButtonGroup>
<ButtonGroup color="black" size="inherit">
<Button
className="px-2 py-2 text-base"
onClick={() => {
commandsManager.runCommand('createTMTVRTReport');
}}
disabled={tmtvValue === null}
>
{t('Create RT Report')}
</Button>
</ButtonGroup>
</div>
) : null}
</>
);
}
export default ExportReports;
@@ -0,0 +1,322 @@
import React, { useEffect, useState, useCallback, useReducer } from 'react';
import PropTypes from 'prop-types';
import { SegmentationTable, Button, Icon } from '@ohif/ui';
import classnames from 'classnames';
import { useTranslation } from 'react-i18next';
import segmentationEditHandler from './segmentationEditHandler';
import ExportReports from './ExportReports';
import ROIThresholdConfiguration, {
ROI_STAT,
} from './ROIThresholdConfiguration';
const LOWER_THRESHOLD_DEFAULT = 0;
const UPPER_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, lower, upper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
lower: lower ? lower : state.lower,
upper: upper ? upper : state.upper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
export default function PanelRoiThresholdSegmentation({
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,
lower: LOWER_THRESHOLD_DEFAULT,
upper: UPPER_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 data = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const notYetUpdatedAtSource = true;
SegmentationService.addOrUpdateSegmentation(
selectedSegmentationId,
{
...segmentation,
data: Object.assign(segmentation.data, data),
text: [`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();
};
}, []);
/**
* Toggle visibility of the segmentation
*/
useEffect(() => {
const subscription = SegmentationService.subscribe(
SegmentationService.EVENTS.SEGMENTATION_VISIBILITY_CHANGED,
({ segmentation }) => {
runCommand('toggleSegmentationVisibility', {
segmentationId: segmentation.id,
});
}
);
return () => {
subscription.unsubscribe();
};
}, [SegmentationService]);
/**
* Whenever the segmentations change, update the TMTV calculations
*/
useEffect(() => {
if (!selectedSegmentationId && segmentations.length > 0) {
setSelectedSegmentationId(segmentations[0].id);
}
handleTMTVCalculation();
}, [segmentations, selectedSegmentationId]);
return (
<div className="flex flex-col justify-between h-full">
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
<div className="flex mx-4 my-4 mb-4 space-x-4">
<Button
color="primary"
onClick={() => {
setLabelmapLoading(true);
setTimeout(() => {
runCommand('createNewLabelmapFromPT').then(segmentationId => {
setLabelmapLoading(false);
setSelectedSegmentationId(segmentationId);
});
});
}}
className={classnames(
'text-xs text-white border-b border-transparent'
)}
>
{labelmapLoading ? 'loading ...' : 'New Label'}
</Button>
<Button
color="primary"
onClick={handleROIThresholding}
className="text-xs text-white border-b border-transparent "
>
Run
</Button>
</div>
<div
className="flex items-center justify-around h-8 mb-2 border-t outline-none cursor-pointer select-none bg-secondary-dark first:border-0 border-secondary-light"
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')}
amount={segmentations.length}
segmentations={segmentations}
activeSegmentationId={selectedSegmentationId}
onClick={id => {
runCommand('setSegmentationActiveForToolGroups', {
segmentationId: id,
});
setSelectedSegmentationId(id);
}}
onToggleVisibility={id => {
SegmentationService.toggleSegmentationsVisibility([id]);
}}
onToggleVisibilityAll={ids => {
SegmentationService.toggleSegmentationsVisibility(ids);
}}
onDelete={id => {
SegmentationService.remove(id);
}}
onEdit={id => {
segmentationEditHandler({
id,
servicesManager,
});
}}
/>
) : null}
</div>
{tmtvValue !== null ? (
<div className="flex items-baseline justify-between px-2 py-1 mt-4 bg-secondary-dark">
<span className="text-base font-bold tracking-widest text-white uppercase">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue} mL`}</div>
</div>
) : null}
<ExportReports
segmentations={segmentations}
tmtvValue={tmtvValue}
config={config}
commandsManager={commandsManager}
/>
</div>
<div
className="opacity-50 hover:opacity-80 flex items-center justify-center text-blue-400 mb-4 cursor-pointer"
onClick={() => {
// navigate to a url in a new tab
window.open(
'https://github.com/OHIF/Viewers/blob/feat/segmentation-service/modes/tmtv/README.md',
'_blank'
);
}}
>
<Icon
width="15px"
height="15px"
name={'info'}
className={'ml-4 mr-3 text-primary-active'}
/>
<span>{'About'}</span>
</div>
</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,
toggleSegmentationsVisibility: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
@@ -0,0 +1,116 @@
import React from 'react';
import { Input, Select, Button, ButtonGroup } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
export const ROI_STAT = 'roi_stat';
const RANGE = 'range';
const options = [
{ value: ROI_STAT, label: 'Max', placeHolder: 'Max' },
{ value: RANGE, label: 'Range', placeHolder: 'Range' },
];
function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
const { t } = useTranslation('ROIThresholdConfiguration');
return (
<div className="flex flex-col px-4 space-y-4 bg-primary-dark">
<div className="flex items-end space-x-2">
<div className="flex flex-col w-1/2 mt-2 ">
<Select
label={t('Strategy')}
closeMenuOnSelect={true}
className="mr-2 bg-black border-primary-main "
options={options}
placeholder={
options.find(option => option.value === config.strategy)
.placeHolder
}
value={config.strategy}
onChange={({ value }) => {
dispatch({
type: 'setStrategy',
payload: {
strategy: value,
},
});
}}
/>
</div>
<div className="w-1/2">
<ButtonGroup color="black" size="inherit">
<Button
className="px-2 py-2 text-base"
onClick={() => runCommand('setStartSliceForROIThresholdTool')}
>
{t('Start')}
</Button>
</ButtonGroup>
<ButtonGroup color="black" size="inherit">
<Button
className="px-2 py-2 text-base"
onClick={() => runCommand('setEndSliceForROIThresholdTool')}
>
{t('End')}
</Button>
</ButtonGroup>
</div>
</div>
{config.strategy === ROI_STAT && (
<Input
label={t('Percentage of Max SUV')}
labelClassName="text-white"
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={config.weight}
onChange={e => {
dispatch({
type: 'setWeight',
payload: e.target.value,
});
}}
/>
)}
{config.strategy !== ROI_STAT && (
<div className="flex justify-between">
<Input
label={t('Lower')}
labelClassName="text-white"
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={config.lower}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
lower: e.target.value,
},
});
}}
/>
<Input
label={t('Upper')}
labelClassName="text-white"
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={config.upper}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
upper: e.target.value,
},
});
}}
/>
</div>
)}
</div>
);
}
export default ROIThresholdConfiguration;
@@ -0,0 +1,3 @@
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
export default PanelROIThresholdSegmentation;
@@ -0,0 +1,70 @@
import React from 'react';
import { Input, Dialog } from '@ohif/ui';
function segmentationItemEditHandler({ id, servicesManager }) {
const { SegmentationService, UIDialogService } = servicesManager.services;
const segmentation = SegmentationService.getSegmentation(id);
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save': {
SegmentationService.addOrUpdateSegmentation(
id,
{
...segmentation,
...value,
},
true
);
}
}
UIDialogService.dismiss({ id: 'enter-annotation' });
};
UIDialogService.create({
id: 'enter-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Enter your annotation',
noCloseButton: true,
value: { label: segmentation.label || '' },
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
};
return (
<div className="p-4 bg-primary-dark">
<Input
autoFocus
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
/>
</div>
);
},
actions: [
// temp: swap button types until colors are updated
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
},
});
}
export default segmentationItemEditHandler;
+4
View File
@@ -0,0 +1,4 @@
import PanelPetSUV from './PanelPetSUV';
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
export { PanelPetSUV, PanelROIThresholdSegmentation };
+696
View File
@@ -0,0 +1,696 @@
import { vec3 } from 'gl-matrix';
import OHIF from '@ohif/core';
import * as cs from '@cornerstonejs/core';
import * as csTools from '@cornerstonejs/tools';
import { classes } from '@ohif/core';
import getThresholdValues from './utils/getThresholdValue';
import calculateSuvPeak from './utils/calculateSUVPeak';
import calculateTMTV from './utils/calculateTMTV';
import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
const metadataProvider = classes.MetadataProvider;
const RECTANGLE_ROI_THRESHOLD_MANUAL = 'RectangleROIStartEndThreshold';
const commandsModule = ({
servicesManager,
commandsManager,
extensionManager,
}) => {
const {
ViewportGridService,
UINotificationService,
DisplaySetService,
HangingProtocolService,
ToolGroupService,
CornerstoneViewportService,
} = servicesManager.services;
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { getEnabledElement } = utilityModule.exports;
function _getActiveViewportsEnabledElement() {
const { activeViewportIndex } = ViewportGridService.getState();
const { element } = getEnabledElement(activeViewportIndex) || {};
const enabledElement = cs.getEnabledElement(element);
return enabledElement;
}
function _getMatchedViewportsToolGroupIds() {
const [matchedViewports] = HangingProtocolService.getState();
const toolGroupIds = [];
matchedViewports.forEach(({ viewportOptions }) => {
const { toolGroupId } = viewportOptions;
if (toolGroupIds.indexOf(toolGroupId) === -1) {
toolGroupIds.push(toolGroupId);
}
});
return toolGroupIds;
}
const actions = {
getMatchingPTDisplaySet: () => {
// Todo: this is assuming that the hanging protocol has successfully matched
// the correct PT. For future, we should have a way to filter out the PTs
// that are in the viewer layout (but then we have the problem of the attenuation
// corrected PT vs the non-attenuation correct PT)
const matches = HangingProtocolService.getDisplaySetsMatchDetails();
const matchedSeriesInstanceUIDs = Array.from(matches.values()).map(
({ SeriesInstanceUID }) => SeriesInstanceUID
);
let ptDisplaySet = null;
for (const SeriesInstanceUID of matchedSeriesInstanceUIDs) {
const displaySets = DisplaySetService.getDisplaySetsForSeries(
SeriesInstanceUID
);
if (!displaySets || displaySets.length === 0) {
continue;
}
const displaySet = displaySets[0];
if (displaySet.Modality !== 'PT') {
continue;
}
ptDisplaySet = displaySet;
}
return ptDisplaySet;
},
getPTMetadata: ({ ptDisplaySet }) => {
const dataSource = extensionManager.getDataSources()[0];
const imageIds = dataSource.getImageIdsForDisplaySet(ptDisplaySet);
const firstImageId = imageIds[0];
const instance = metadataProvider.get('instance', firstImageId);
if (instance.Modality !== 'PT') {
return;
}
const metadata = {
SeriesTime: instance.SeriesTime,
Modality: instance.Modality,
PatientSex: instance.PatientSex,
PatientWeight: instance.PatientWeight,
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose:
instance.RadiopharmaceuticalInformationSequence[0]
.RadionuclideTotalDose,
RadionuclideHalfLife:
instance.RadiopharmaceuticalInformationSequence[0]
.RadionuclideHalfLife,
RadiopharmaceuticalStartTime:
instance.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartTime,
RadiopharmaceuticalStartDateTime:
instance.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartDateTime,
},
};
return metadata;
},
createNewLabelmapFromPT: async () => {
// Create a segmentation of the same resolution as the source data
// using volumeLoader.createAndCacheDerivedVolume.
const ptDisplaySet = actions.getMatchingPTDisplaySet();
if (!ptDisplaySet) {
UINotificationService.error('No matching PT display set found');
return;
}
const segmentationId = await commandsManager.runCommand(
'createSegmentationForDisplaySet',
{
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
}
);
const toolGroupIds = _getMatchedViewportsToolGroupIds();
const representationType =
csTools.Enums.SegmentationRepresentations.Labelmap;
for (const toolGroupId of toolGroupIds) {
await commandsManager.runCommand(
'addSegmentationRepresentationToToolGroup',
{ segmentationId, toolGroupId: toolGroupId, representationType }
);
}
return segmentationId;
},
setSegmentationActiveForToolGroups: ({ segmentationId }) => {
const toolGroupIds = _getMatchedViewportsToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations(
toolGroupId
);
if (segmentationRepresentations.length === 0) {
return;
}
// Todo: this finds the first segmentation representation that matches the segmentationId
// If there are two labelmap representations from the same segmentation, this will not work
const representation = segmentationRepresentations.find(
representation => representation.segmentationId === segmentationId
);
csTools.segmentation.activeSegmentation.setActiveSegmentationRepresentation(
toolGroupId,
representation.segmentationRepresentationUID
);
});
},
thresholdSegmentationByRectangleROITool: ({ segmentationId, config }) => {
const segmentation = csTools.segmentation.state.getSegmentation(
segmentationId
);
const { representationData } = segmentation;
const { volumeId: segVolumeId } = representationData[
csTools.Enums.SegmentationRepresentations.Labelmap
];
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
const labelmapVolume = cs.cache.getVolume(segmentationId);
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
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
);
if (annotationUIDs.length === 0) {
UINotificationService.show({
title: 'Commands Module',
message: 'No ROIThreshold Tool is Selected',
type: 'error',
});
return;
}
const { lower, upper } = getThresholdValues(
annotationUIDs,
referencedVolume,
config
);
const configToUse = {
lower,
upper,
overwrite: true,
};
return csTools.utilities.segmentation.rectangleROIThresholdVolumeByRange(
annotationUIDs,
labelmapVolume,
[referencedVolume],
configToUse
);
},
toggleSegmentationVisibility: ({ segmentationId }) => {
const toolGroupIds = _getMatchedViewportsToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations(
toolGroupId
);
if (segmentationRepresentations.length === 0) {
return;
}
// Todo: this finds the first segmentation representation that matches the segmentationId
// If there are two labelmap representations from the same segmentation, this will not work
const representation = segmentationRepresentations.find(
representation => representation.segmentationId === segmentationId
);
const visibility = csTools.segmentation.config.visibility.getSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID
);
csTools.segmentation.config.visibility.setSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID,
!visibility
);
});
},
calculateSuvPeak: ({ labelmap }) => {
const { referencedVolumeId } = labelmap;
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
);
const annotations = annotationUIDs.map(annotationUID =>
csTools.annotation.state.getAnnotation(annotationUID)
);
const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations);
return {
suvPeak: suvPeak.mean,
suvMax: suvPeak.max,
suvMaxIJK: suvPeak.maxIJK,
suvMaxLPS: suvPeak.maxLPS,
};
},
getLesionStats: ({ labelmap, segmentIndex = 1 }) => {
const { scalarData, spacing } = labelmap;
const { scalarData: referencedScalarData } = cs.cache.getVolume(
labelmap.referencedVolumeId
);
let segmentationMax = -Infinity;
let segmentationMin = Infinity;
let segmentationValues = [];
let voxelCount = 0;
for (let i = 0; i < scalarData.length; i++) {
if (scalarData[i] === segmentIndex) {
const value = referencedScalarData[i];
segmentationValues.push(value);
if (value > segmentationMax) {
segmentationMax = value;
}
if (value < segmentationMin) {
segmentationMin = value;
}
voxelCount++;
}
}
const stats = {
minValue: segmentationMin,
maxValue: segmentationMax,
meanValue: segmentationValues.reduce((a, b) => a + b, 0) / voxelCount,
stdValue: Math.sqrt(
segmentationValues.reduce((a, b) => a + b * b, 0) / voxelCount -
segmentationValues.reduce((a, b) => a + b, 0) / voxelCount ** 2
),
volume: voxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3,
};
return stats;
},
calculateLesionGlycolysis: ({ lesionStats }) => {
const { meanValue, volume } = lesionStats;
return {
lesionGlyoclysisStats: volume * meanValue,
};
},
calculateTMTV: ({ segmentations }) => {
const labelmaps = commandsManager.runCommand('getLabelmapVolumes', {
segmentations,
});
if (!labelmaps.length) {
return;
}
return calculateTMTV(labelmaps);
},
exportTMTVReportCSV: ({ segmentations, tmtv, config }) => {
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);
},
getTotalLesionGlycolysis: ({ segmentations }) => {
const labelmapVolumes = commandsManager.runCommand('getLabelmapVolumes', {
segmentations,
});
let mergedLabelmap;
// merge labelmap will through an error if labels maps are not the same size
// or same direction or ....
try {
mergedLabelmap = csTools.utilities.segmentation.createMergedLabelmapForIndex(
labelmapVolumes
);
} catch (e) {
console.error('commandsModule::getTotalLesionGlycolysis', e);
return;
}
// grabbing the first labelmap referenceVolume since it will be the same for all
const { referencedVolumeId, spacing } = labelmapVolumes[0];
if (!referencedVolumeId) {
console.error(
'commandsModule::getTotalLesionGlycolysis:No referencedVolumeId found'
);
}
const ptVolume = cs.cache.getVolume(referencedVolumeId);
const mergedLabelData = mergedLabelmap.scalarData;
if (mergedLabelData.length !== ptVolume.scalarData.length) {
console.error(
'commandsModule::getTotalLesionGlycolysis:Labelmap and ptVolume are not the same size'
);
}
let suv = 0;
let totalLesionVoxelCount = 0;
for (let i = 0; i < mergedLabelData.length; i++) {
// if not background
if (mergedLabelData[i] !== 0) {
suv += ptVolume.scalarData[i];
totalLesionVoxelCount += 1;
}
}
// Average SUV for the merged labelmap
const averageSuv = suv / totalLesionVoxelCount;
// total Lesion Glycolysis [suv * ml]
return (
averageSuv *
totalLesionVoxelCount *
spacing[0] *
spacing[1] *
spacing[2] *
1e-3
);
},
setStartSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const { focalPoint, viewPlaneNormal } = viewport.getCamera();
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
);
const annotationUID = selectedAnnotationUIDs[0];
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
const { handles } = annotation.data;
const { points } = handles;
// get the current slice Index
const sliceIndex = viewport.getCurrentImageIdIndex();
annotation.data.startSlice = sliceIndex;
// distance between camera focal point and each point on the rectangle
const newPoints = points.map(point => {
const distance = vec3.create();
vec3.subtract(distance, focalPoint, point);
// distance in the direction of the viewPlaneNormal
const distanceInViewPlane = vec3.dot(distance, viewPlaneNormal);
// new point is current point minus distanceInViewPlane
const newPoint = vec3.create();
vec3.scaleAndAdd(newPoint, point, viewPlaneNormal, distanceInViewPlane);
return newPoint;
//
});
handles.points = newPoints;
// IMPORTANT: invalidate the toolData for the cached stat to get updated
// and re-calculate the projection points
annotation.invalidated = true;
viewport.render();
},
setEndSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
RECTANGLE_ROI_THRESHOLD_MANUAL
);
const annotationUID = selectedAnnotationUIDs[0];
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
// get the current slice Index
const sliceIndex = viewport.getCurrentImageIdIndex();
annotation.data.endSlice = sliceIndex;
// IMPORTANT: invalidate the toolData for the cached stat to get updated
// and re-calculate the projection points
annotation.invalidated = true;
viewport.render();
},
createTMTVRTReport: () => {
// get all Rectangle ROI annotation
const stateManager = csTools.annotation.state.getDefaultAnnotationManager();
const annotations = [];
Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => {
const forAnnotations = stateManager.annotations[frameOfReferenceUID];
const ROIAnnotations = forAnnotations[RECTANGLE_ROI_THRESHOLD_MANUAL];
annotations.push(...ROIAnnotations);
});
commandsManager.runCommand('exportRTReportForAnnotations', {
annotations,
});
},
getSegmentationCSVReport: ({ segmentations }) => {
if (!segmentations || !segmentations.length) {
segmentations = SegmentationService.getSegmentations();
}
let report = {};
for (const segmentation of segmentations) {
const { id, label, data } = segmentation;
const segReport = { id, label };
if (!data) {
report[id] = segReport;
continue;
}
Object.keys(data).forEach(key => {
if (typeof data[key] !== 'object') {
segReport[key] = data[key];
} else {
Object.keys(data[key]).forEach(subKey => {
const newKey = `${key}_${subKey}`;
segReport[newKey] = data[key][subKey];
});
}
});
const labelmapVolume = cornerstone.cache.getVolume(id);
if (!labelmapVolume) {
report[id] = segReport;
continue;
}
const referencedVolumeId = labelmapVolume.referencedVolumeId;
segReport.referencedVolumeId = referencedVolumeId;
const referencedVolume = cornerstone.cache.getVolume(
referencedVolumeId
);
if (!referencedVolume) {
report[id] = segReport;
continue;
}
if (!referencedVolume.imageIds || !referencedVolume.imageIds.length) {
report[id] = segReport;
continue;
}
const firstImageId = referencedVolume.imageIds[0];
const instance = OHIF.classes.MetadataProvider.get(
'instance',
firstImageId
);
if (!instance) {
report[id] = segReport;
continue;
}
report[id] = {
...segReport,
PatientID: instance.PatientID,
PatientName: instance.PatientName.Alphabetic,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
StudyDate: instance.StudyDate,
};
}
return report;
},
exportRTReportForAnnotations: ({ annotations }) => {
dicomRTAnnotationExport(annotations);
},
setFusionPTColormap: ({ toolGroupId, colormap }) => {
const toolGroup = ToolGroupService.getToolGroup(toolGroupId);
const ptDisplaySet = actions.getMatchingPTDisplaySet();
if (!ptDisplaySet) {
return;
}
const fusionViewportIds = toolGroup.getViewportIds();
let viewports = [];
fusionViewportIds.forEach(viewportId => {
const viewportInfo = CornerstoneViewportService.getViewportInfo(
viewportId
);
const viewportIndex = viewportInfo.getViewportIndex();
commandsManager.runCommand('setViewportColormap', {
viewportIndex,
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
colormap,
});
viewports.push(
CornerstoneViewportService.getCornerstoneViewport(viewportId)
);
});
viewports.forEach(viewport => {
viewport.render();
});
},
};
const definitions = {
setEndSliceForROIThresholdTool: {
commandFn: actions.setEndSliceForROIThresholdTool,
storeContexts: [],
options: {},
},
setStartSliceForROIThresholdTool: {
commandFn: actions.setStartSliceForROIThresholdTool,
storeContexts: [],
options: {},
},
getMatchingPTDisplaySet: {
commandFn: actions.getMatchingPTDisplaySet,
storeContexts: [],
options: {},
},
getPTMetadata: {
commandFn: actions.getPTMetadata,
storeContexts: [],
options: {},
},
createNewLabelmapFromPT: {
commandFn: actions.createNewLabelmapFromPT,
storeContexts: [],
options: {},
},
setSegmentationActiveForToolGroups: {
commandFn: actions.setSegmentationActiveForToolGroups,
storeContexts: [],
options: {},
},
thresholdSegmentationByRectangleROITool: {
commandFn: actions.thresholdSegmentationByRectangleROITool,
storeContexts: [],
options: {},
},
toggleSegmentationVisibility: {
commandFn: actions.toggleSegmentationVisibility,
storeContexts: [],
options: {},
},
getTotalLesionGlycolysis: {
commandFn: actions.getTotalLesionGlycolysis,
storeContexts: [],
options: {},
},
calculateSuvPeak: {
commandFn: actions.calculateSuvPeak,
storeContexts: [],
options: {},
},
getLesionStats: {
commandFn: actions.getLesionStats,
storeContexts: [],
options: {},
},
calculateTMTV: {
commandFn: actions.calculateTMTV,
storeContexts: [],
options: {},
},
exportTMTVReportCSV: {
commandFn: actions.exportTMTVReportCSV,
storeContexts: [],
options: {},
},
createTMTVRTReport: {
commandFn: actions.createTMTVRTReport,
storeContexts: [],
options: {},
},
getSegmentationCSVReport: {
commandFn: actions.getSegmentationCSVReport,
storeContexts: [],
options: {},
},
exportRTReportForAnnotations: {
commandFn: actions.exportRTReportForAnnotations,
storeContexts: [],
options: {},
},
setFusionPTColormap: {
commandFn: actions.setFusionPTColormap,
storeContexts: [],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'TMTV:CORNERSTONE',
};
};
export default commandsModule;
@@ -0,0 +1,601 @@
const ptCT = {
id: 'test',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
availableTo: {},
editableBy: {},
toolGroupIds: [
'ctToolGroup',
'ptToolGroup',
'fusionToolGroup',
'mipToolGroup',
],
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'StudyInstanceUID',
constraint: {
contains: {
value: '1.3.6.1.4.',
},
},
required: false,
},
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'StudyDescription',
constraint: {
contains: {
value: 'PETCT',
},
},
required: false,
},
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'StudyDescription',
constraint: {
contains: {
value: 'PET/CT',
},
},
required: false,
},
],
stages: [
{
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
],
},
},
displaySets: [
{
id: 'ctDisplaySet',
imageMatchingRules: [],
seriesMatchingRules: [
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'Modality',
constraint: {
equals: {
value: 'CT',
},
},
required: true,
},
{
id: 'vSjk7NCYjtdS3XZAw',
weight: 1,
attribute: 'SeriesNumber',
constraint: {
equals: {
value: '4',
},
},
required: false,
},
{
id: 'vSjk7NCYjtdS3XZAw',
weight: 1,
attribute: 'SeriesDescription',
constraint: {
contains: {
value: 'CT',
},
},
required: false,
},
{
id: 'vSjk7NCYjtdS3XZAw',
weight: 1,
attribute: 'SeriesDescription',
constraint: {
contains: {
value: 'CT WB',
},
},
required: false,
},
],
studyMatchingRules: [],
},
{
id: 'ptDisplaySet',
imageMatchingRules: [],
seriesMatchingRules: [
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'Modality',
constraint: {
equals: {
value: 'PT',
},
},
required: true,
},
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'SeriesDescription',
constraint: {
contains: {
value: 'Corrected',
},
},
required: false,
},
{
id: 'GPEYqFLv2dwzCM322',
weight: 2,
attribute: 'SeriesDescription',
constraint: {
doesNotContain: {
value: 'Uncorrected',
},
},
required: false,
},
],
studyMatchingRules: [],
},
],
viewports: [
{
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // '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: 'ctToolGroup',
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: 'ctToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptAXIAL',
viewportType: 'volume',
background: [1, 1, 1],
orientation: 'axial',
toolGroupId: 'ptToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptCORONAL',
viewportType: 'volume',
orientation: 'coronal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'fusionToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // '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,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// 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,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// 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,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'mipSagittal',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'mipToolGroup',
// Custom props can be used to set custom properties which extensions
// can react on.
customViewportOptions: {
// We use viewportDisplay to filter the viewports which are displayed
// in mip and we set the scrollbar according to their rotation index
// in the cornerstone extension.
hideOverlays: true,
},
},
displaySets: [
{
options: {
blendMode: 'MIP',
slabThickness: 'fullVolume',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
],
createdDate: '2021-02-23T18:32:42.850Z',
},
],
numberOfPriorsReferenced: -1,
};
function getHangingProtocolModule() {
return [
{
name: 'ptCT',
protocols: [ptCT],
},
];
}
export default getHangingProtocolModule;
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels';
// TODO:
// - No loading UI exists yet
// - cancel promises when component is destroyed
// - show errors in UI for thumbnails if promise fails
function getPanelModule({
commandsManager,
extensionManager,
servicesManager,
}) {
const wrappedPanelPetSuv = () => {
return (
<PanelPetSUV
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
const wrappedROIThresholdSeg = () => {
return (
<PanelROIThresholdSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
return [
{
name: 'petSUV',
iconName: 'edit-patient',
iconLabel: 'PET SUV',
label: 'PET-SUV',
component: wrappedPanelPetSuv,
},
{
name: 'ROIThresholdSeg',
iconName: 'tool-create-threshold',
iconLabel: 'Threshold Seg',
label: 'Threshold-Seg',
component: wrappedROIThresholdSeg,
},
];
}
export default getPanelModule;
+5
View File
@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };
+34
View File
@@ -0,0 +1,34 @@
import { id } from './id';
import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import init from './init';
import commandsModule from './commandsModule';
/**
*
*/
const tmtvExtension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration({
servicesManager,
commandsManager,
extensionManager,
configuration = {},
}) {
init({ servicesManager, commandsManager, extensionManager, configuration });
},
getPanelModule,
getHangingProtocolModule,
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
};
export default tmtvExtension;
+56
View File
@@ -0,0 +1,56 @@
import {
addTool,
RectangleROIStartEndThresholdTool,
} from '@cornerstonejs/tools';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import colormaps from './utils/colormaps';
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
/**
*
* @param {Object} servicesManager
* @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig
*/
export default function init({ servicesManager, extensionManager }) {
const {
MeasurementService,
DisplaySetService,
CornerstoneViewportService,
} = servicesManager.services;
addTool(RectangleROIStartEndThresholdTool);
const { RectangleROIStartEndThreshold } = measurementServiceMappingsFactory(
MeasurementService,
DisplaySetService,
CornerstoneViewportService
);
const csTools3DVer1MeasurementSource = MeasurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
MeasurementService.addMapping(
csTools3DVer1MeasurementSource,
'RectangleROIStartEndThreshold',
RectangleROIStartEndThreshold.matchingCriteria,
RectangleROIStartEndThreshold.toAnnotation,
RectangleROIStartEndThreshold.toMeasurement
);
initColormaps(extensionManager);
}
function initColormaps(extensionManager) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { registerColormap } = utilityModule.exports;
colormaps.forEach(registerColormap);
}
@@ -0,0 +1,155 @@
import { Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
type AnnotationsForThresholding = {
data: {
handles: {
points: Types.Point3[];
};
cachedStats?: {
projectionPoints?: Types.Point3[][];
};
};
};
/**
* This method calculates the SUV peak on a segmented ROI from a reference PET
* volume. If a rectangle annotation is provided, the peak is calculated within that
* rectangle. Otherwise, the calculation is performed on the entire volume which
* will be slower but same result.
* @param viewport Viewport to use for the calculation
* @param labelmap Labelmap from which the mask is taken
* @param referenceVolume PET volume to use for SUV calculation
* @param toolData [Optional] list of toolData to use for SUV calculation
* @param segmentIndex The index of the segment to use for masking
* @returns
*/
function calculateSuvPeak(
labelmap: Types.IImageVolume,
referenceVolume: Types.IImageVolume,
annotations?: AnnotationsForThresholding[],
segmentIndex = 1
): {
max: number;
maxIJK: Types.Point3;
maxLPS: Types.Point3;
mean: number;
} {
if (referenceVolume.metadata.Modality !== 'PT') {
return;
}
if (labelmap.scalarData.length !== referenceVolume.scalarData.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;
let boundsIJK;
// Todo: using the first annotation for now
if (annotations && annotations[0].data?.cachedStats) {
const { projectionPoints } = annotations[0].data.cachedStats;
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now
const rectangleCornersIJK = pointsToUse.map(world => {
const ijk = vec3.fromValues(0, 0, 0);
referenceVolumeImageData.worldToIndex(world, ijk);
return ijk as Types.Point3;
});
boundsIJK = utilities.boundingBox.getBoundingBoxAroundShape(
rectangleCornersIJK,
dimensions
);
}
let max = 0;
let maxIJK = [0, 0, 0];
let maxLPS = [0, 0, 0];
const callback = ({ pointIJK, pointLPS }) => {
const offset = referenceVolumeImageData.computeOffsetIndex(pointIJK);
const value = labelmapData[offset];
if (value !== segmentIndex) {
return;
}
const referenceValue = referenceVolumeData[offset];
if (referenceValue > max) {
max = referenceValue;
maxIJK = pointIJK;
maxLPS = pointLPS;
}
};
utilities.pointInShapeCallback(
labelmapImageData,
() => true,
callback,
boundsIJK
);
const direction = labelmapImageData
.getDirection()
.slice(0, 3) as Types.Point3;
/**
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
* V = (4/3)πr3
*/
const radius = Math.pow(1 / ((4 / 3) * Math.PI), 1 / 3) * 10;
const diameter = radius * 2;
const secondaryCircleWorld = vec3.create();
const bottomWorld = vec3.create();
const topWorld = vec3.create();
referenceVolumeImageData.indexToWorld(<vec3>maxIJK, secondaryCircleWorld);
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
const suvPeakCirclePoints = [bottomWorld, topWorld] as [
Types.Point3,
Types.Point3
];
/**
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
* sphere
*/
let count = 0;
let acc = 0;
const suvPeakMeanCallback = ({ value }) => {
acc += value;
count += 1;
};
utilities.pointInSurroundingSphereCallback(
referenceVolumeImageData,
suvPeakCirclePoints,
suvPeakMeanCallback
);
const mean = acc / count;
return {
max,
maxIJK,
maxLPS,
mean,
};
}
export default calculateSuvPeak;
@@ -0,0 +1,44 @@
import { Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
/**
* Given a list of labelmaps (with the possibility of overlapping regions),
* and a referenceVolume, it calculates the total metabolic tumor volume (TMTV)
* by flattening and rasterizing each segment into a single labelmap and summing
* the total number of volume voxels. It should be noted that for this calculation
* we do not double count voxels that are part of multiple labelmaps.
* @param {} labelmaps
* @param {number} segmentIndex
* @returns {number} TMTV in ml
*/
function calculateTMTV(
labelmaps: Array<Types.IImageVolume>,
segmentIndex = 1
): number {
const volumeId = 'mergedLabelmap';
const mergedLabelmap = utilities.segmentation.createMergedLabelmapForIndex(
labelmaps,
segmentIndex,
volumeId
);
const { imageData, spacing } = mergedLabelmap;
const values = imageData
.getPointData()
.getScalars()
.getData();
// count non-zero values inside the outputData, this would
// consider the overlapping regions to be only counted once
const numVoxels = values.reduce((acc, curr) => {
if (curr > 0) {
return acc + 1;
}
return acc;
}, 0);
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
}
export default calculateTMTV;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
export default function createAndDownloadTMTVReport(
segReport,
additionalReportRows
) {
const firstReport = segReport[Object.keys(segReport)[0]];
const columns = Object.keys(firstReport);
const csv = [columns.join(',')];
Object.values(segReport).forEach(segmentation => {
const row = [];
columns.forEach(column => {
// if it is array then we need to replace , with space to avoid csv parsing error
row.push(
Array.isArray(segmentation[column])
? segmentation[column].join(' ')
: segmentation[column]
);
});
csv.push(row.join(','));
});
csv.push('');
csv.push('');
csv.push('');
csv.push(`Patient ID,${firstReport.PatientID}`);
csv.push(`Study Date,${firstReport.StudyDate}`);
csv.push('');
additionalReportRows.forEach(({ key, value: values }) => {
const temp = [];
temp.push(`${key}`);
Object.keys(values).forEach(k => {
temp.push(`${k}`);
temp.push(`${values[k]}`);
});
csv.push(temp.join(','));
});
const blob = new Blob([csv.join('\n')], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${firstReport.PatientID}_tmtv.csv`;
a.click();
}
@@ -0,0 +1,262 @@
import AnnotationToPointData from './measurements/AnnotationToPointData';
import dcmjs from 'dcmjs';
import { DicomMetadataStore } from '@ohif/core';
const { DicomMetaDictionary } = dcmjs.data;
export default class RTSSReport {
constructor() {}
/**
* Convert handles to RTSSReport report object containing the dcmjs dicom dataset.
*
* Note: The tool data needs to be formatted in a specific way, and currently
* it is limited to the RectangleROIStartEndTool in the Cornerstone.
*
* @param annotations Array of Cornerstone tool annotation data
* @param metadataProvider Metadata provider
* @param options report generation options
* @returns Report object containing the dataset
*/
static generateReport(annotations, metadataProvider, options) {
let dataset = initializeDataset(annotations, metadataProvider);
annotations.forEach((annotation, index) => {
const ContourSequence = AnnotationToPointData.convert(
annotation,
index,
metadataProvider,
options
);
dataset.StructureSetROISequence.push(
getStructureSetModule(annotation, index, metadataProvider)
);
dataset.ROIContourSequence.push(ContourSequence);
dataset.RTROIObservationsSequence.push(
getRTROIObservationsSequence(annotation, index, metadataProvider)
);
// ReferencedSeriesSequence
// Todo: handle more than one series
dataset.ReferencedSeriesSequence = getReferencedSeriesSequence(
annotation,
index,
metadataProvider
);
// ReferencedFrameOfReferenceSequence
dataset.ReferencedFrameOfReferenceSequence = getReferencedFrameOfReferenceSequence(
annotation,
metadataProvider,
dataset
);
});
const fileMetaInformationVersionArray = new Uint8Array(2);
fileMetaInformationVersionArray[1] = 1;
const _meta = {
FileMetaInformationVersion: {
Value: [fileMetaInformationVersionArray.buffer],
vr: 'OB',
},
TransferSyntaxUID: {
Value: ['1.2.840.10008.1.2.1'],
vr: 'UI',
},
ImplementationClassUID: {
Value: [DicomMetaDictionary.uid()], // TODO: could be git hash or other valid id
vr: 'UI',
},
ImplementationVersionName: {
Value: ['dcmjs'],
vr: 'SH',
},
};
dataset._meta = _meta;
return dataset;
}
/**
* Generate Cornerstone tool state from dataset
* @param {object} dataset dataset
* @param {object} hooks
* @param {function} hooks.getToolClass Function to map dataset to a tool class
* @returns
*/
static generateToolState(dataset, hooks = {}) {
// Todo
console.warn('RTSSReport.generateToolState not implemented');
}
}
function initializeDataset(annotations, metadataProvider) {
const rtSOPInstanceUID = DicomMetaDictionary.uid();
// get the first annotation data
const {
referencedImageId: imageId,
FrameOfReferenceUID,
} = annotations[0].metadata;
const { studyInstanceUID } = metadataProvider.get(
'generalSeriesModule',
imageId
);
const patientModule = getPatientModule(imageId, metadataProvider);
const rtSeriesModule = getRTSeriesModule(imageId, metadataProvider);
return {
StructureSetROISequence: [],
ROIContourSequence: [],
RTROIObservationsSequence: [],
ReferencedSeriesSequence: [],
ReferencedFrameOfReferenceSequence: [],
...patientModule,
...rtSeriesModule,
StudyInstanceUID: studyInstanceUID,
SOPClassUID: '1.2.840.10008.5.1.4.1.1.481.3', // RT Structure Set Storage
SOPInstanceUID: rtSOPInstanceUID,
Manufacturer: 'dcmjs',
Modality: 'RTSTRUCT',
FrameOfReferenceUID,
PositionReferenceIndicator: '',
StructureSetLabel: '',
StructureSetName: '',
ReferringPhysicianName: '',
OperatorsName: '',
StructureSetDate: DicomMetaDictionary.date(),
StructureSetTime: DicomMetaDictionary.time(),
};
}
function getPatientModule(imageId, metadataProvider) {
const generalSeriesModule = metadataProvider.get(
'generalSeriesModule',
imageId
);
const generalStudyModule = metadataProvider.get(
'generalStudyModule',
imageId
);
const patientStudyModule = metadataProvider.get(
'patientStudyModule',
imageId
);
const patientModule = metadataProvider.get('patientModule', imageId);
const patientDemographicModule = metadataProvider.get(
'patientDemographicModule',
imageId
);
return {
Modality: generalSeriesModule.modality,
PatientID: patientModule.patientId,
PatientName: patientModule.patientName,
PatientBirthDate: '',
PatientAge: patientStudyModule.patientAge,
PatientSex: patientDemographicModule.patientSex,
PatientWeight: patientStudyModule.patientWeight,
StudyDate: generalStudyModule.studyDate,
StudyTime: generalStudyModule.studyTime,
StudyID: 'ToDo',
AccessionNumber: generalStudyModule.accessionNumber,
};
}
function getReferencedFrameOfReferenceSequence(
toolData,
metadataProvider,
dataset
) {
const { referencedImageId: imageId, FrameOfReferenceUID } = toolData.metadata;
const instance = metadataProvider.get('instance', imageId);
const { SeriesInstanceUID } = instance;
const { ReferencedSeriesSequence } = dataset;
return [
{
FrameOfReferenceUID,
RTReferencedStudySequence: [
{
ReferencedSOPClassUID: dataset.SOPClassUID,
ReferencedSOPInstanceUID: dataset.SOPInstanceUID,
RTReferencedSeriesSequence: [
{
SeriesInstanceUID,
ContourImageSequence: [
...ReferencedSeriesSequence[0].ReferencedInstanceSequence,
],
},
],
},
],
},
];
}
function getReferencedSeriesSequence(toolData, index, metadataProvider) {
// grab imageId from toolData
const { referencedImageId: imageId } = toolData.metadata;
const instance = metadataProvider.get('instance', imageId);
const { SeriesInstanceUID, StudyInstanceUID } = instance;
const ReferencedSeriesSequence = [];
if (SeriesInstanceUID) {
const series = DicomMetadataStore.getSeries(
StudyInstanceUID,
SeriesInstanceUID
);
const ReferencedSeries = {
SeriesInstanceUID,
ReferencedInstanceSequence: [],
};
series.instances.forEach(instance => {
const { SOPInstanceUID, SOPClassUID } = instance;
ReferencedSeries.ReferencedInstanceSequence.push({
ReferencedSOPClassUID: SOPClassUID,
ReferencedSOPInstanceUID: SOPInstanceUID,
});
});
ReferencedSeriesSequence.push(ReferencedSeries);
}
return ReferencedSeriesSequence;
}
function getRTSeriesModule(imageId, metadataProvider) {
return {
SeriesInstanceUID: DicomMetaDictionary.uid(), // generate a new series instance uid
SeriesNumber: '99', // Todo:: what should be the series number?
};
}
function getStructureSetModule(toolData, index, metadataProvider) {
const { FrameOfReferenceUID } = toolData.metadata;
return {
ROINumber: index + 1,
ROIName: `Todo: name ${index + 1}`,
ROIDescription: `Todo: description ${index + 1}`,
ROIGenerationAlgorithm: 'Todo: algorithm',
ReferencedFrameOfReferenceUID: FrameOfReferenceUID,
};
}
function getRTROIObservationsSequence(toolData, index, metadataProvider) {
return {
ObservationNumber: index + 1,
ReferencedROINumber: index + 1,
RTROIInterpretedType: 'Todo: type',
ROIInterpreter: 'Todo: interpreter',
};
}
@@ -0,0 +1,15 @@
import RTSSReport from './RTSSReport';
import dcmjs from 'dcmjs';
import { classes } from '@ohif/core';
const { datasetToBlob } = dcmjs.data;
const metadataProvider = classes.MetadataProvider;
export default function dicomRTAnnotationExport(annotations) {
const dataset = RTSSReport.generateReport(annotations, metadataProvider);
const reportBlob = datasetToBlob(dataset);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
}
@@ -0,0 +1,3 @@
import dicomRTAnnotationExport from './dicomRTAnnotationExport';
export default dicomRTAnnotationExport;
@@ -0,0 +1,58 @@
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
function validateAnnotation(annotation) {
if (!annotation?.data) {
throw new Error('Tool data is empty');
}
if (!annotation.metadata || annotation.metadata.referenceImageId) {
throw new Error('Tool data is not associated with any imageId');
}
}
class AnnotationToPointData {
constructor() {}
static convert(annotation, index, metadataProvider) {
validateAnnotation(annotation);
const { toolName } = annotation.metadata;
const toolClass = AnnotationToPointData.TOOL_NAMES[toolName];
if (!toolClass) {
throw new Error(
`Unknown tool type: ${toolName}, cannot convert to RTSSReport`
);
}
// Each toolData should become a list of contours, ContourSequence
// contains a list of contours with their pointData, their geometry
// type and their length.
const ContourSequence = toolClass.getContourSequence(
annotation,
metadataProvider
);
// Todo: random rgb color for now, options should be passed in
const color = [
Math.floor(Math.random() * 255),
Math.floor(Math.random() * 255),
Math.floor(Math.random() * 255),
];
return {
ReferencedROINumber: index + 1,
ROIDisplayColor: color,
ContourSequence,
};
}
static register(toolClass) {
AnnotationToPointData.TOOL_NAMES[toolClass.toolName] = toolClass;
}
}
AnnotationToPointData.TOOL_NAMES = {};
AnnotationToPointData.register(RectangleROIStartEndThreshold);
export default AnnotationToPointData;
@@ -0,0 +1,56 @@
// comment
class RectangleROIStartEndThreshold {
constructor() {}
static getContourSequence(toolData, metadataProvider) {
const { data } = toolData;
const { projectionPoints, projectionPointsImageIds } = data.cachedStats;
return projectionPoints.map((point, index) => {
const ContourData = getPointData(point);
const ContourImageSequence = getContourImageSequence(
projectionPointsImageIds[index],
metadataProvider
);
return {
NumberOfContourPoints: ContourData.length / 3,
ContourImageSequence,
ContourGeometricType: 'CLOSED_PLANAR',
ContourData,
};
});
}
}
RectangleROIStartEndThreshold.toolName = 'RectangleROIStartEndThreshold';
function getPointData(points) {
// Since this is a closed contour, the order of the points is important.
// re-order the points to be in the correct order clockwise
// Spread to make sure Float32Arrays are converted to arrays
const orderedPoints = [
...points[0],
...points[1],
...points[3],
...points[2],
];
const pointsArray = orderedPoints.flat();
// reduce the precision of the points to 2 decimal places
const pointsArrayWithPrecision = pointsArray.map(point => {
return point.toFixed(2);
});
return pointsArrayWithPrecision;
}
function getContourImageSequence(imageId, metadataProvider) {
const sopCommon = metadataProvider.get('sopCommonModule', imageId);
return {
ReferencedSOPClassUID: sopCommon.sopClassUID,
ReferencedSOPInstanceUID: sopCommon.sopInstanceUID,
};
}
export default RectangleROIStartEndThreshold;
@@ -0,0 +1,29 @@
async function getStudiesForPatientByStudyInstanceUID(
dataSource,
StudyInstanceUID
) {
if (StudyInstanceUID === undefined) {
return;
}
// TODO: The `DicomMetadataStore` should short-circuit both of these requests
// Data _could_ be here from route query, or if using JSON data source
// We could also force this to "await" these values being available in the DICOMStore?
// Kind of like promise fulfillment in cornerstone-wado-image-loader when there are multiple
// outgoing requests for the same data
const getStudyResult = await dataSource.query.studies.search({
studyInstanceUid: StudyInstanceUID,
});
// TODO: To Erik's point, the data source likely shouldn't deviate from
// Naturalized DICOM JSON when returning. It makes things like this awkward (mrn)
if (getStudyResult && getStudyResult.length && getStudyResult[0].mrn) {
return dataSource.query.studies.search({
patientId: getStudyResult[0].mrn,
});
}
console.log('No mrn found for', getStudyResult);
// The original study we KNOW belongs to the same set, so just return it
return getStudyResult;
}
export default getStudiesForPatientByStudyInstanceUID;
@@ -0,0 +1,66 @@
import * as csTools from '@cornerstonejs/tools';
function getThresholdValues(
annotationUIDs,
referencedVolume,
config
): { lower: number; upper: number } {
if (config.strategy === 'range') {
return {
lower: Number(config.lower),
upper: Number(config.upper),
};
}
// roiStats
const { weight } = config;
const { imageData } = referencedVolume;
const values = imageData
.getPointData()
.getScalars()
.getData();
// Todo: add support for other strategies
const { fn, baseValue } = _getStrategyFn('max');
let value = baseValue;
const annotations = annotationUIDs.map(annotationUID =>
csTools.annotation.state.getAnnotation(annotationUID)
);
const boundsIJK = csTools.utilities.rectangleROITool.getBoundsIJKFromRectangleAnnotations(
annotations,
referencedVolume
);
const [[iMin, iMax], [jMin, jMax], [kMin, kMax]] = boundsIJK;
for (let i = iMin; i <= iMax; i++) {
for (let j = jMin; j <= jMax; j++) {
for (let k = kMin; k <= kMax; k++) {
const offset = imageData.computeOffsetIndex([i, j, k]);
value = fn(values[offset], value);
}
}
}
return {
lower: weight * value,
upper: +Infinity,
};
}
function _getStrategyFn(
statistic
): { fn: (a: number, b: number) => number; baseValue: number } {
const baseValue = -Infinity;
const fn = (number, maxValue) => {
if (number > maxValue) {
maxValue = number;
}
return maxValue;
};
return { fn, baseValue };
}
export default getThresholdValues;
@@ -0,0 +1,75 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
const RectangleROIStartEndThreshold = {
toAnnotation: (measurement, definition) => {},
/**
* Maps cornerstone annotation event data to measurement service format.
*
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
toMeasurement: (
csToolsEventDetail,
DisplaySetService,
CornerstoneViewportService
) => {
const { annotation, viewportId } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
if (!metadata || !data) {
console.warn('Length tool: Missing metadata or data');
return null;
}
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
const validToolType = SUPPORTED_TOOLS.includes(toolName);
if (!validToolType) {
throw new Error('Tool not supported');
}
const {
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
} = getSOPInstanceAttributes(
referencedImageId,
CornerstoneViewportService,
viewportId
);
let displaySet;
if (SOPInstanceUID) {
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
displaySet = DisplaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
}
const { cachedStats } = data;
return {
uid: annotationUID,
SOPInstanceUID,
FrameOfReferenceUID,
// points,
metadata,
referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID,
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: metadata.label,
// displayText: displayText,
data: data.cachedStats,
type: 'RectangleROIStartEndThreshold',
// getReport,
};
},
};
export default RectangleROIStartEndThreshold;
@@ -0,0 +1 @@
export default ['RectangleROIStartEndThreshold'];
@@ -0,0 +1,26 @@
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
const measurementServiceMappingsFactory = (
MeasurementService,
DisplaySetService,
CornerstoneViewportService
) => {
return {
RectangleROIStartEndThreshold: {
toAnnotation: RectangleROIStartEndThreshold.toAnnotation,
toMeasurement: csToolsAnnotation =>
RectangleROIStartEndThreshold.toMeasurement(
csToolsAnnotation,
DisplaySetService,
CornerstoneViewportService
),
matchingCriteria: [
{
valueType: MeasurementService.VALUE_TYPES.ROI_THRESHOLD_MANUAL,
},
],
},
};
};
export default measurementServiceMappingsFactory;
@@ -0,0 +1,17 @@
import { metaData } from '@cornerstonejs/core';
export default function getSOPInstanceAttributes(imageId) {
if (imageId) {
return _getUIDFromImageID(imageId);
}
}
function _getUIDFromImageID(imageId) {
const instance = metaData.get('instance', imageId);
return {
SOPInstanceUID: instance.SOPInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
StudyInstanceUID: instance.StudyInstanceUID,
};
}