init
This commit is contained in:
8
extensions/tmtv/.webpack/webpack.dev.js
Normal file
8
extensions/tmtv/.webpack/webpack.dev.js
Normal file
@@ -0,0 +1,8 @@
|
||||
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');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
};
|
||||
54
extensions/tmtv/.webpack/webpack.prod.js
Normal file
54
extensions/tmtv/.webpack/webpack.prod.js
Normal 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 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 MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.tsx`,
|
||||
};
|
||||
|
||||
const outputName = `ohif-${pkg.name.split('/').pop()}`;
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
|
||||
return merge(commonConfig, {
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
timings: true,
|
||||
assets: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
modules: false,
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
sideEffects: false,
|
||||
},
|
||||
output: {
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-tmtv',
|
||||
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`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
2183
extensions/tmtv/CHANGELOG.md
Normal file
2183
extensions/tmtv/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
extensions/tmtv/LICENSE
Normal file
21
extensions/tmtv/LICENSE
Normal 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
extensions/tmtv/README.md
Normal file
1
extensions/tmtv/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# TMTV Extension
|
||||
1
extensions/tmtv/babel.config.js
Normal file
1
extensions/tmtv/babel.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../../babel.config.js');
|
||||
45
extensions/tmtv/package.json
Normal file
45
extensions/tmtv/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@ohif/extension-tmtv",
|
||||
"version": "3.9.1",
|
||||
"description": "OHIF extension for Total Metabolic Tumor Volume",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-extension-tmtv.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"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.9.1",
|
||||
"@ohif/ui": "3.9.1",
|
||||
"dcmjs": "*",
|
||||
"dicom-parser": "^1.8.9",
|
||||
"hammerjs": "^2.0.8",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"classnames": "^2.3.2"
|
||||
}
|
||||
}
|
||||
246
extensions/tmtv/src/Panels/PanelPetSUV.tsx
Normal file
246
extensions/tmtv/src/Panels/PanelPetSUV.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PanelSection, Input, Button } from '@ohif/ui';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Separator } from '@ohif/ui-next';
|
||||
|
||||
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 }: withAppTypes) {
|
||||
const { t } = useTranslation('PanelSUV');
|
||||
const { displaySetService, toolGroupService, toolbarService, hangingProtocolService } =
|
||||
servicesManager.services;
|
||||
const [metadata, setMetadata] = useState(DEFAULT_MEATADATA);
|
||||
const [ptDisplaySet, setPtDisplaySet] = useState(null);
|
||||
|
||||
const handleMetadataChange = 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;
|
||||
});
|
||||
};
|
||||
|
||||
const getMatchingPTDisplaySet = viewportMatchDetails => {
|
||||
const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet', {
|
||||
viewportMatchDetails,
|
||||
});
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = commandsManager.runCommand('getPTMetadata', {
|
||||
ptDisplaySet,
|
||||
});
|
||||
|
||||
return {
|
||||
ptDisplaySet,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const displaySets = displaySetService.getActiveDisplaySets();
|
||||
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
|
||||
if (!displaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails);
|
||||
|
||||
if (!displaySetInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ptDisplaySet, metadata } = displaySetInfo;
|
||||
setPtDisplaySet(ptDisplaySet);
|
||||
setMetadata(metadata);
|
||||
}, []);
|
||||
|
||||
// get the patientMetadata from the StudyInstanceUIDs and update the state
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = hangingProtocolService.subscribe(
|
||||
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
|
||||
({ viewportMatchDetails }) => {
|
||||
const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails);
|
||||
|
||||
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);
|
||||
|
||||
// Crosshair position depends on the metadata values such as the positioning interaction
|
||||
// between series, so when the metadata is updated, the crosshairs need to be reset.
|
||||
setTimeout(() => {
|
||||
commandsManager.runCommand('resetCrosshairs');
|
||||
}, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-black text-[13px] font-[300]">
|
||||
<PanelSection title={t('Patient Information')}>
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-primary-dark flex flex-col gap-4 p-2">
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Patient Sex')}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={metadata.PatientSex || ''}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
PatientSex: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Weight')}
|
||||
labelChildren={<span className="text-aqua-pale"> kg</span>}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={metadata.PatientWeight || ''}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
PatientWeight: e.target.value,
|
||||
});
|
||||
}}
|
||||
id="weight-input"
|
||||
/>
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Total Dose')}
|
||||
labelChildren={<span className="text-aqua-pale"> bq</span>}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={
|
||||
metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''
|
||||
}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideTotalDose: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Half Life')}
|
||||
labelChildren={<span className="text-aqua-pale"> s</span>}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideHalfLife: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Injection Time')}
|
||||
labelChildren={<span className="text-aqua-pale"> s</span>}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={
|
||||
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ||
|
||||
''
|
||||
}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadiopharmaceuticalStartTime: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
containerClassName={'!flex-row !justify-between items-center'}
|
||||
label={t('Acquisition Time')}
|
||||
labelChildren={<span className="text-aqua-pale"> s</span>}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="!m-0 !h-[26px] !w-[117px]"
|
||||
value={metadata.SeriesTime || ''}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Button
|
||||
className="!h-[26px] !w-[115px] self-end !p-0"
|
||||
onClick={updateMetadata}
|
||||
>
|
||||
Reload Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</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,76 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone';
|
||||
import { handleROIThresholding } from '../../utils/handleROIThresholding';
|
||||
import { debounce } from '@ohif/core/src/utils';
|
||||
|
||||
export default function PanelRoiThresholdSegmentation({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
}: withAppTypes) {
|
||||
const { segmentationService } = servicesManager.services;
|
||||
const { segmentationsWithRepresentations: segmentationsInfo } =
|
||||
useActiveViewportSegmentationRepresentations({ servicesManager });
|
||||
|
||||
useEffect(() => {
|
||||
const segmentationIds = segmentationsInfo.map(
|
||||
segmentationInfo => segmentationInfo.segmentation.segmentationId
|
||||
);
|
||||
|
||||
const initialRun = async () => {
|
||||
for (const segmentationId of segmentationIds) {
|
||||
await handleROIThresholding({
|
||||
segmentationId,
|
||||
commandsManager,
|
||||
segmentationService,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initialRun();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedHandleROIThresholding = debounce(async eventDetail => {
|
||||
const { segmentationId } = eventDetail;
|
||||
await handleROIThresholding({
|
||||
segmentationId,
|
||||
commandsManager,
|
||||
segmentationService,
|
||||
});
|
||||
}, 100);
|
||||
|
||||
const dataModifiedCallback = eventDetail => {
|
||||
debouncedHandleROIThresholding(eventDetail);
|
||||
};
|
||||
|
||||
const dataModifiedSubscription = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
|
||||
dataModifiedCallback
|
||||
);
|
||||
|
||||
return () => {
|
||||
dataModifiedSubscription.unsubscribe();
|
||||
};
|
||||
}, [commandsManager, segmentationService]);
|
||||
|
||||
// Find the first segmentation with a TMTV value since all of them have the same value
|
||||
const tmtvSegmentation = segmentationsInfo.find(
|
||||
info => info.segmentation.cachedStats?.tmtv !== undefined
|
||||
);
|
||||
const tmtvValue = tmtvSegmentation?.segmentation.cachedStats?.tmtv;
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-10 flex flex-col">
|
||||
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
|
||||
{tmtvValue !== null && tmtvValue !== undefined ? (
|
||||
<div className="bg-secondary-dark 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.toFixed(3)} mL`}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Input, Label, Select, LegacyButton, LegacyButtonGroup } 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="bg-primary-dark flex flex-col space-y-4">
|
||||
<div className="flex items-end space-x-2">
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<Select
|
||||
label={t('Strategy')}
|
||||
closeMenuOnSelect={true}
|
||||
className="border-primary-main mr-2 bg-black text-white "
|
||||
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">
|
||||
{/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/}
|
||||
<LegacyButtonGroup>
|
||||
<LegacyButton
|
||||
size="initial"
|
||||
className="px-2 py-2 text-base text-white"
|
||||
color="primaryLight"
|
||||
variant="outlined"
|
||||
onClick={() => runCommand('setStartSliceForROIThresholdTool')}
|
||||
>
|
||||
{t('Start')}
|
||||
</LegacyButton>
|
||||
<LegacyButton
|
||||
size="initial"
|
||||
color="primaryLight"
|
||||
variant="outlined"
|
||||
className="px-2 py-2 text-base text-white"
|
||||
onClick={() => runCommand('setEndSliceForROIThresholdTool')}
|
||||
>
|
||||
{t('End')}
|
||||
</LegacyButton>
|
||||
</LegacyButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.strategy === ROI_STAT && (
|
||||
<Input
|
||||
label={t('Percentage of Max SUV')}
|
||||
labelClassName="text-[13px] font-inter text-white"
|
||||
className="border-primary-main bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.weight}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setWeight',
|
||||
payload: {
|
||||
weight: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{config.strategy !== ROI_STAT && (
|
||||
<div className="mr-2 text-sm">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr className="mt-2">
|
||||
<td
|
||||
className="pr-4"
|
||||
colSpan="3"
|
||||
>
|
||||
<Label
|
||||
className="font-inter text-[13px] text-white"
|
||||
text="Lower & Upper Ranges"
|
||||
></Label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="mt-2">
|
||||
<td className="pr-4 pt-2 text-center">
|
||||
<Label
|
||||
className="text-white"
|
||||
text="CT"
|
||||
></Label>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex justify-between">
|
||||
<Input
|
||||
label={t('')}
|
||||
labelClassName="text-white"
|
||||
className="border-primary-main mt-2 bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.ctLower}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
ctLower: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('')}
|
||||
labelClassName="text-white"
|
||||
className="border-primary-main mt-2 bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.ctUpper}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
ctUpper: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="pr-4 pt-2 text-center">
|
||||
<Label
|
||||
className="text-white"
|
||||
text="PT"
|
||||
></Label>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex justify-between">
|
||||
<Input
|
||||
label={t('')}
|
||||
labelClassName="text-white"
|
||||
className="border-primary-main mt-2 bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.ptLower}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
ptLower: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('')}
|
||||
labelClassName="text-white"
|
||||
className="border-primary-main mt-2 bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.ptUpper}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
ptUpper: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ROIThresholdConfiguration;
|
||||
@@ -0,0 +1,3 @@
|
||||
import PanelROIThresholdExport from './PanelROIThresholdExport';
|
||||
|
||||
export default PanelROIThresholdExport;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
|
||||
|
||||
function segmentationItemEditHandler({ id, servicesManager }: withAppTypes) {
|
||||
const { segmentationService, uiDialogService } = servicesManager.services;
|
||||
|
||||
const segmentation = segmentationService.getSegmentation(id);
|
||||
|
||||
const onSubmitHandler = ({ action, value }) => {
|
||||
switch (action.id) {
|
||||
case 'save': {
|
||||
segmentationService.addOrUpdateSegmentation({
|
||||
...segmentation,
|
||||
...value,
|
||||
});
|
||||
}
|
||||
}
|
||||
uiDialogService.dismiss({ id: 'enter-annotation' });
|
||||
};
|
||||
|
||||
uiDialogService.create({
|
||||
id: 'enter-annotation',
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
showOverlay: true,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Enter your Segmentation',
|
||||
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 (
|
||||
<Input
|
||||
autoFocus
|
||||
className="border-primary-main bg-black"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={value.label}
|
||||
onChange={onChangeHandler}
|
||||
onKeyPress={onKeyPressHandler}
|
||||
/>
|
||||
);
|
||||
},
|
||||
actions: [
|
||||
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
|
||||
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
|
||||
],
|
||||
onSubmit: onSubmitHandler,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default segmentationItemEditHandler;
|
||||
62
extensions/tmtv/src/Panels/PanelTMTV.tsx
Normal file
62
extensions/tmtv/src/Panels/PanelTMTV.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
PanelSegmentation,
|
||||
useActiveViewportSegmentationRepresentations,
|
||||
} from '@ohif/extension-cornerstone';
|
||||
import { Button, Icons } from '@ohif/ui-next';
|
||||
|
||||
export default function PanelTMTV({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
configuration,
|
||||
}: withAppTypes) {
|
||||
return (
|
||||
<>
|
||||
<PanelSegmentation
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
extensionManager={extensionManager}
|
||||
configuration={configuration}
|
||||
>
|
||||
<ExportCSV
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
/>
|
||||
</PanelSegmentation>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ExportCSV = ({ servicesManager, commandsManager }: withAppTypes) => {
|
||||
const { segmentationsWithRepresentations: representations } =
|
||||
useActiveViewportSegmentationRepresentations({ servicesManager });
|
||||
|
||||
const tmtv = representations[0]?.segmentation.cachedStats?.tmtv;
|
||||
|
||||
const segmentations = representations.map(representation => representation.segmentation);
|
||||
|
||||
if (!segmentations.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 w-full items-center rounded pr-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="pl-1.5"
|
||||
onClick={() => {
|
||||
commandsManager.runCommand('exportTMTVReportCSV', {
|
||||
segmentations,
|
||||
tmtv,
|
||||
config: {},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.Download />
|
||||
<span className="pl-1">CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
extensions/tmtv/src/Panels/RectangleROIOptions.tsx
Normal file
136
extensions/tmtv/src/Panels/RectangleROIOptions.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState, useCallback, useReducer, useEffect } from 'react';
|
||||
import { Button } from '@ohif/ui';
|
||||
import ROIThresholdConfiguration, {
|
||||
ROI_STAT,
|
||||
} from './PanelROIThresholdSegmentation/ROIThresholdConfiguration';
|
||||
import * as cs3dTools from '@cornerstonejs/tools';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function RectangleROIOptions({ servicesManager, commandsManager }: withAppTypes) {
|
||||
const { segmentationService } = servicesManager.services;
|
||||
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
|
||||
|
||||
const runCommand = useCallback(
|
||||
(commandName, commandOptions = {}) => {
|
||||
return commandsManager.runCommand(commandName, commandOptions);
|
||||
},
|
||||
[commandsManager]
|
||||
);
|
||||
|
||||
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 handleROIThresholding = useCallback(() => {
|
||||
const segmentationId = selectedSegmentationId;
|
||||
const activeSegmentIndex =
|
||||
cs3dTools.segmentation.segmentIndex.getActiveSegmentIndex(segmentationId);
|
||||
|
||||
// run the threshold based on the active segment index
|
||||
// Todo: later find a way to associate each rectangle with a segment (e.g., maybe with color?)
|
||||
runCommand('thresholdSegmentationByRectangleROITool', {
|
||||
segmentationId,
|
||||
config,
|
||||
segmentIndex: activeSegmentIndex,
|
||||
});
|
||||
}, [selectedSegmentationId, config]);
|
||||
|
||||
useEffect(() => {
|
||||
const segmentations = segmentationService.getSegmentationRepresentations();
|
||||
|
||||
if (!segmentations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = segmentations.find(seg => seg.isActive);
|
||||
setSelectedSegmentationId(isActive.id);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update UI based on segmentation changes (added, removed, updated)
|
||||
*/
|
||||
useEffect(() => {
|
||||
// ~~ Subscription
|
||||
const updated = segmentationService.EVENTS.SEGMENTATION_MODIFIED;
|
||||
const subscriptions = [];
|
||||
|
||||
[updated].forEach(evt => {
|
||||
const { unsubscribe } = segmentationService.subscribe(evt, () => {
|
||||
const segmentations = segmentationService.getSegmentationRepresentations();
|
||||
|
||||
if (!segmentations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = segmentations.find(seg => seg.isActive);
|
||||
setSelectedSegmentationId(isActive.id);
|
||||
});
|
||||
subscriptions.push(unsubscribe);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="invisible-scrollbar mb-2 flex flex-col overflow-y-auto overflow-x-hidden">
|
||||
<ROIThresholdConfiguration
|
||||
config={config}
|
||||
dispatch={dispatch}
|
||||
runCommand={runCommand}
|
||||
/>
|
||||
{selectedSegmentationId !== null && (
|
||||
<Button
|
||||
className="mt-2 !h-[26px] !w-[75px]"
|
||||
onClick={handleROIThresholding}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RectangleROIOptions;
|
||||
4
extensions/tmtv/src/Panels/index.tsx
Normal file
4
extensions/tmtv/src/Panels/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import PanelPetSUV from './PanelPetSUV';
|
||||
import PanelROIThresholdExport from './PanelROIThresholdSegmentation';
|
||||
|
||||
export { PanelPetSUV, PanelROIThresholdExport };
|
||||
703
extensions/tmtv/src/commandsModule.ts
Normal file
703
extensions/tmtv/src/commandsModule.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
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 createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
|
||||
|
||||
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
|
||||
|
||||
import { getWebWorkerManager } from '@cornerstonejs/core';
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
|
||||
const { SegmentationRepresentations } = Enums;
|
||||
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
const ROI_THRESHOLD_MANUAL_TOOL_IDS = [
|
||||
'RectangleROIStartEndThreshold',
|
||||
'RectangleROIThreshold',
|
||||
'CircleROIStartEndThreshold',
|
||||
];
|
||||
|
||||
const workerManager = getWebWorkerManager();
|
||||
|
||||
const options = {
|
||||
maxWorkerInstances: 1,
|
||||
autoTerminateOnIdle: {
|
||||
enabled: true,
|
||||
idleTimeThreshold: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
// Register the task
|
||||
const workerFn = () => {
|
||||
return new Worker(new URL('./utils/calculateSUVPeakWorker.js', import.meta.url), {
|
||||
name: 'suv-peak-worker', // name used by the browser to name the worker
|
||||
});
|
||||
};
|
||||
|
||||
function getVolumesFromSegmentation(segmentationId) {
|
||||
const csSegmentation = csTools.segmentation.state.getSegmentation(segmentationId);
|
||||
const labelmapData = csSegmentation.representationData[
|
||||
SegmentationRepresentations.Labelmap
|
||||
] as csTools.Types.LabelmapToolOperationDataVolume;
|
||||
|
||||
const { volumeId, referencedVolumeId } = labelmapData;
|
||||
const labelmapVolume = cs.cache.getVolume(volumeId);
|
||||
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
|
||||
|
||||
return { labelmapVolume, referencedVolume };
|
||||
}
|
||||
|
||||
function getLabelmapVolumeFromSegmentation(segmentation) {
|
||||
const { representationData } = segmentation;
|
||||
const { volumeId } = representationData[
|
||||
SegmentationRepresentations.Labelmap
|
||||
] as csTools.Types.LabelmapToolOperationDataVolume;
|
||||
|
||||
return cs.cache.getVolume(volumeId);
|
||||
}
|
||||
|
||||
const commandsModule = ({ servicesManager, commandsManager, extensionManager }: withAppTypes) => {
|
||||
const {
|
||||
viewportGridService,
|
||||
uiNotificationService,
|
||||
displaySetService,
|
||||
hangingProtocolService,
|
||||
toolGroupService,
|
||||
cornerstoneViewportService,
|
||||
segmentationService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
const { getEnabledElement } = utilityModule.exports;
|
||||
|
||||
function _getActiveViewportsEnabledElement() {
|
||||
const { activeViewportId } = viewportGridService.getState();
|
||||
const { element } = getEnabledElement(activeViewportId) || {};
|
||||
const enabledElement = cs.getEnabledElement(element);
|
||||
return enabledElement;
|
||||
}
|
||||
|
||||
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
|
||||
// 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)
|
||||
|
||||
let ptDisplaySet = null;
|
||||
for (const [viewportId, viewportDetails] of viewportMatchDetails) {
|
||||
const { displaySetsInfo } = viewportDetails;
|
||||
const displaySets = displaySetsInfo.map(({ displaySetInstanceUID }) =>
|
||||
displaySetService.getDisplaySetByUID(displaySetInstanceUID)
|
||||
);
|
||||
|
||||
if (!displaySets || displaySets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ptDisplaySet = displaySets.find(displaySet => displaySet.Modality === 'PT');
|
||||
if (ptDisplaySet) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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 ({ label }) => {
|
||||
// Create a segmentation of the same resolution as the source data
|
||||
// using volumeLoader.createAndCacheDerivedVolume.
|
||||
|
||||
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
|
||||
|
||||
const ptDisplaySet = actions.getMatchingPTDisplaySet({
|
||||
viewportMatchDetails,
|
||||
});
|
||||
|
||||
let withPTViewportId = null;
|
||||
|
||||
for (const [viewportId, { displaySetsInfo }] of viewportMatchDetails.entries()) {
|
||||
const isPT = displaySetsInfo.some(
|
||||
({ displaySetInstanceUID }) =>
|
||||
displaySetInstanceUID === ptDisplaySet.displaySetInstanceUID
|
||||
);
|
||||
|
||||
if (isPT) {
|
||||
withPTViewportId = viewportId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
uiNotificationService.error('No matching PT display set found');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSegmentations =
|
||||
segmentationService.getSegmentationRepresentations(withPTViewportId);
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(ptDisplaySet.displaySetInstanceUID);
|
||||
|
||||
const segmentationId = await segmentationService.createLabelmapForDisplaySet(displaySet, {
|
||||
label: `Segmentation ${currentSegmentations.length + 1}`,
|
||||
segments: { 1: { label: 'Segment 1', active: true } },
|
||||
});
|
||||
|
||||
segmentationService.addSegmentationRepresentation(withPTViewportId, {
|
||||
segmentationId,
|
||||
});
|
||||
|
||||
return segmentationId;
|
||||
},
|
||||
thresholdSegmentationByRectangleROITool: ({ segmentationId, config, segmentIndex }) => {
|
||||
const segmentation = csTools.segmentation.state.getSegmentation(segmentationId);
|
||||
|
||||
const { representationData } = segmentation;
|
||||
const { displaySetMatchDetails: matchDetails } = hangingProtocolService.getMatchDetails();
|
||||
const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use
|
||||
|
||||
const ctDisplaySet = matchDetails.get('ctDisplaySet');
|
||||
const ctVolumeId = `${volumeLoaderScheme}:${ctDisplaySet.displaySetInstanceUID}`; // VolumeId with loader id + volume id
|
||||
|
||||
const { volumeId: segVolumeId } = representationData[
|
||||
SegmentationRepresentations.Labelmap
|
||||
] as csTools.Types.LabelmapToolOperationDataVolume;
|
||||
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
|
||||
|
||||
const annotationUIDs = _getAnnotationsSelectedByToolNames(ROI_THRESHOLD_MANUAL_TOOL_IDS);
|
||||
|
||||
if (annotationUIDs.length === 0) {
|
||||
uiNotificationService.show({
|
||||
title: 'Commands Module',
|
||||
message: 'No ROIThreshold Tool is Selected',
|
||||
type: 'error',
|
||||
});
|
||||
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],
|
||||
config
|
||||
);
|
||||
|
||||
return csTools.utilities.segmentation.rectangleROIThresholdVolumeByRange(
|
||||
annotationUIDs,
|
||||
labelmapVolume,
|
||||
[
|
||||
{ volume: referencedVolume, lower: ptLower, upper: ptUpper },
|
||||
{ volume: ctReferencedVolume, lower: ctLower, upper: ctUpper },
|
||||
],
|
||||
{ overwrite: true, segmentIndex }
|
||||
);
|
||||
},
|
||||
calculateSuvPeak: async ({ segmentationId, segmentIndex }) => {
|
||||
const segmentation = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
const { representationData } = segmentation;
|
||||
const { volumeId, referencedVolumeId } = representationData[
|
||||
SegmentationRepresentations.Labelmap
|
||||
] as csTools.Types.LabelmapToolOperationDataVolume;
|
||||
|
||||
const labelmap = cs.cache.getVolume(volumeId);
|
||||
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
|
||||
|
||||
// if we put it in the top, it will appear in other modes
|
||||
workerManager.registerWorker('suv-peak-worker', workerFn, options);
|
||||
|
||||
const annotationUIDs = _getAnnotationsSelectedByToolNames(ROI_THRESHOLD_MANUAL_TOOL_IDS);
|
||||
|
||||
const annotations = annotationUIDs.map(annotationUID =>
|
||||
csTools.annotation.state.getAnnotation(annotationUID)
|
||||
);
|
||||
|
||||
const labelmapProps = {
|
||||
dimensions: labelmap.dimensions,
|
||||
origin: labelmap.origin,
|
||||
direction: labelmap.direction,
|
||||
spacing: labelmap.spacing,
|
||||
metadata: labelmap.metadata,
|
||||
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
|
||||
};
|
||||
|
||||
const referenceVolumeProps = {
|
||||
dimensions: referencedVolume.dimensions,
|
||||
origin: referencedVolume.origin,
|
||||
direction: referencedVolume.direction,
|
||||
spacing: referencedVolume.spacing,
|
||||
metadata: referencedVolume.metadata,
|
||||
scalarData: referencedVolume.voxelManager.getCompleteScalarDataArray(),
|
||||
};
|
||||
|
||||
// metadata in annotations has enabledElement which is not serializable
|
||||
// we need to remove it
|
||||
// Todo: we should probably have a sanitization function for this
|
||||
const annotationsToSend = annotations.map(annotation => {
|
||||
return {
|
||||
...annotation,
|
||||
metadata: {
|
||||
...annotation.metadata,
|
||||
enabledElement: {
|
||||
...annotation.metadata.enabledElement,
|
||||
viewport: null,
|
||||
renderingEngine: null,
|
||||
element: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const suvPeak =
|
||||
(await workerManager.executeTask('suv-peak-worker', 'calculateSuvPeak', {
|
||||
labelmapProps,
|
||||
referenceVolumeProps,
|
||||
annotations: annotationsToSend,
|
||||
segmentIndex,
|
||||
})) || {};
|
||||
|
||||
return {
|
||||
suvPeak: suvPeak.mean,
|
||||
suvMax: suvPeak.max,
|
||||
suvMaxIJK: suvPeak.maxIJK,
|
||||
suvMaxLPS: suvPeak.maxLPS,
|
||||
};
|
||||
},
|
||||
getLesionStats: ({ segmentationId, segmentIndex = 1 }) => {
|
||||
const { labelmapVolume, referencedVolume } = getVolumesFromSegmentation(segmentationId);
|
||||
const { voxelManager: segVoxelManager, imageData, spacing } = labelmapVolume;
|
||||
const { voxelManager: refVoxelManager } = referencedVolume;
|
||||
|
||||
let segmentationMax = -Infinity;
|
||||
let segmentationMin = Infinity;
|
||||
const segmentationValues = [];
|
||||
let voxelCount = 0;
|
||||
|
||||
const callback = ({ value, index }) => {
|
||||
if (value === segmentIndex) {
|
||||
const refValue = refVoxelManager.getAtIndex(index) as number;
|
||||
segmentationValues.push(refValue);
|
||||
if (refValue > segmentationMax) {
|
||||
segmentationMax = refValue;
|
||||
}
|
||||
if (refValue < segmentationMin) {
|
||||
segmentationMin = refValue;
|
||||
}
|
||||
voxelCount++;
|
||||
}
|
||||
};
|
||||
|
||||
segVoxelManager.forEach(callback, { imageData });
|
||||
const mean = segmentationValues.reduce((a, b) => a + b, 0) / voxelCount;
|
||||
const stats = {
|
||||
minValue: segmentationMin,
|
||||
maxValue: segmentationMax,
|
||||
meanValue: mean,
|
||||
stdValue: Math.sqrt(
|
||||
segmentationValues.map(k => (k - mean) ** 2).reduce((acc, curr) => acc + curr, 0) /
|
||||
voxelCount
|
||||
),
|
||||
volume: voxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3,
|
||||
};
|
||||
|
||||
return stats;
|
||||
},
|
||||
calculateLesionGlycolysis: ({ lesionStats }) => {
|
||||
const { meanValue, volume } = lesionStats;
|
||||
|
||||
return {
|
||||
lesionGlyoclysisStats: volume * meanValue,
|
||||
};
|
||||
},
|
||||
calculateTMTV: async ({ segmentations }) => {
|
||||
const labelmapProps = segmentations.map(segmentation => {
|
||||
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
|
||||
return {
|
||||
dimensions: labelmap.dimensions,
|
||||
spacing: labelmap.spacing,
|
||||
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
|
||||
origin: labelmap.origin,
|
||||
direction: labelmap.direction,
|
||||
};
|
||||
});
|
||||
|
||||
if (!labelmapProps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await workerManager.executeTask('suv-peak-worker', 'calculateTMTV', labelmapProps);
|
||||
},
|
||||
exportTMTVReportCSV: async ({ segmentations, tmtv, config, options }) => {
|
||||
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
|
||||
segmentations,
|
||||
});
|
||||
|
||||
const tlg = await actions.getTotalLesionGlycolysis({ segmentations });
|
||||
const additionalReportRows = [
|
||||
{ key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } },
|
||||
{ key: 'Threshold Configuration', value: { ...config } },
|
||||
];
|
||||
|
||||
if (tmtv !== undefined) {
|
||||
additionalReportRows.unshift({
|
||||
key: 'Total Metabolic Tumor Volume',
|
||||
value: { tmtv },
|
||||
});
|
||||
}
|
||||
|
||||
createAndDownloadTMTVReport(segReport, additionalReportRows, options);
|
||||
},
|
||||
getTotalLesionGlycolysis: async ({ segmentations }) => {
|
||||
const labelmapProps = segmentations.map(segmentation => {
|
||||
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
|
||||
return {
|
||||
dimensions: labelmap.dimensions,
|
||||
spacing: labelmap.spacing,
|
||||
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
|
||||
origin: labelmap.origin,
|
||||
direction: labelmap.direction,
|
||||
};
|
||||
});
|
||||
|
||||
const { referencedVolume: ptVolume } = getVolumesFromSegmentation(
|
||||
segmentations[0].segmentationId
|
||||
);
|
||||
|
||||
const ptVolumeProps = {
|
||||
dimensions: ptVolume.dimensions,
|
||||
spacing: ptVolume.spacing,
|
||||
scalarData: ptVolume.voxelManager.getCompleteScalarDataArray(),
|
||||
origin: ptVolume.origin,
|
||||
direction: ptVolume.direction,
|
||||
};
|
||||
|
||||
return await workerManager.executeTask('suv-peak-worker', 'getTotalLesionGlycolysis', {
|
||||
labelmapProps,
|
||||
referenceVolumeProps: ptVolumeProps,
|
||||
});
|
||||
},
|
||||
setStartSliceForROIThresholdTool: () => {
|
||||
const { viewport } = _getActiveViewportsEnabledElement();
|
||||
const { focalPoint } = viewport.getCamera();
|
||||
|
||||
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
|
||||
ROI_THRESHOLD_MANUAL_TOOL_IDS
|
||||
);
|
||||
|
||||
const annotationUID = selectedAnnotationUIDs[0];
|
||||
|
||||
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
|
||||
|
||||
// set the current focal point
|
||||
annotation.data.startCoordinate = focalPoint;
|
||||
// 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 = _getAnnotationsSelectedByToolNames(
|
||||
ROI_THRESHOLD_MANUAL_TOOL_IDS
|
||||
);
|
||||
|
||||
const annotationUID = selectedAnnotationUIDs[0];
|
||||
|
||||
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
|
||||
|
||||
// get the current focal point
|
||||
const focalPointToEnd = viewport.getCamera().focalPoint;
|
||||
annotation.data.endCoordinate = focalPointToEnd;
|
||||
|
||||
// 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.getAnnotationManager();
|
||||
|
||||
const annotations = [];
|
||||
|
||||
Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => {
|
||||
const forAnnotations = stateManager.annotations[frameOfReferenceUID];
|
||||
const ROIAnnotations = ROI_THRESHOLD_MANUAL_TOOL_IDS.reduce(
|
||||
(annotations, toolName) => [...annotations, ...(forAnnotations[toolName] ?? [])],
|
||||
[]
|
||||
);
|
||||
|
||||
annotations.push(...ROIAnnotations);
|
||||
});
|
||||
|
||||
commandsManager.runCommand('exportRTReportForAnnotations', {
|
||||
annotations,
|
||||
});
|
||||
},
|
||||
getSegmentationCSVReport: ({ segmentations }) => {
|
||||
if (!segmentations || !segmentations.length) {
|
||||
segmentations = segmentationService.getSegmentations();
|
||||
}
|
||||
|
||||
const report = {};
|
||||
|
||||
for (const segmentation of segmentations) {
|
||||
const { label, segmentationId, representationData } =
|
||||
segmentation as csTools.Types.Segmentation;
|
||||
const id = segmentationId;
|
||||
|
||||
const segReport = { id, label };
|
||||
|
||||
if (!representationData) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { cachedStats } = segmentation.segments[1] || {}; // Assuming we want stats from the first segment
|
||||
|
||||
if (cachedStats) {
|
||||
Object.entries(cachedStats).forEach(([key, value]) => {
|
||||
if (typeof value !== 'object') {
|
||||
segReport[key] = value;
|
||||
} else {
|
||||
Object.entries(value).forEach(([subKey, subValue]) => {
|
||||
const newKey = `${key}_${subKey}`;
|
||||
segReport[newKey] = subValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const labelmapVolume =
|
||||
segmentation.representationData[SegmentationRepresentations.Labelmap];
|
||||
|
||||
if (!labelmapVolume) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
const referencedVolumeId = labelmapVolume.referencedVolumeId;
|
||||
|
||||
const referencedVolume = cs.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 ?? '000000',
|
||||
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);
|
||||
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
|
||||
|
||||
const ptDisplaySet = actions.getMatchingPTDisplaySet({
|
||||
viewportMatchDetails,
|
||||
});
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fusionViewportIds = toolGroup.getViewportIds();
|
||||
|
||||
const viewports = [];
|
||||
fusionViewportIds.forEach(viewportId => {
|
||||
commandsManager.runCommand('setViewportColormap', {
|
||||
viewportId,
|
||||
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
|
||||
colormap: {
|
||||
name: colormap,
|
||||
},
|
||||
});
|
||||
|
||||
viewports.push(cornerstoneViewportService.getCornerstoneViewport(viewportId));
|
||||
});
|
||||
|
||||
viewports.forEach(viewport => {
|
||||
viewport.render();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
setEndSliceForROIThresholdTool: {
|
||||
commandFn: actions.setEndSliceForROIThresholdTool,
|
||||
},
|
||||
setStartSliceForROIThresholdTool: {
|
||||
commandFn: actions.setStartSliceForROIThresholdTool,
|
||||
},
|
||||
getMatchingPTDisplaySet: {
|
||||
commandFn: actions.getMatchingPTDisplaySet,
|
||||
},
|
||||
getPTMetadata: {
|
||||
commandFn: actions.getPTMetadata,
|
||||
},
|
||||
createNewLabelmapFromPT: {
|
||||
commandFn: actions.createNewLabelmapFromPT,
|
||||
},
|
||||
thresholdSegmentationByRectangleROITool: {
|
||||
commandFn: actions.thresholdSegmentationByRectangleROITool,
|
||||
},
|
||||
getTotalLesionGlycolysis: {
|
||||
commandFn: actions.getTotalLesionGlycolysis,
|
||||
},
|
||||
calculateSuvPeak: {
|
||||
commandFn: actions.calculateSuvPeak,
|
||||
},
|
||||
getLesionStats: {
|
||||
commandFn: actions.getLesionStats,
|
||||
},
|
||||
calculateTMTV: {
|
||||
commandFn: actions.calculateTMTV,
|
||||
},
|
||||
exportTMTVReportCSV: {
|
||||
commandFn: actions.exportTMTVReportCSV,
|
||||
},
|
||||
createTMTVRTReport: {
|
||||
commandFn: actions.createTMTVRTReport,
|
||||
},
|
||||
getSegmentationCSVReport: {
|
||||
commandFn: actions.getSegmentationCSVReport,
|
||||
},
|
||||
exportRTReportForAnnotations: {
|
||||
commandFn: actions.exportRTReportForAnnotations,
|
||||
},
|
||||
setFusionPTColormap: {
|
||||
commandFn: actions.setFusionPTColormap,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'TMTV:CORNERSTONE',
|
||||
};
|
||||
};
|
||||
|
||||
export default commandsModule;
|
||||
350
extensions/tmtv/src/getHangingProtocolModule.ts
Normal file
350
extensions/tmtv/src/getHangingProtocolModule.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
ctAXIAL,
|
||||
ctCORONAL,
|
||||
ctSAGITTAL,
|
||||
fusionAXIAL,
|
||||
fusionCORONAL,
|
||||
fusionSAGITTAL,
|
||||
mipSAGITTAL,
|
||||
ptAXIAL,
|
||||
ptCORONAL,
|
||||
ptSAGITTAL,
|
||||
} from './utils/hpViewports';
|
||||
|
||||
/**
|
||||
* represents a 3x4 viewport layout configuration. The layout displays CT axial, sagittal, and coronal
|
||||
* images in the first row, PT axial, sagittal, and coronal images in the second row, and fusion axial,
|
||||
* sagittal, and coronal images in the third row. The fourth column is fully spanned by a MIP sagittal
|
||||
* image, covering all three rows. It has synchronizers for windowLevel for all CT and PT images, and
|
||||
* also camera synchronizer for each orientation
|
||||
*/
|
||||
const stage1: AppTypes.HangingProtocol.ProtocolStage = {
|
||||
name: 'default',
|
||||
id: '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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
ctAXIAL,
|
||||
ctSAGITTAL,
|
||||
ctCORONAL,
|
||||
ptAXIAL,
|
||||
ptSAGITTAL,
|
||||
ptCORONAL,
|
||||
fusionAXIAL,
|
||||
fusionSAGITTAL,
|
||||
fusionCORONAL,
|
||||
mipSAGITTAL,
|
||||
],
|
||||
createdDate: '2021-02-23T18:32:42.850Z',
|
||||
};
|
||||
|
||||
/**
|
||||
* The layout displays CT axial image in the top-left viewport, fusion axial image
|
||||
* in the top-right viewport, PT axial image in the bottom-left viewport, and MIP
|
||||
* sagittal image in the bottom-right viewport. The layout follows a simple grid
|
||||
* pattern with 2 rows and 2 columns. It includes synchronizers as well.
|
||||
*/
|
||||
const stage2 = {
|
||||
name: 'Fusion 2x2',
|
||||
id: 'Fusion-2x2',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
},
|
||||
},
|
||||
viewports: [ctAXIAL, fusionAXIAL, ptAXIAL, mipSAGITTAL],
|
||||
};
|
||||
|
||||
/**
|
||||
* The top row displays CT images in axial, sagittal, and coronal orientations from
|
||||
* left to right, respectively. The bottom row displays PT images in axial, sagittal,
|
||||
* and coronal orientations from left to right, respectively.
|
||||
* The layout follows a simple grid pattern with 2 rows and 3 columns.
|
||||
* It includes synchronizers as well.
|
||||
*/
|
||||
const stage3: AppTypes.HangingProtocol.ProtocolStage = {
|
||||
name: '2x3-layout',
|
||||
id: '2x3-layout',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
},
|
||||
},
|
||||
viewports: [ctAXIAL, ctSAGITTAL, ctCORONAL, ptAXIAL, ptSAGITTAL, ptCORONAL],
|
||||
};
|
||||
|
||||
/**
|
||||
* In this layout, the top row displays PT images in coronal, sagittal, and axial
|
||||
* orientations from left to right, respectively, followed by a MIP sagittal image
|
||||
* that spans both rows on the rightmost side. The bottom row displays fusion images
|
||||
* in coronal, sagittal, and axial orientations from left to right, respectively.
|
||||
* There is no viewport in the bottom row's rightmost position, as the MIP sagittal viewport
|
||||
* from the top row spans the full height of both rows.
|
||||
* It includes synchronizers as well.
|
||||
*/
|
||||
const stage4: AppTypes.HangingProtocol.ProtocolStage = {
|
||||
name: '2x4-layout',
|
||||
id: '2x4-layout',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 2 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 3 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 1 / 2,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 1 / 2,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 2 / 4,
|
||||
y: 1 / 2,
|
||||
width: 1 / 4,
|
||||
height: 1 / 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
ptCORONAL,
|
||||
ptSAGITTAL,
|
||||
ptAXIAL,
|
||||
mipSAGITTAL,
|
||||
fusionCORONAL,
|
||||
fusionSAGITTAL,
|
||||
fusionAXIAL,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* This layout displays three fusion viewports: axial, sagittal, and coronal.
|
||||
* It follows a simple grid pattern with 1 row and 3 columns.
|
||||
*/
|
||||
// const stage0: AppTypes.HangingProtocol.ProtocolStage = {
|
||||
// name: 'Fusion 1x3',
|
||||
// viewportStructure: {
|
||||
// layoutType: 'grid',
|
||||
// properties: {
|
||||
// rows: 1,
|
||||
// columns: 3,
|
||||
// },
|
||||
// },
|
||||
// viewports: [fusionAXIAL, fusionSAGITTAL, fusionCORONAL],
|
||||
// };
|
||||
|
||||
const ptCT: AppTypes.HangingProtocol.Protocol = {
|
||||
id: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
|
||||
locked: true,
|
||||
name: 'Default',
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
modifiedDate: '2022-10-04T19:22:08.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
|
||||
protocolMatchingRules: [
|
||||
{
|
||||
attribute: 'ModalitiesInStudy',
|
||||
constraint: {
|
||||
contains: ['CT', 'PT'],
|
||||
},
|
||||
},
|
||||
{
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: 'PETCT',
|
||||
},
|
||||
},
|
||||
{
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: 'PET/CT',
|
||||
},
|
||||
},
|
||||
],
|
||||
displaySetSelectors: {
|
||||
ctDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: 'CT',
|
||||
},
|
||||
},
|
||||
{
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: 'CT WB',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ptDisplaySet: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [stage1, stage2, stage3, stage4],
|
||||
numberOfPriorsReferenced: -1,
|
||||
};
|
||||
|
||||
function getHangingProtocolModule() {
|
||||
return [
|
||||
{
|
||||
name: ptCT.id,
|
||||
protocol: ptCT,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getHangingProtocolModule;
|
||||
91
extensions/tmtv/src/getPanelModule.tsx
Normal file
91
extensions/tmtv/src/getPanelModule.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { PanelPetSUV, PanelROIThresholdExport } from './Panels';
|
||||
import { Toolbox } from '@ohif/ui-next';
|
||||
import PanelTMTV from './Panels/PanelTMTV';
|
||||
|
||||
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
|
||||
const wrappedPanelPetSuv = () => {
|
||||
return (
|
||||
<PanelPetSUV
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedROIThresholdToolbox = () => {
|
||||
return (
|
||||
<Toolbox
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
buttonSectionId="ROIThresholdToolbox"
|
||||
title="Threshold Tools"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedROIThresholdExport = () => {
|
||||
return (
|
||||
<PanelROIThresholdExport
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedPanelTMTV = () => {
|
||||
return (
|
||||
<>
|
||||
<Toolbox
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
buttonSectionId="ROIThresholdToolbox"
|
||||
title="Threshold Tools"
|
||||
/>
|
||||
<PanelTMTV
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
<PanelROIThresholdExport
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'petSUV',
|
||||
iconName: 'tab-patient-info',
|
||||
iconLabel: 'Patient Info',
|
||||
label: 'Patient Info',
|
||||
component: wrappedPanelPetSuv,
|
||||
},
|
||||
{
|
||||
name: 'tmtv',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
component: wrappedPanelTMTV,
|
||||
},
|
||||
{
|
||||
name: 'tmtvBox',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
label: 'Segmentation Toolbox',
|
||||
component: wrappedROIThresholdToolbox,
|
||||
},
|
||||
{
|
||||
name: 'tmtvExport',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
label: 'Segmentation Export',
|
||||
component: wrappedROIThresholdExport,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getPanelModule;
|
||||
10
extensions/tmtv/src/getToolbarModule.tsx
Normal file
10
extensions/tmtv/src/getToolbarModule.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import RectangleROIOptions from './Panels/RectangleROIOptions';
|
||||
|
||||
export default function getToolbarModule({ commandsManager, servicesManager }) {
|
||||
return [
|
||||
{
|
||||
name: 'tmtv.RectangleROIThresholdOptions',
|
||||
defaultComponent: () => RectangleROIOptions({ commandsManager, servicesManager }),
|
||||
},
|
||||
];
|
||||
}
|
||||
5
extensions/tmtv/src/id.js
Normal file
5
extensions/tmtv/src/id.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
31
extensions/tmtv/src/index.tsx
Normal file
31
extensions/tmtv/src/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { id } from './id';
|
||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||
import getPanelModule from './getPanelModule';
|
||||
import init from './init';
|
||||
import commandsModule from './commandsModule';
|
||||
import getToolbarModule from './getToolbarModule';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const tmtvExtension = {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id,
|
||||
preRegistration({ servicesManager, commandsManager, extensionManager, configuration = {} }) {
|
||||
init({ servicesManager, commandsManager, extensionManager, configuration });
|
||||
},
|
||||
getToolbarModule,
|
||||
getPanelModule,
|
||||
getHangingProtocolModule,
|
||||
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
|
||||
return commandsModule({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default tmtvExtension;
|
||||
52
extensions/tmtv/src/init.js
Normal file
52
extensions/tmtv/src/init.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
addTool,
|
||||
RectangleROIStartEndThresholdTool,
|
||||
CircleROIStartEndThresholdTool,
|
||||
} from '@cornerstonejs/tools';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
|
||||
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
|
||||
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} servicesManager
|
||||
* @param {Object} configuration
|
||||
* @param {Object|Array} configuration.csToolsConfig
|
||||
*/
|
||||
export default function init({ servicesManager }) {
|
||||
const { measurementService, displaySetService, cornerstoneViewportService } =
|
||||
servicesManager.services;
|
||||
|
||||
addTool(RectangleROIStartEndThresholdTool);
|
||||
addTool(CircleROIStartEndThresholdTool);
|
||||
|
||||
const { RectangleROIStartEndThreshold, CircleROIStartEndThreshold } =
|
||||
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
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'CircleROIStartEndThreshold',
|
||||
CircleROIStartEndThreshold.matchingCriteria,
|
||||
CircleROIStartEndThreshold.toAnnotation,
|
||||
CircleROIStartEndThreshold.toMeasurement
|
||||
);
|
||||
}
|
||||
209
extensions/tmtv/src/utils/calculateSUVPeakWorker.js
Normal file
209
extensions/tmtv/src/utils/calculateSUVPeakWorker.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { utilities } from '@cornerstonejs/core';
|
||||
import { utilities as cstUtils } from '@cornerstonejs/tools';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
|
||||
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
|
||||
import { expose } from 'comlink';
|
||||
|
||||
const createVolume = ({ dimensions, origin, direction, spacing, metadata, 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 voxelManager = utilities.VoxelManager.createScalarVolumeVoxelManager({
|
||||
scalarData,
|
||||
dimensions,
|
||||
numberOfComponents: 1,
|
||||
});
|
||||
return {
|
||||
imageData,
|
||||
spacing,
|
||||
origin,
|
||||
direction,
|
||||
metadata,
|
||||
voxelManager,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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({ labelmapProps, referenceVolumeProps, annotations, segmentIndex = 1 }) {
|
||||
const labelmapInfo = createVolume(labelmapProps);
|
||||
const referenceInfo = createVolume(referenceVolumeProps);
|
||||
|
||||
if (referenceInfo.metadata.Modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dimensions, imageData: labelmapImageData } = labelmapInfo;
|
||||
const { imageData: referenceVolumeImageData } = referenceInfo;
|
||||
|
||||
let boundsIJK;
|
||||
// Todo: using the first annotation for now
|
||||
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
|
||||
|
||||
const rectangleCornersIJK = pointsToUse.map(world => {
|
||||
const ijk = vec3.fromValues(0, 0, 0);
|
||||
referenceVolumeImageData.worldToIndex(world, ijk);
|
||||
return ijk;
|
||||
});
|
||||
|
||||
boundsIJK = cstUtils.boundingBox.getBoundingBoxAroundShape(rectangleCornersIJK, dimensions);
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let maxIJK = [0, 0, 0];
|
||||
let maxLPS = [0, 0, 0];
|
||||
|
||||
const callback = ({ pointIJK, pointLPS }) => {
|
||||
const value = labelmapInfo.voxelManager.getAtIJKPoint(pointIJK);
|
||||
|
||||
if (value !== segmentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceValue = referenceInfo.voxelManager.getAtIJKPoint(pointIJK);
|
||||
|
||||
if (referenceValue > max) {
|
||||
max = referenceValue;
|
||||
maxIJK = pointIJK;
|
||||
maxLPS = pointLPS;
|
||||
}
|
||||
};
|
||||
|
||||
labelmapInfo.voxelManager.forEach(callback, {
|
||||
boundsIJK,
|
||||
imageData: labelmapImageData,
|
||||
isInObject: () => true,
|
||||
returnPoints: true,
|
||||
});
|
||||
|
||||
const direction = labelmapImageData.getDirection().slice(0, 3);
|
||||
|
||||
/**
|
||||
* 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(maxIJK, secondaryCircleWorld);
|
||||
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
|
||||
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
|
||||
const suvPeakCirclePoints = [bottomWorld, topWorld];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
cstUtils.pointInSurroundingSphereCallback(
|
||||
referenceVolumeImageData,
|
||||
suvPeakCirclePoints,
|
||||
suvPeakMeanCallback
|
||||
);
|
||||
|
||||
const mean = acc / count;
|
||||
|
||||
return {
|
||||
max,
|
||||
maxIJK,
|
||||
maxLPS,
|
||||
mean,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTMTV(labelmapProps, segmentIndex = 1) {
|
||||
const labelmaps = labelmapProps.map(props => createVolume(props));
|
||||
|
||||
const mergedLabelmap =
|
||||
labelmaps.length === 1
|
||||
? labelmaps[0]
|
||||
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function getTotalLesionGlycolysis({ labelmapProps, referenceVolumeProps }) {
|
||||
const labelmaps = labelmapProps.map(props => createVolume(props));
|
||||
|
||||
const mergedLabelmap =
|
||||
labelmaps.length === 1
|
||||
? labelmaps[0]
|
||||
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
|
||||
|
||||
// grabbing the first labelmap referenceVolume since it will be the same for all
|
||||
const { spacing } = labelmaps[0];
|
||||
|
||||
const ptVolume = createVolume(referenceVolumeProps);
|
||||
|
||||
let suv = 0;
|
||||
let totalLesionVoxelCount = 0;
|
||||
const scalarDataLength = mergedLabelmap.voxelManager.getScalarDataLength();
|
||||
for (let i = 0; i < scalarDataLength; i++) {
|
||||
// if not background
|
||||
if (mergedLabelmap.voxelManager.getAtIndex(i) !== 0) {
|
||||
suv += ptVolume.voxelManager.getAtIndex(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;
|
||||
}
|
||||
|
||||
const obj = {
|
||||
calculateSuvPeak,
|
||||
calculateTMTV,
|
||||
getTotalLesionGlycolysis,
|
||||
};
|
||||
|
||||
expose(obj);
|
||||
42
extensions/tmtv/src/utils/calculateTMTV.ts
Normal file
42
extensions/tmtv/src/utils/calculateTMTV.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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, voxelManager } = mergedLabelmap;
|
||||
|
||||
// count non-zero values inside the outputData, this would
|
||||
// consider the overlapping regions to be only counted once
|
||||
let numVoxels = 0;
|
||||
const callback = ({ value }) => {
|
||||
if (value > 0) {
|
||||
numVoxels += 1;
|
||||
}
|
||||
};
|
||||
|
||||
voxelManager.forEach(callback, {
|
||||
imageData,
|
||||
isInObject: () => true,
|
||||
});
|
||||
|
||||
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
|
||||
}
|
||||
|
||||
export default calculateTMTV;
|
||||
1554
extensions/tmtv/src/utils/colormaps/index.js
Normal file
1554
extensions/tmtv/src/utils/colormaps/index.js
Normal file
File diff suppressed because it is too large
Load Diff
45
extensions/tmtv/src/utils/createAndDownloadTMTVReport.js
Normal file
45
extensions/tmtv/src/utils/createAndDownloadTMTVReport.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export default function createAndDownloadTMTVReport(segReport, additionalReportRows, options = {}) {
|
||||
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 = options.filename ?? `${firstReport.PatientID}_tmtv.csv`;
|
||||
a.click();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
import { classes, DicomMetadataStore } from '@ohif/core';
|
||||
import { adaptersRT } from '@cornerstonejs/adapters';
|
||||
|
||||
const { datasetToBlob } = dcmjs.data;
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
|
||||
export default function dicomRTAnnotationExport(annotations) {
|
||||
const dataset = adaptersRT.Cornerstone3D.RTSS.generateRTSSFromAnnotations(
|
||||
annotations,
|
||||
metadataProvider,
|
||||
DicomMetadataStore
|
||||
);
|
||||
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;
|
||||
73
extensions/tmtv/src/utils/getThresholdValue.ts
Normal file
73
extensions/tmtv/src/utils/getThresholdValue.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as csTools from '@cornerstonejs/tools';
|
||||
|
||||
function getRoiStats(referencedVolume, annotations) {
|
||||
// roiStats
|
||||
const { imageData } = referencedVolume;
|
||||
const values = imageData.getPointData().getScalars().getData();
|
||||
|
||||
// Todo: add support for other strategies
|
||||
const { fn, baseValue } = _getStrategyFn('max');
|
||||
let value = baseValue;
|
||||
|
||||
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 value;
|
||||
}
|
||||
|
||||
function getThresholdValues(
|
||||
annotationUIDs,
|
||||
referencedVolumes,
|
||||
config
|
||||
): { ptLower: number; ptUpper: number; ctLower: number; ctUpper: number } {
|
||||
if (config.strategy === 'range') {
|
||||
return {
|
||||
ptLower: Number(config.ptLower),
|
||||
ptUpper: Number(config.ptUpper),
|
||||
ctLower: Number(config.ctLower),
|
||||
ctUpper: Number(config.ctUpper),
|
||||
};
|
||||
}
|
||||
|
||||
const { weight } = config;
|
||||
const annotations = annotationUIDs.map(annotationUID =>
|
||||
csTools.annotation.state.getAnnotation(annotationUID)
|
||||
);
|
||||
|
||||
const ptValue = getRoiStats(referencedVolumes[0], annotations);
|
||||
|
||||
return {
|
||||
ctLower: -Infinity,
|
||||
ctUpper: +Infinity,
|
||||
ptLower: weight * ptValue,
|
||||
ptUpper: +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;
|
||||
97
extensions/tmtv/src/utils/handleROIThresholding.ts
Normal file
97
extensions/tmtv/src/utils/handleROIThresholding.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Segment, Segmentation } from '@cornerstonejs/tools/types';
|
||||
import { triggerEvent, eventTarget, Enums } from '@cornerstonejs/core';
|
||||
|
||||
export const handleROIThresholding = async ({
|
||||
segmentationId,
|
||||
commandsManager,
|
||||
segmentationService,
|
||||
}: withAppTypes<{
|
||||
segmentationId: string;
|
||||
}>) => {
|
||||
const segmentation = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
|
||||
progress: 0,
|
||||
type: 'Calculate Lesion Stats',
|
||||
id: segmentationId,
|
||||
});
|
||||
|
||||
// re-calculating the cached stats for the active segmentation
|
||||
const updatedPerSegmentCachedStats = {};
|
||||
for (const [segmentIndex, segment] of Object.entries(segmentation.segments)) {
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericSegmentIndex = Number(segmentIndex);
|
||||
|
||||
const lesionStats = await commandsManager.run('getLesionStats', {
|
||||
segmentationId,
|
||||
segmentIndex: numericSegmentIndex,
|
||||
});
|
||||
|
||||
const suvPeak = await commandsManager.run('calculateSuvPeak', {
|
||||
segmentationId,
|
||||
segmentIndex: numericSegmentIndex,
|
||||
});
|
||||
|
||||
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
|
||||
|
||||
// update segDetails with the suv peak for the active segmentation
|
||||
const cachedStats = {
|
||||
lesionStats,
|
||||
suvPeak,
|
||||
lesionGlyoclysisStats,
|
||||
};
|
||||
|
||||
const updatedSegment: Segment = {
|
||||
...segment,
|
||||
cachedStats: {
|
||||
...segment.cachedStats,
|
||||
...cachedStats,
|
||||
},
|
||||
};
|
||||
|
||||
updatedPerSegmentCachedStats[numericSegmentIndex] = cachedStats;
|
||||
|
||||
segmentation.segments[segmentIndex] = updatedSegment;
|
||||
}
|
||||
|
||||
// all available segmentations
|
||||
const segmentations = segmentationService.getSegmentations();
|
||||
const tmtv = await commandsManager.run('calculateTMTV', { segmentations });
|
||||
|
||||
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
|
||||
progress: 100,
|
||||
type: 'Calculate Lesion Stats',
|
||||
id: segmentationId,
|
||||
});
|
||||
|
||||
// add the tmtv to all the segment cachedStats, although it is a global
|
||||
// value but we don't have any other way to display it for now
|
||||
// Update all segmentations with the calculated TMTV
|
||||
segmentations.forEach(segmentation => {
|
||||
segmentation.cachedStats = {
|
||||
...segmentation.cachedStats,
|
||||
tmtv,
|
||||
};
|
||||
|
||||
// Update each segment within the segmentation
|
||||
Object.keys(segmentation.segments).forEach(segmentIndex => {
|
||||
segmentation.segments[segmentIndex].cachedStats = {
|
||||
...segmentation.segments[segmentIndex].cachedStats,
|
||||
tmtv,
|
||||
};
|
||||
});
|
||||
|
||||
// Update the segmentation object
|
||||
const updatedSegmentation: Segmentation = {
|
||||
...segmentation,
|
||||
segments: {
|
||||
...segmentation.segments,
|
||||
},
|
||||
};
|
||||
|
||||
segmentationService.addOrUpdateSegmentation(updatedSegmentation);
|
||||
});
|
||||
};
|
||||
494
extensions/tmtv/src/utils/hpViewports.ts
Normal file
494
extensions/tmtv/src/utils/hpViewports.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
// Common sync group configurations
|
||||
const cameraPositionSync = (id: string) => ({
|
||||
type: 'cameraPosition',
|
||||
id,
|
||||
source: true,
|
||||
target: true,
|
||||
});
|
||||
|
||||
const hydrateSegSync = {
|
||||
type: 'hydrateseg',
|
||||
id: 'sameFORId',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
matchingRules: ['sameFOR'],
|
||||
},
|
||||
};
|
||||
|
||||
const ctAXIAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
cameraPositionSync('axialSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ctSAGITTAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ctSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
syncGroups: [
|
||||
cameraPositionSync('sagittalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ctCORONAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ctCORONAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
syncGroups: [
|
||||
cameraPositionSync('coronalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ptAXIAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ptAXIAL',
|
||||
viewportType: 'volume',
|
||||
background: [1, 1, 1],
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ptToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
cameraPositionSync('axialSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: true,
|
||||
target: false,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ptSAGITTAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ptSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'ptToolGroup',
|
||||
syncGroups: [
|
||||
cameraPositionSync('sagittalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: true,
|
||||
target: false,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ptCORONAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'ptCORONAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'ptToolGroup',
|
||||
syncGroups: [
|
||||
cameraPositionSync('coronalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: true,
|
||||
target: false,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fusionAXIAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
cameraPositionSync('axialSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
id: 'ptDisplaySet',
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
{ value: 1, opacity: 0.9 },
|
||||
],
|
||||
},
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fusionSAGITTAL = {
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
// initialImageOptions: {
|
||||
// index: 180,
|
||||
// preset: 'middle', // 'first', 'last', 'middle'
|
||||
// },
|
||||
syncGroups: [
|
||||
cameraPositionSync('sagittalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
id: 'ptDisplaySet',
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
{ value: 1, opacity: 0.9 },
|
||||
],
|
||||
},
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fusionCORONAL = {
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionCoronal',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
// initialImageOptions: {
|
||||
// index: 180,
|
||||
// preset: 'middle', // 'first', 'last', 'middle'
|
||||
// },
|
||||
syncGroups: [
|
||||
cameraPositionSync('coronalSync'),
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
id: 'ptDisplaySet',
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
{ value: 1, opacity: 0.9 },
|
||||
],
|
||||
},
|
||||
voi: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mipSAGITTAL: AppTypes.HangingProtocol.Viewport = {
|
||||
viewportOptions: {
|
||||
viewportId: 'mipSagittal',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'mipToolGroup',
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptFusionWLSync',
|
||||
source: true,
|
||||
target: false,
|
||||
options: {
|
||||
syncColormap: false,
|
||||
syncInvertState: false,
|
||||
},
|
||||
},
|
||||
hydrateSegSync,
|
||||
],
|
||||
|
||||
// Custom props can be used to set custom properties which extensions
|
||||
// can react on.
|
||||
customViewportProps: {
|
||||
// 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: {
|
||||
custom: 'getPTVOIRange',
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export {
|
||||
ctAXIAL,
|
||||
ctSAGITTAL,
|
||||
ctCORONAL,
|
||||
ptAXIAL,
|
||||
ptSAGITTAL,
|
||||
ptCORONAL,
|
||||
fusionAXIAL,
|
||||
fusionSAGITTAL,
|
||||
fusionCORONAL,
|
||||
mipSAGITTAL,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import SUPPORTED_TOOLS from './constants/supportedTools';
|
||||
import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone';
|
||||
|
||||
const CircleROIStartEndThreshold = {
|
||||
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: 'CircleROIStartEndThreshold',
|
||||
// getReport,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default CircleROIStartEndThreshold;
|
||||
@@ -0,0 +1,63 @@
|
||||
import SUPPORTED_TOOLS from './constants/supportedTools';
|
||||
import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
uid: annotationUID,
|
||||
SOPInstanceUID,
|
||||
FrameOfReferenceUID,
|
||||
// points,
|
||||
metadata,
|
||||
referenceSeriesUID: SeriesInstanceUID,
|
||||
referenceStudyUID: StudyInstanceUID,
|
||||
toolName: metadata.toolName,
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
label: metadata.label,
|
||||
data: data.cachedStats,
|
||||
type: 'RectangleROIStartEndThreshold',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default RectangleROIStartEndThreshold;
|
||||
@@ -0,0 +1 @@
|
||||
export default ['RectangleROIStartEndThreshold'];
|
||||
@@ -0,0 +1,41 @@
|
||||
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
|
||||
import CircleROIStartEndThreshold from './CircleROIStartEndThreshold';
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
CircleROIStartEndThreshold: {
|
||||
toAnnotation: CircleROIStartEndThreshold.toAnnotation,
|
||||
toMeasurement: csToolsAnnotation =>
|
||||
CircleROIStartEndThreshold.toMeasurement(
|
||||
csToolsAnnotation,
|
||||
displaySetService,
|
||||
cornerstoneViewportService
|
||||
),
|
||||
matchingCriteria: [
|
||||
{
|
||||
valueType: measurementService.VALUE_TYPES.ROI_THRESHOLD_MANUAL,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default measurementServiceMappingsFactory;
|
||||
Reference in New Issue
Block a user