feat: cornerstone3D stack and volume viewports (#2787)
* feat: cs3d working stack viewport and tools (#19) * Squashed everything * fix weird react issue * fix eslint / prettier stuff * thumbnails work now with cpu rendering * feat: use new loadImageToCanvas in cs3d * make jump to measurement work * fix active thumbnail * fix measurement delete * fix window level presets * remove segmentation and sync groups for now * fix the dicom pdf and dicom video * fix cornerstone window assignment for cypress * apply review comments Co-authored-by: Erik <erik.sweed@gmail.com> * feat: cs3d tools and toolGroups (#20) * add more tools to work with cs3d mode * fix hotkeys * add stack manager usage for stach viewports * add image scrollbar * wip viewport overlay * fix toAnnotation schema for tools * fix the unnecessary size change that triggered resize * hanging protocol improvement to allow unmatched errors * study description matching for hanging protocol * fix the displaysetOptions to work * fix handle the active tool when a new viewport is added * fix separate toolGroups for mode * apply review comments * apply review comments * yarn lock * feat: overlay component (#21) * fix default displayset options * add viewport overlay * apply review comments * feat: loading and orientation indicators (#22) * add loading indicator * add orientation marker initial work * apply review comments * fix: orientation markers (#23) * finished the orientation markers * fix various broken cypress tests * apply review comments * update yarn lock * feat: re-working measurement tracking mode with cornerstone3d (#2805) * feat: cs3d working stack viewport and tools (#19) * Squashed everything * fix weird react issue * fix eslint / prettier stuff * thumbnails work now with cpu rendering * feat: use new loadImageToCanvas in cs3d * make jump to measurement work * fix active thumbnail * fix measurement delete * fix window level presets * remove segmentation and sync groups for now * fix the dicom pdf and dicom video * fix cornerstone window assignment for cypress * apply review comments Co-authored-by: Erik <erik.sweed@gmail.com> * feat: Add Measurement tracking mode with cs3D (#2789) * feat: first render for cornerstone3d tracked viewport * make tool active work * wip for SR extension * renamed dicom sr to cornerstone dicom sr * remove cornerstone from dicom pdf and video * move dicom sr logic to sr extension * feat: Add hydration for the length tool * fix SR display tool for length using cs3d * fix default config * fix: various bugs with sr viewport and tracking * fix promptying to continue tracking for when SR is created * feat: add keep trackign of unique identifiers * fix hydration for same imageIds * feat: Add SR toolGroup creation on modeEnter * feat: remove the need for separate mapper for SR hydration * add SR display for ellipse * handle hydration of elliptical ROI tool * remove cornerstone extension * add arrow mapping * feat: Add ArrowAnnotate SR display and hydration * apply review comments * apply review comments * move viewport labels to the viewportData * apply review comments * fix: integration cypress tests with cornerstone3D and add CINE tool (#2795) * fix: integration cypress tests with cornerstone3D * revert to addOrUpdate as it makes more sense * fix local drag and drop * fix tests * move dicomLoaderService to cornerstone extension * fix various import bugs * fix bug for local PT series * fix various unit tests * bump cs3d versions * add angle and magnify tool * bump dependencies to avoid broken peerDeps * feat: add initial work for capture using cs3d * feat: show annotations on the image capture * feat: add svg layer export * feat: Add CINE Tool * feat: remove unnecessary viewport rendering for cine state changes Co-authored-by: Erik Ziegler <erik.sweed@gmail.com> * docs: modify and improve documentation (#2800) * cleanup docs versionings * feat: Add all version explanations * version docs for 3.0 * wip for changing docs * wip for updated docs * add utility module documentation * fix demo with nohoisting of history * add slides and video to resources * apply review comments * fix: drag and drop SR into SR viewport (#2803) * update yarn lock Co-authored-by: Erik <erik.sweed@gmail.com> * update pathnames to match v3-stable * update the e2e pathname * fix: various bugs with regard to tracking workflow (#2811) * fix: various issues with measurement panel * fix: update default tool style for annotations * fix: annotatoin label getting removed * feat: Add backward compatibility for SR hydration with legacy cornerstone * fix: cursors and ellipse ROI max style * fix: ArrowAnnotate SRDisplay * apply review comments * bump package versions * fix: bugin rehydration of SR * fix: e2e tests * fix active viewport thickness and arrowTool ui * add readme for measurement tracking * use uploaded image for readme * add back images * try to fix e2e test * fix: window level presets hotkeys * Update README.md * update yarn lock * feat: volume api and TMTV mode (#2817) * feat: volumeAPI and TMTV mode * feat: use cs3d cache service to obtain viewportData * wip for volume api * wip: fusion viewport * feat: add blend mode option * wip for image scrollbar * fix drag and drop thumbnail * wip for image scrollbar for voluems * fix: element mismatch bug for scroll * feat: Add image scrollbar to volumes * feat: add syncGroups to volume api * feat: add tmtv mode initial setup * feat: add initial image options for the stack viewports * feat: Add custom load strategy for volume viewports via HP * apply review comments fix: Jump presets cs3d (#2812) * feat: Add JumpPreset to OHIF for Cornerstone3D * fix: accessing viewport service from servicesManager feat: volume API and TMTV mode (#2814) * fix: do not display overlays on mip viewports * feat: add optional disableCommands for toggle buttons * fix: toggleCrossharis * feat: config the crosshairs * feat: add PetSUV Panel for changing metadata * feat: initial work for the rectangleROIThreshold panel * wip: measurement service * roi threshold working * feat: Add displayText to segmentations * feat: add remove segmentation * feat: add csv export * feat: add RT export for annotations in tmtv mode * fix: fusion to use pt in tmtv mode and measuremet mappings * fix: various bugs * apply review comments * apply review comments * feat: add fusion color maps * add readme to tmtv mode * Update README.md * fix: try to fix build * fix unit tests * Update README.md * feat: add about to the panel * fix: changing strategy in roi panel * apply review comments * update package versions * wip for stackPrefetch * fix: cornerstone3d hydration and renaming (#2818) * update readme * renamed cornerstone extension * wip for renaming cornerstone3D variables * wip for renaming cornerstone3D variables * wip for fixing bugs for SR viewport * fix: jumpToMeasurement and initial label after hydration * fix: fileName capitalization * feat: use the new prefetch stack in the cs3d (#2820) * feat: use the new prefetch stack in the cs3d * use viewport scroll api for stack viewport * fix cine stop when scrollbar changes * feat: use new prefetch events * fix loading state to not show repeatedly * fix: various bugs for tmtv mode thresholding and new icons (#2823) * feat: make tmtv mode available in worklist * feat: add new icons for tmtv mode * feat: add fusion color icon * fix: parallel scale calculation * fix: bump Cornerstone to get large image support working * fix: Fix issues with CPU viewport flipping, including config files * fix: bump cornerstone to fix magnify tool * fix: Bump Cornerstone version to fix resetCamera issue in StackViewport * ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys * fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors * feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838) Also works with the compile time add that the existing cli uses, so that both build types work. Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes. * bump: dependency versions to fix hydration bugs (#2848) * bump: dcmjs version to fix hydration bugs * try to fix tests * bump dependency versions Co-authored-by: Alireza <ar.sedghi@gmail.com> Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
668 files changed
+41669
-13410
No files matched your search
@@ -0,0 +1,8 @@
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
const webpack = require('webpack');
|
||||
const merge = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, './..');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
|
||||
|
||||
return merge(commonConfig, {
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
timings: true,
|
||||
assets: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
modules: false,
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
path: ROOT_DIR,
|
||||
library: 'OHIFExtDICOMSR',
|
||||
libraryTarget: 'umd',
|
||||
libraryExport: 'default',
|
||||
filename: pkg.main,
|
||||
},
|
||||
plugins: [
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# TMTV Extension
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('../../babel.config.js');
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@ohif/extension-tmtv",
|
||||
"version": "3.0.1",
|
||||
"description": "OHIF extension for Total Metabolic Tumore Volume",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/index.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"start": "yarn run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "^3.0.0",
|
||||
"@ohif/ui": "^2.0.0",
|
||||
"dcmjs": "0.22.0",
|
||||
"dicom-parser": "^1.8.9",
|
||||
"hammerjs": "^2.0.8",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^17.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.7.6",
|
||||
"classnames": "^2.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Input, Button } from '@ohif/ui';
|
||||
import { classes, DicomMetadataStore } from '@ohif/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_MEATADATA = {
|
||||
PatientWeight: null,
|
||||
PatientSex: null,
|
||||
SeriesTime: null,
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideTotalDose: null,
|
||||
RadionuclideHalfLife: null,
|
||||
RadiopharmaceuticalStartTime: null,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* PETSUV panel enables the user to modify the patient related information, such as
|
||||
* patient sex, patientWeight. This is allowed since
|
||||
* sometimes these metadata are missing or wrong. By changing them
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export default function PanelPetSUV({ servicesManager, commandsManager }) {
|
||||
const { t } = useTranslation('PanelSUV');
|
||||
const { DisplaySetService } = servicesManager.services;
|
||||
const [metadata, setMetadata] = useState(DEFAULT_MEATADATA);
|
||||
const [ptDisplaySet, setPtDisplaySet] = useState(null);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
metadata => {
|
||||
setMetadata(prevState => {
|
||||
const newState = { ...prevState };
|
||||
Object.keys(metadata).forEach(key => {
|
||||
if (typeof metadata[key] === 'object') {
|
||||
newState[key] = {
|
||||
...prevState[key],
|
||||
...metadata[key],
|
||||
};
|
||||
} else {
|
||||
newState[key] = metadata[key];
|
||||
}
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
[metadata]
|
||||
);
|
||||
|
||||
const getMatchingPTDisplaySet = useCallback(() => {
|
||||
const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet');
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = commandsManager.runCommand('getPTMetadata', {
|
||||
ptDisplaySet,
|
||||
});
|
||||
|
||||
return {
|
||||
ptDisplaySet,
|
||||
metadata,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const displaySets = DisplaySetService.activeDisplaySets;
|
||||
|
||||
if (!displaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySetInfo = getMatchingPTDisplaySet();
|
||||
|
||||
if (!displaySetInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ptDisplaySet, metadata } = displaySetInfo;
|
||||
setPtDisplaySet(ptDisplaySet);
|
||||
setMetadata(metadata);
|
||||
}, []);
|
||||
|
||||
// get the patientMetadata from the StudyInstanceUIDs and update the state
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = DisplaySetService.subscribe(
|
||||
DisplaySetService.EVENTS.DISPLAY_SETS_ADDED,
|
||||
() => {
|
||||
const displaySetInfo = getMatchingPTDisplaySet();
|
||||
|
||||
if (!displaySetInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ptDisplaySet, metadata } = displaySetInfo;
|
||||
setPtDisplaySet(ptDisplaySet);
|
||||
setMetadata(metadata);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateMetadata() {
|
||||
if (!ptDisplaySet) {
|
||||
throw new Error('No ptDisplaySet found');
|
||||
}
|
||||
// metadata should be dcmjs naturalized
|
||||
DicomMetadataStore.updateMetadataForSeries(
|
||||
ptDisplaySet.StudyInstanceUID,
|
||||
ptDisplaySet.SeriesInstanceUID,
|
||||
metadata
|
||||
);
|
||||
|
||||
// update the displaySets
|
||||
DisplaySetService.setDisplaySetMetadataInvalidated(
|
||||
ptDisplaySet.displaySetInstanceUID
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
|
||||
{
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col p-4 space-y-2 bg-primary-dark">
|
||||
<Input
|
||||
label={t('Patient Sex')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={metadata.PatientSex || ''}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
PatientSex: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Patient Weight')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={metadata.PatientWeight || ''}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
PatientWeight: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Total Dose')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={
|
||||
metadata.RadiopharmaceuticalInformationSequence
|
||||
.RadionuclideTotalDose || ''
|
||||
}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideTotalDose: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Half Life')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={
|
||||
metadata.RadiopharmaceuticalInformationSequence
|
||||
.RadionuclideHalfLife || ''
|
||||
}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideHalfLife: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Injection Time')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={
|
||||
metadata.RadiopharmaceuticalInformationSequence
|
||||
.RadiopharmaceuticalStartTime || ''
|
||||
}
|
||||
onChange={e => {
|
||||
handleMetadataChange({
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadiopharmaceuticalStartTime: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Acquisition Time')}
|
||||
labelClassName="text-white"
|
||||
className="mt-1 mb-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={metadata.SeriesTime || ''}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={updateMetadata}
|
||||
className="px-1 py-1 mx-4 mt-2 text-xs text-white border-b border-transparent"
|
||||
>
|
||||
Reload Data
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PanelPetSUV.propTypes = {
|
||||
servicesManager: PropTypes.shape({
|
||||
services: PropTypes.shape({
|
||||
MeasurementService: PropTypes.shape({
|
||||
getMeasurements: PropTypes.func.isRequired,
|
||||
subscribe: PropTypes.func.isRequired,
|
||||
EVENTS: PropTypes.object.isRequired,
|
||||
VALUE_TYPES: PropTypes.object.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonGroup } from '@ohif/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ExportReports({ segmentations, tmtvValue, config, commandsManager }) {
|
||||
const { t } = useTranslation('PanelSUVExport');
|
||||
|
||||
return (
|
||||
<>
|
||||
{segmentations?.length ? (
|
||||
<div className="flex justify-center mt-4 space-x-2">
|
||||
<ButtonGroup color="black" size="inherit">
|
||||
<Button
|
||||
className="px-2 py-2 text-base"
|
||||
disabled={tmtvValue === null}
|
||||
onClick={() => {
|
||||
commandsManager.runCommand('exportTMTVReportCSV', {
|
||||
segmentations,
|
||||
tmtv: tmtvValue,
|
||||
config,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Export CSV')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup color="black" size="inherit">
|
||||
<Button
|
||||
className="px-2 py-2 text-base"
|
||||
onClick={() => {
|
||||
commandsManager.runCommand('createTMTVRTReport');
|
||||
}}
|
||||
disabled={tmtvValue === null}
|
||||
>
|
||||
{t('Create RT Report')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportReports;
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
import React, { useEffect, useState, useCallback, useReducer } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SegmentationTable, Button, Icon } from '@ohif/ui';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import segmentationEditHandler from './segmentationEditHandler';
|
||||
import ExportReports from './ExportReports';
|
||||
import ROIThresholdConfiguration, {
|
||||
ROI_STAT,
|
||||
} from './ROIThresholdConfiguration';
|
||||
|
||||
const LOWER_THRESHOLD_DEFAULT = 0;
|
||||
const UPPER_THRESHOLD_DEFAULT = 100;
|
||||
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
|
||||
const DEFAULT_STRATEGY = ROI_STAT;
|
||||
|
||||
function reducer(state, action) {
|
||||
const { payload } = action;
|
||||
const { strategy, lower, upper, weight } = payload;
|
||||
|
||||
switch (action.type) {
|
||||
case 'setStrategy':
|
||||
return {
|
||||
...state,
|
||||
strategy,
|
||||
};
|
||||
case 'setThreshold':
|
||||
return {
|
||||
...state,
|
||||
lower: lower ? lower : state.lower,
|
||||
upper: upper ? upper : state.upper,
|
||||
};
|
||||
case 'setWeight':
|
||||
return {
|
||||
...state,
|
||||
weight,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PanelRoiThresholdSegmentation({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
}) {
|
||||
const { SegmentationService } = servicesManager.services;
|
||||
|
||||
const { t } = useTranslation('PanelSUV');
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [labelmapLoading, setLabelmapLoading] = useState(false);
|
||||
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
|
||||
const [segmentations, setSegmentations] = useState(() =>
|
||||
SegmentationService.getSegmentations()
|
||||
);
|
||||
|
||||
const [config, dispatch] = useReducer(reducer, {
|
||||
strategy: DEFAULT_STRATEGY,
|
||||
lower: LOWER_THRESHOLD_DEFAULT,
|
||||
upper: UPPER_THRESHOLD_DEFAULT,
|
||||
weight: WEIGHT_DEFAULT,
|
||||
});
|
||||
|
||||
const [tmtvValue, setTmtvValue] = useState(null);
|
||||
|
||||
const runCommand = useCallback(
|
||||
(commandName, commandOptions = {}) => {
|
||||
return commandsManager.runCommand(commandName, commandOptions);
|
||||
},
|
||||
[commandsManager]
|
||||
);
|
||||
|
||||
const handleTMTVCalculation = useCallback(() => {
|
||||
const tmtv = runCommand('calculateTMTV', { segmentations });
|
||||
|
||||
if (tmtv !== undefined) {
|
||||
setTmtvValue(tmtv.toFixed(2));
|
||||
}
|
||||
}, [segmentations, runCommand]);
|
||||
|
||||
const handleROIThresholding = useCallback(() => {
|
||||
const labelmap = runCommand('thresholdSegmentationByRectangleROITool', {
|
||||
segmentationId: selectedSegmentationId,
|
||||
config,
|
||||
});
|
||||
|
||||
const lesionStats = runCommand('getLesionStats', { labelmap });
|
||||
const suvPeak = runCommand('calculateSuvPeak', { labelmap });
|
||||
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
|
||||
|
||||
// update segDetails with the suv peak for the active segmentation
|
||||
const segmentation = SegmentationService.getSegmentation(
|
||||
selectedSegmentationId
|
||||
);
|
||||
|
||||
const data = {
|
||||
lesionStats,
|
||||
suvPeak,
|
||||
lesionGlyoclysisStats,
|
||||
};
|
||||
|
||||
const notYetUpdatedAtSource = true;
|
||||
SegmentationService.addOrUpdateSegmentation(
|
||||
selectedSegmentationId,
|
||||
{
|
||||
...segmentation,
|
||||
data: Object.assign(segmentation.data, data),
|
||||
text: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`],
|
||||
},
|
||||
notYetUpdatedAtSource
|
||||
);
|
||||
|
||||
handleTMTVCalculation();
|
||||
}, [selectedSegmentationId, config]);
|
||||
|
||||
/**
|
||||
* Update UI based on segmentation changes (added, removed, updated)
|
||||
*/
|
||||
useEffect(() => {
|
||||
// ~~ Subscription
|
||||
const added = SegmentationService.EVENTS.SEGMENTATION_ADDED;
|
||||
const updated = SegmentationService.EVENTS.SEGMENTATION_UPDATED;
|
||||
const subscriptions = [];
|
||||
|
||||
[added, updated].forEach(evt => {
|
||||
const { unsubscribe } = SegmentationService.subscribe(evt, () => {
|
||||
const segmentations = SegmentationService.getSegmentations();
|
||||
setSegmentations(segmentations);
|
||||
});
|
||||
subscriptions.push(unsubscribe);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = SegmentationService.subscribe(
|
||||
SegmentationService.EVENTS.SEGMENTATION_REMOVED,
|
||||
() => {
|
||||
const segmentations = SegmentationService.getSegmentations();
|
||||
setSegmentations(segmentations);
|
||||
|
||||
if (segmentations.length > 0) {
|
||||
setSelectedSegmentationId(segmentations[0].id);
|
||||
handleTMTVCalculation();
|
||||
} else {
|
||||
setSelectedSegmentationId(null);
|
||||
setTmtvValue(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle visibility of the segmentation
|
||||
*/
|
||||
useEffect(() => {
|
||||
const subscription = SegmentationService.subscribe(
|
||||
SegmentationService.EVENTS.SEGMENTATION_VISIBILITY_CHANGED,
|
||||
({ segmentation }) => {
|
||||
runCommand('toggleSegmentationVisibility', {
|
||||
segmentationId: segmentation.id,
|
||||
});
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [SegmentationService]);
|
||||
|
||||
/**
|
||||
* Whenever the segmentations change, update the TMTV calculations
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!selectedSegmentationId && segmentations.length > 0) {
|
||||
setSelectedSegmentationId(segmentations[0].id);
|
||||
}
|
||||
|
||||
handleTMTVCalculation();
|
||||
}, [segmentations, selectedSegmentationId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
|
||||
<div className="flex mx-4 my-4 mb-4 space-x-4">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setLabelmapLoading(true);
|
||||
setTimeout(() => {
|
||||
runCommand('createNewLabelmapFromPT').then(segmentationId => {
|
||||
setLabelmapLoading(false);
|
||||
setSelectedSegmentationId(segmentationId);
|
||||
});
|
||||
});
|
||||
}}
|
||||
className={classnames(
|
||||
'text-xs text-white border-b border-transparent'
|
||||
)}
|
||||
>
|
||||
{labelmapLoading ? 'loading ...' : 'New Label'}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleROIThresholding}
|
||||
className="text-xs text-white border-b border-transparent "
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-around h-8 mb-2 border-t outline-none cursor-pointer select-none bg-secondary-dark first:border-0 border-secondary-light"
|
||||
onClick={() => {
|
||||
setShowConfig(!showConfig);
|
||||
}}
|
||||
>
|
||||
<div className="px-4 text-base text-white">
|
||||
{t('ROI Threshold Configuration')}
|
||||
</div>
|
||||
</div>
|
||||
{showConfig && (
|
||||
<ROIThresholdConfiguration
|
||||
config={config}
|
||||
dispatch={dispatch}
|
||||
runCommand={runCommand}
|
||||
/>
|
||||
)}
|
||||
{/* show segmentation table */}
|
||||
<div className="mt-4">
|
||||
{segmentations?.length ? (
|
||||
<SegmentationTable
|
||||
title={t('Segmentations')}
|
||||
amount={segmentations.length}
|
||||
segmentations={segmentations}
|
||||
activeSegmentationId={selectedSegmentationId}
|
||||
onClick={id => {
|
||||
runCommand('setSegmentationActiveForToolGroups', {
|
||||
segmentationId: id,
|
||||
});
|
||||
setSelectedSegmentationId(id);
|
||||
}}
|
||||
onToggleVisibility={id => {
|
||||
SegmentationService.toggleSegmentationsVisibility([id]);
|
||||
}}
|
||||
onToggleVisibilityAll={ids => {
|
||||
SegmentationService.toggleSegmentationsVisibility(ids);
|
||||
}}
|
||||
onDelete={id => {
|
||||
SegmentationService.remove(id);
|
||||
}}
|
||||
onEdit={id => {
|
||||
segmentationEditHandler({
|
||||
id,
|
||||
servicesManager,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{tmtvValue !== null ? (
|
||||
<div className="flex items-baseline justify-between px-2 py-1 mt-4 bg-secondary-dark">
|
||||
<span className="text-base font-bold tracking-widest text-white uppercase">
|
||||
{'TMTV:'}
|
||||
</span>
|
||||
<div className="text-white">{`${tmtvValue} mL`}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ExportReports
|
||||
segmentations={segmentations}
|
||||
tmtvValue={tmtvValue}
|
||||
config={config}
|
||||
commandsManager={commandsManager}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="opacity-50 hover:opacity-80 flex items-center justify-center text-blue-400 mb-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
// navigate to a url in a new tab
|
||||
window.open(
|
||||
'https://github.com/OHIF/Viewers/blob/feat/segmentation-service/modes/tmtv/README.md',
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
width="15px"
|
||||
height="15px"
|
||||
name={'info'}
|
||||
className={'ml-4 mr-3 text-primary-active'}
|
||||
/>
|
||||
<span>{'About'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PanelRoiThresholdSegmentation.propTypes = {
|
||||
commandsManager: PropTypes.shape({
|
||||
runCommand: PropTypes.func.isRequired,
|
||||
}),
|
||||
servicesManager: PropTypes.shape({
|
||||
services: PropTypes.shape({
|
||||
SegmentationService: PropTypes.shape({
|
||||
getSegmentation: PropTypes.func.isRequired,
|
||||
getSegmentations: PropTypes.func.isRequired,
|
||||
toggleSegmentationsVisibility: PropTypes.func.isRequired,
|
||||
subscribe: PropTypes.func.isRequired,
|
||||
EVENTS: PropTypes.object.isRequired,
|
||||
VALUE_TYPES: PropTypes.object.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, Button, ButtonGroup } from '@ohif/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const ROI_STAT = 'roi_stat';
|
||||
const RANGE = 'range';
|
||||
|
||||
const options = [
|
||||
{ value: ROI_STAT, label: 'Max', placeHolder: 'Max' },
|
||||
{ value: RANGE, label: 'Range', placeHolder: 'Range' },
|
||||
];
|
||||
|
||||
function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
|
||||
const { t } = useTranslation('ROIThresholdConfiguration');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-4 space-y-4 bg-primary-dark">
|
||||
<div className="flex items-end space-x-2">
|
||||
<div className="flex flex-col w-1/2 mt-2 ">
|
||||
<Select
|
||||
label={t('Strategy')}
|
||||
closeMenuOnSelect={true}
|
||||
className="mr-2 bg-black border-primary-main "
|
||||
options={options}
|
||||
placeholder={
|
||||
options.find(option => option.value === config.strategy)
|
||||
.placeHolder
|
||||
}
|
||||
value={config.strategy}
|
||||
onChange={({ value }) => {
|
||||
dispatch({
|
||||
type: 'setStrategy',
|
||||
payload: {
|
||||
strategy: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<ButtonGroup color="black" size="inherit">
|
||||
<Button
|
||||
className="px-2 py-2 text-base"
|
||||
onClick={() => runCommand('setStartSliceForROIThresholdTool')}
|
||||
>
|
||||
{t('Start')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup color="black" size="inherit">
|
||||
<Button
|
||||
className="px-2 py-2 text-base"
|
||||
onClick={() => runCommand('setEndSliceForROIThresholdTool')}
|
||||
>
|
||||
{t('End')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.strategy === ROI_STAT && (
|
||||
<Input
|
||||
label={t('Percentage of Max SUV')}
|
||||
labelClassName="text-white"
|
||||
className="mt-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.weight}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setWeight',
|
||||
payload: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{config.strategy !== ROI_STAT && (
|
||||
<div className="flex justify-between">
|
||||
<Input
|
||||
label={t('Lower')}
|
||||
labelClassName="text-white"
|
||||
className="mt-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.lower}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
lower: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={t('Upper')}
|
||||
labelClassName="text-white"
|
||||
className="mt-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={config.upper}
|
||||
onChange={e => {
|
||||
dispatch({
|
||||
type: 'setThreshold',
|
||||
payload: {
|
||||
upper: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ROIThresholdConfiguration;
|
||||
@@ -0,0 +1,3 @@
|
||||
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
|
||||
|
||||
export default PanelROIThresholdSegmentation;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Input, Dialog } from '@ohif/ui';
|
||||
|
||||
function segmentationItemEditHandler({ id, servicesManager }) {
|
||||
const { SegmentationService, UIDialogService } = servicesManager.services;
|
||||
|
||||
const segmentation = SegmentationService.getSegmentation(id);
|
||||
|
||||
const onSubmitHandler = ({ action, value }) => {
|
||||
switch (action.id) {
|
||||
case 'save': {
|
||||
SegmentationService.addOrUpdateSegmentation(
|
||||
id,
|
||||
{
|
||||
...segmentation,
|
||||
...value,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
UIDialogService.dismiss({ id: 'enter-annotation' });
|
||||
};
|
||||
|
||||
UIDialogService.create({
|
||||
id: 'enter-annotation',
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
showOverlay: true,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Enter your annotation',
|
||||
noCloseButton: true,
|
||||
value: { label: segmentation.label || '' },
|
||||
body: ({ value, setValue }) => {
|
||||
const onChangeHandler = event => {
|
||||
event.persist();
|
||||
setValue(value => ({ ...value, label: event.target.value }));
|
||||
};
|
||||
|
||||
const onKeyPressHandler = event => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmitHandler({ value, action: { id: 'save' } });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="p-4 bg-primary-dark">
|
||||
<Input
|
||||
autoFocus
|
||||
className="mt-2 bg-black border-primary-main"
|
||||
type="text"
|
||||
containerClassName="mr-2"
|
||||
value={value.label}
|
||||
onChange={onChangeHandler}
|
||||
onKeyPress={onKeyPressHandler}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
actions: [
|
||||
// temp: swap button types until colors are updated
|
||||
{ id: 'cancel', text: 'Cancel', type: 'primary' },
|
||||
{ id: 'save', text: 'Save', type: 'secondary' },
|
||||
],
|
||||
onSubmit: onSubmitHandler,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default segmentationItemEditHandler;
|
||||
@@ -0,0 +1,4 @@
|
||||
import PanelPetSUV from './PanelPetSUV';
|
||||
import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation';
|
||||
|
||||
export { PanelPetSUV, PanelROIThresholdSegmentation };
|
||||
@@ -0,0 +1,696 @@
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import OHIF from '@ohif/core';
|
||||
import * as cs from '@cornerstonejs/core';
|
||||
import * as csTools from '@cornerstonejs/tools';
|
||||
import { classes } from '@ohif/core';
|
||||
import getThresholdValues from './utils/getThresholdValue';
|
||||
import calculateSuvPeak from './utils/calculateSUVPeak';
|
||||
import calculateTMTV from './utils/calculateTMTV';
|
||||
import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
|
||||
|
||||
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
|
||||
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
const RECTANGLE_ROI_THRESHOLD_MANUAL = 'RectangleROIStartEndThreshold';
|
||||
|
||||
const commandsModule = ({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
}) => {
|
||||
const {
|
||||
ViewportGridService,
|
||||
UINotificationService,
|
||||
DisplaySetService,
|
||||
HangingProtocolService,
|
||||
ToolGroupService,
|
||||
CornerstoneViewportService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
const { getEnabledElement } = utilityModule.exports;
|
||||
|
||||
function _getActiveViewportsEnabledElement() {
|
||||
const { activeViewportIndex } = ViewportGridService.getState();
|
||||
const { element } = getEnabledElement(activeViewportIndex) || {};
|
||||
const enabledElement = cs.getEnabledElement(element);
|
||||
return enabledElement;
|
||||
}
|
||||
|
||||
function _getMatchedViewportsToolGroupIds() {
|
||||
const [matchedViewports] = HangingProtocolService.getState();
|
||||
const toolGroupIds = [];
|
||||
matchedViewports.forEach(({ viewportOptions }) => {
|
||||
const { toolGroupId } = viewportOptions;
|
||||
if (toolGroupIds.indexOf(toolGroupId) === -1) {
|
||||
toolGroupIds.push(toolGroupId);
|
||||
}
|
||||
});
|
||||
|
||||
return toolGroupIds;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
getMatchingPTDisplaySet: () => {
|
||||
// Todo: this is assuming that the hanging protocol has successfully matched
|
||||
// the correct PT. For future, we should have a way to filter out the PTs
|
||||
// that are in the viewer layout (but then we have the problem of the attenuation
|
||||
// corrected PT vs the non-attenuation correct PT)
|
||||
const matches = HangingProtocolService.getDisplaySetsMatchDetails();
|
||||
|
||||
const matchedSeriesInstanceUIDs = Array.from(matches.values()).map(
|
||||
({ SeriesInstanceUID }) => SeriesInstanceUID
|
||||
);
|
||||
|
||||
let ptDisplaySet = null;
|
||||
for (const SeriesInstanceUID of matchedSeriesInstanceUIDs) {
|
||||
const displaySets = DisplaySetService.getDisplaySetsForSeries(
|
||||
SeriesInstanceUID
|
||||
);
|
||||
|
||||
if (!displaySets || displaySets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displaySet = displaySets[0];
|
||||
if (displaySet.Modality !== 'PT') {
|
||||
continue;
|
||||
}
|
||||
|
||||
ptDisplaySet = displaySet;
|
||||
}
|
||||
|
||||
return ptDisplaySet;
|
||||
},
|
||||
getPTMetadata: ({ ptDisplaySet }) => {
|
||||
const dataSource = extensionManager.getDataSources()[0];
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(ptDisplaySet);
|
||||
|
||||
const firstImageId = imageIds[0];
|
||||
const instance = metadataProvider.get('instance', firstImageId);
|
||||
if (instance.Modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
SeriesTime: instance.SeriesTime,
|
||||
Modality: instance.Modality,
|
||||
PatientSex: instance.PatientSex,
|
||||
PatientWeight: instance.PatientWeight,
|
||||
RadiopharmaceuticalInformationSequence: {
|
||||
RadionuclideTotalDose:
|
||||
instance.RadiopharmaceuticalInformationSequence[0]
|
||||
.RadionuclideTotalDose,
|
||||
RadionuclideHalfLife:
|
||||
instance.RadiopharmaceuticalInformationSequence[0]
|
||||
.RadionuclideHalfLife,
|
||||
RadiopharmaceuticalStartTime:
|
||||
instance.RadiopharmaceuticalInformationSequence[0]
|
||||
.RadiopharmaceuticalStartTime,
|
||||
RadiopharmaceuticalStartDateTime:
|
||||
instance.RadiopharmaceuticalInformationSequence[0]
|
||||
.RadiopharmaceuticalStartDateTime,
|
||||
},
|
||||
};
|
||||
|
||||
return metadata;
|
||||
},
|
||||
createNewLabelmapFromPT: async () => {
|
||||
// Create a segmentation of the same resolution as the source data
|
||||
// using volumeLoader.createAndCacheDerivedVolume.
|
||||
const ptDisplaySet = actions.getMatchingPTDisplaySet();
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
UINotificationService.error('No matching PT display set found');
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentationId = await commandsManager.runCommand(
|
||||
'createSegmentationForDisplaySet',
|
||||
{
|
||||
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
|
||||
}
|
||||
);
|
||||
|
||||
const toolGroupIds = _getMatchedViewportsToolGroupIds();
|
||||
|
||||
const representationType =
|
||||
csTools.Enums.SegmentationRepresentations.Labelmap;
|
||||
|
||||
for (const toolGroupId of toolGroupIds) {
|
||||
await commandsManager.runCommand(
|
||||
'addSegmentationRepresentationToToolGroup',
|
||||
{ segmentationId, toolGroupId: toolGroupId, representationType }
|
||||
);
|
||||
}
|
||||
|
||||
return segmentationId;
|
||||
},
|
||||
setSegmentationActiveForToolGroups: ({ segmentationId }) => {
|
||||
const toolGroupIds = _getMatchedViewportsToolGroupIds();
|
||||
|
||||
toolGroupIds.forEach(toolGroupId => {
|
||||
const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations(
|
||||
toolGroupId
|
||||
);
|
||||
|
||||
if (segmentationRepresentations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: this finds the first segmentation representation that matches the segmentationId
|
||||
// If there are two labelmap representations from the same segmentation, this will not work
|
||||
const representation = segmentationRepresentations.find(
|
||||
representation => representation.segmentationId === segmentationId
|
||||
);
|
||||
|
||||
csTools.segmentation.activeSegmentation.setActiveSegmentationRepresentation(
|
||||
toolGroupId,
|
||||
representation.segmentationRepresentationUID
|
||||
);
|
||||
});
|
||||
},
|
||||
thresholdSegmentationByRectangleROITool: ({ segmentationId, config }) => {
|
||||
const segmentation = csTools.segmentation.state.getSegmentation(
|
||||
segmentationId
|
||||
);
|
||||
|
||||
const { representationData } = segmentation;
|
||||
const { volumeId: segVolumeId } = representationData[
|
||||
csTools.Enums.SegmentationRepresentations.Labelmap
|
||||
];
|
||||
|
||||
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
|
||||
|
||||
const labelmapVolume = cs.cache.getVolume(segmentationId);
|
||||
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
|
||||
|
||||
if (!referencedVolume) {
|
||||
throw new Error('No Reference volume found');
|
||||
}
|
||||
|
||||
if (!labelmapVolume) {
|
||||
throw new Error('No Reference labelmap found');
|
||||
}
|
||||
|
||||
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
|
||||
RECTANGLE_ROI_THRESHOLD_MANUAL
|
||||
);
|
||||
|
||||
if (annotationUIDs.length === 0) {
|
||||
UINotificationService.show({
|
||||
title: 'Commands Module',
|
||||
message: 'No ROIThreshold Tool is Selected',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { lower, upper } = getThresholdValues(
|
||||
annotationUIDs,
|
||||
referencedVolume,
|
||||
config
|
||||
);
|
||||
|
||||
const configToUse = {
|
||||
lower,
|
||||
upper,
|
||||
overwrite: true,
|
||||
};
|
||||
|
||||
return csTools.utilities.segmentation.rectangleROIThresholdVolumeByRange(
|
||||
annotationUIDs,
|
||||
labelmapVolume,
|
||||
[referencedVolume],
|
||||
configToUse
|
||||
);
|
||||
},
|
||||
toggleSegmentationVisibility: ({ segmentationId }) => {
|
||||
const toolGroupIds = _getMatchedViewportsToolGroupIds();
|
||||
|
||||
toolGroupIds.forEach(toolGroupId => {
|
||||
const segmentationRepresentations = csTools.segmentation.state.getSegmentationRepresentations(
|
||||
toolGroupId
|
||||
);
|
||||
|
||||
if (segmentationRepresentations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: this finds the first segmentation representation that matches the segmentationId
|
||||
// If there are two labelmap representations from the same segmentation, this will not work
|
||||
const representation = segmentationRepresentations.find(
|
||||
representation => representation.segmentationId === segmentationId
|
||||
);
|
||||
|
||||
const visibility = csTools.segmentation.config.visibility.getSegmentationVisibility(
|
||||
toolGroupId,
|
||||
representation.segmentationRepresentationUID
|
||||
);
|
||||
|
||||
csTools.segmentation.config.visibility.setSegmentationVisibility(
|
||||
toolGroupId,
|
||||
representation.segmentationRepresentationUID,
|
||||
!visibility
|
||||
);
|
||||
});
|
||||
},
|
||||
calculateSuvPeak: ({ labelmap }) => {
|
||||
const { referencedVolumeId } = labelmap;
|
||||
|
||||
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
|
||||
|
||||
const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
|
||||
RECTANGLE_ROI_THRESHOLD_MANUAL
|
||||
);
|
||||
|
||||
const annotations = annotationUIDs.map(annotationUID =>
|
||||
csTools.annotation.state.getAnnotation(annotationUID)
|
||||
);
|
||||
|
||||
const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations);
|
||||
return {
|
||||
suvPeak: suvPeak.mean,
|
||||
suvMax: suvPeak.max,
|
||||
suvMaxIJK: suvPeak.maxIJK,
|
||||
suvMaxLPS: suvPeak.maxLPS,
|
||||
};
|
||||
},
|
||||
getLesionStats: ({ labelmap, segmentIndex = 1 }) => {
|
||||
const { scalarData, spacing } = labelmap;
|
||||
|
||||
const { scalarData: referencedScalarData } = cs.cache.getVolume(
|
||||
labelmap.referencedVolumeId
|
||||
);
|
||||
|
||||
let segmentationMax = -Infinity;
|
||||
let segmentationMin = Infinity;
|
||||
let segmentationValues = [];
|
||||
|
||||
let voxelCount = 0;
|
||||
for (let i = 0; i < scalarData.length; i++) {
|
||||
if (scalarData[i] === segmentIndex) {
|
||||
const value = referencedScalarData[i];
|
||||
segmentationValues.push(value);
|
||||
if (value > segmentationMax) {
|
||||
segmentationMax = value;
|
||||
}
|
||||
if (value < segmentationMin) {
|
||||
segmentationMin = value;
|
||||
}
|
||||
voxelCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
minValue: segmentationMin,
|
||||
maxValue: segmentationMax,
|
||||
meanValue: segmentationValues.reduce((a, b) => a + b, 0) / voxelCount,
|
||||
stdValue: Math.sqrt(
|
||||
segmentationValues.reduce((a, b) => a + b * b, 0) / voxelCount -
|
||||
segmentationValues.reduce((a, b) => a + b, 0) / voxelCount ** 2
|
||||
),
|
||||
volume: voxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3,
|
||||
};
|
||||
|
||||
return stats;
|
||||
},
|
||||
calculateLesionGlycolysis: ({ lesionStats }) => {
|
||||
const { meanValue, volume } = lesionStats;
|
||||
|
||||
return {
|
||||
lesionGlyoclysisStats: volume * meanValue,
|
||||
};
|
||||
},
|
||||
calculateTMTV: ({ segmentations }) => {
|
||||
const labelmaps = commandsManager.runCommand('getLabelmapVolumes', {
|
||||
segmentations,
|
||||
});
|
||||
|
||||
if (!labelmaps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return calculateTMTV(labelmaps);
|
||||
},
|
||||
exportTMTVReportCSV: ({ segmentations, tmtv, config }) => {
|
||||
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
|
||||
segmentations,
|
||||
});
|
||||
|
||||
const tlg = actions.getTotalLesionGlycolysis({ segmentations });
|
||||
const additionalReportRows = [
|
||||
{ key: 'Total Metabolic Tumor Volume', value: { tmtv } },
|
||||
{ key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } },
|
||||
{ key: 'Threshold Configuration', value: { ...config } },
|
||||
];
|
||||
|
||||
createAndDownloadTMTVReport(segReport, additionalReportRows);
|
||||
},
|
||||
getTotalLesionGlycolysis: ({ segmentations }) => {
|
||||
const labelmapVolumes = commandsManager.runCommand('getLabelmapVolumes', {
|
||||
segmentations,
|
||||
});
|
||||
|
||||
let mergedLabelmap;
|
||||
// merge labelmap will through an error if labels maps are not the same size
|
||||
// or same direction or ....
|
||||
try {
|
||||
mergedLabelmap = csTools.utilities.segmentation.createMergedLabelmapForIndex(
|
||||
labelmapVolumes
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('commandsModule::getTotalLesionGlycolysis', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// grabbing the first labelmap referenceVolume since it will be the same for all
|
||||
const { referencedVolumeId, spacing } = labelmapVolumes[0];
|
||||
|
||||
if (!referencedVolumeId) {
|
||||
console.error(
|
||||
'commandsModule::getTotalLesionGlycolysis:No referencedVolumeId found'
|
||||
);
|
||||
}
|
||||
|
||||
const ptVolume = cs.cache.getVolume(referencedVolumeId);
|
||||
const mergedLabelData = mergedLabelmap.scalarData;
|
||||
|
||||
if (mergedLabelData.length !== ptVolume.scalarData.length) {
|
||||
console.error(
|
||||
'commandsModule::getTotalLesionGlycolysis:Labelmap and ptVolume are not the same size'
|
||||
);
|
||||
}
|
||||
|
||||
let suv = 0;
|
||||
let totalLesionVoxelCount = 0;
|
||||
for (let i = 0; i < mergedLabelData.length; i++) {
|
||||
// if not background
|
||||
if (mergedLabelData[i] !== 0) {
|
||||
suv += ptVolume.scalarData[i];
|
||||
totalLesionVoxelCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Average SUV for the merged labelmap
|
||||
const averageSuv = suv / totalLesionVoxelCount;
|
||||
|
||||
// total Lesion Glycolysis [suv * ml]
|
||||
return (
|
||||
averageSuv *
|
||||
totalLesionVoxelCount *
|
||||
spacing[0] *
|
||||
spacing[1] *
|
||||
spacing[2] *
|
||||
1e-3
|
||||
);
|
||||
},
|
||||
|
||||
setStartSliceForROIThresholdTool: () => {
|
||||
const { viewport } = _getActiveViewportsEnabledElement();
|
||||
const { focalPoint, viewPlaneNormal } = viewport.getCamera();
|
||||
|
||||
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
|
||||
RECTANGLE_ROI_THRESHOLD_MANUAL
|
||||
);
|
||||
|
||||
const annotationUID = selectedAnnotationUIDs[0];
|
||||
|
||||
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
|
||||
|
||||
const { handles } = annotation.data;
|
||||
const { points } = handles;
|
||||
|
||||
// get the current slice Index
|
||||
const sliceIndex = viewport.getCurrentImageIdIndex();
|
||||
annotation.data.startSlice = sliceIndex;
|
||||
|
||||
// distance between camera focal point and each point on the rectangle
|
||||
const newPoints = points.map(point => {
|
||||
const distance = vec3.create();
|
||||
vec3.subtract(distance, focalPoint, point);
|
||||
// distance in the direction of the viewPlaneNormal
|
||||
const distanceInViewPlane = vec3.dot(distance, viewPlaneNormal);
|
||||
// new point is current point minus distanceInViewPlane
|
||||
const newPoint = vec3.create();
|
||||
vec3.scaleAndAdd(newPoint, point, viewPlaneNormal, distanceInViewPlane);
|
||||
|
||||
return newPoint;
|
||||
//
|
||||
});
|
||||
|
||||
handles.points = newPoints;
|
||||
// IMPORTANT: invalidate the toolData for the cached stat to get updated
|
||||
// and re-calculate the projection points
|
||||
annotation.invalidated = true;
|
||||
viewport.render();
|
||||
},
|
||||
setEndSliceForROIThresholdTool: () => {
|
||||
const { viewport } = _getActiveViewportsEnabledElement();
|
||||
|
||||
const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName(
|
||||
RECTANGLE_ROI_THRESHOLD_MANUAL
|
||||
);
|
||||
|
||||
const annotationUID = selectedAnnotationUIDs[0];
|
||||
|
||||
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
|
||||
|
||||
// get the current slice Index
|
||||
const sliceIndex = viewport.getCurrentImageIdIndex();
|
||||
annotation.data.endSlice = sliceIndex;
|
||||
|
||||
// IMPORTANT: invalidate the toolData for the cached stat to get updated
|
||||
// and re-calculate the projection points
|
||||
annotation.invalidated = true;
|
||||
|
||||
viewport.render();
|
||||
},
|
||||
createTMTVRTReport: () => {
|
||||
// get all Rectangle ROI annotation
|
||||
const stateManager = csTools.annotation.state.getDefaultAnnotationManager();
|
||||
|
||||
const annotations = [];
|
||||
|
||||
Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => {
|
||||
const forAnnotations = stateManager.annotations[frameOfReferenceUID];
|
||||
const ROIAnnotations = forAnnotations[RECTANGLE_ROI_THRESHOLD_MANUAL];
|
||||
annotations.push(...ROIAnnotations);
|
||||
});
|
||||
|
||||
commandsManager.runCommand('exportRTReportForAnnotations', {
|
||||
annotations,
|
||||
});
|
||||
},
|
||||
getSegmentationCSVReport: ({ segmentations }) => {
|
||||
if (!segmentations || !segmentations.length) {
|
||||
segmentations = SegmentationService.getSegmentations();
|
||||
}
|
||||
|
||||
let report = {};
|
||||
|
||||
for (const segmentation of segmentations) {
|
||||
const { id, label, data } = segmentation;
|
||||
|
||||
const segReport = { id, label };
|
||||
|
||||
if (!data) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
if (typeof data[key] !== 'object') {
|
||||
segReport[key] = data[key];
|
||||
} else {
|
||||
Object.keys(data[key]).forEach(subKey => {
|
||||
const newKey = `${key}_${subKey}`;
|
||||
segReport[newKey] = data[key][subKey];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const labelmapVolume = cornerstone.cache.getVolume(id);
|
||||
|
||||
if (!labelmapVolume) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
const referencedVolumeId = labelmapVolume.referencedVolumeId;
|
||||
segReport.referencedVolumeId = referencedVolumeId;
|
||||
|
||||
const referencedVolume = cornerstone.cache.getVolume(
|
||||
referencedVolumeId
|
||||
);
|
||||
|
||||
if (!referencedVolume) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!referencedVolume.imageIds || !referencedVolume.imageIds.length) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstImageId = referencedVolume.imageIds[0];
|
||||
const instance = OHIF.classes.MetadataProvider.get(
|
||||
'instance',
|
||||
firstImageId
|
||||
);
|
||||
|
||||
if (!instance) {
|
||||
report[id] = segReport;
|
||||
continue;
|
||||
}
|
||||
|
||||
report[id] = {
|
||||
...segReport,
|
||||
PatientID: instance.PatientID,
|
||||
PatientName: instance.PatientName.Alphabetic,
|
||||
StudyInstanceUID: instance.StudyInstanceUID,
|
||||
SeriesInstanceUID: instance.SeriesInstanceUID,
|
||||
StudyDate: instance.StudyDate,
|
||||
};
|
||||
}
|
||||
|
||||
return report;
|
||||
},
|
||||
exportRTReportForAnnotations: ({ annotations }) => {
|
||||
dicomRTAnnotationExport(annotations);
|
||||
},
|
||||
setFusionPTColormap: ({ toolGroupId, colormap }) => {
|
||||
const toolGroup = ToolGroupService.getToolGroup(toolGroupId);
|
||||
|
||||
const ptDisplaySet = actions.getMatchingPTDisplaySet();
|
||||
|
||||
if (!ptDisplaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fusionViewportIds = toolGroup.getViewportIds();
|
||||
|
||||
let viewports = [];
|
||||
fusionViewportIds.forEach(viewportId => {
|
||||
const viewportInfo = CornerstoneViewportService.getViewportInfo(
|
||||
viewportId
|
||||
);
|
||||
|
||||
const viewportIndex = viewportInfo.getViewportIndex();
|
||||
commandsManager.runCommand('setViewportColormap', {
|
||||
viewportIndex,
|
||||
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
|
||||
colormap,
|
||||
});
|
||||
|
||||
viewports.push(
|
||||
CornerstoneViewportService.getCornerstoneViewport(viewportId)
|
||||
);
|
||||
});
|
||||
|
||||
viewports.forEach(viewport => {
|
||||
viewport.render();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
setEndSliceForROIThresholdTool: {
|
||||
commandFn: actions.setEndSliceForROIThresholdTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setStartSliceForROIThresholdTool: {
|
||||
commandFn: actions.setStartSliceForROIThresholdTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
getMatchingPTDisplaySet: {
|
||||
commandFn: actions.getMatchingPTDisplaySet,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
getPTMetadata: {
|
||||
commandFn: actions.getPTMetadata,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
createNewLabelmapFromPT: {
|
||||
commandFn: actions.createNewLabelmapFromPT,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setSegmentationActiveForToolGroups: {
|
||||
commandFn: actions.setSegmentationActiveForToolGroups,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
thresholdSegmentationByRectangleROITool: {
|
||||
commandFn: actions.thresholdSegmentationByRectangleROITool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
toggleSegmentationVisibility: {
|
||||
commandFn: actions.toggleSegmentationVisibility,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
getTotalLesionGlycolysis: {
|
||||
commandFn: actions.getTotalLesionGlycolysis,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
calculateSuvPeak: {
|
||||
commandFn: actions.calculateSuvPeak,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
getLesionStats: {
|
||||
commandFn: actions.getLesionStats,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
calculateTMTV: {
|
||||
commandFn: actions.calculateTMTV,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
exportTMTVReportCSV: {
|
||||
commandFn: actions.exportTMTVReportCSV,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
createTMTVRTReport: {
|
||||
commandFn: actions.createTMTVRTReport,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
getSegmentationCSVReport: {
|
||||
commandFn: actions.getSegmentationCSVReport,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
exportRTReportForAnnotations: {
|
||||
commandFn: actions.exportRTReportForAnnotations,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setFusionPTColormap: {
|
||||
commandFn: actions.setFusionPTColormap,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'TMTV:CORNERSTONE',
|
||||
};
|
||||
};
|
||||
|
||||
export default commandsModule;
|
||||
@@ -0,0 +1,601 @@
|
||||
const ptCT = {
|
||||
id: 'test',
|
||||
locked: true,
|
||||
hasUpdatedPriorsInformation: false,
|
||||
name: 'Default',
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
modifiedDate: '2021-02-23T19:22:08.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
toolGroupIds: [
|
||||
'ctToolGroup',
|
||||
'ptToolGroup',
|
||||
'fusionToolGroup',
|
||||
'mipToolGroup',
|
||||
],
|
||||
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
|
||||
protocolMatchingRules: [
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyInstanceUID',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: '1.3.6.1.4.',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'PETCT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'PET/CT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
stages: [
|
||||
{
|
||||
id: 'hYbmMy3b7pz7GLiaT',
|
||||
name: 'default',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 3,
|
||||
columns: 4,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 2 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 1 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 1 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 2 / 4,
|
||||
y: 1 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 2 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 2 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 2 / 4,
|
||||
y: 2 / 3,
|
||||
width: 1 / 4,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 3 / 4,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'vSjk7NCYjtdS3XZAw',
|
||||
weight: 1,
|
||||
attribute: 'SeriesNumber',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: '4',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'vSjk7NCYjtdS3XZAw',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'vSjk7NCYjtdS3XZAw',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'CT WB',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
{
|
||||
id: 'ptDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'PT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'Corrected',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 2,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
doesNotContain: {
|
||||
value: 'Uncorrected',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'axialSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'sagittalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctCORONAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'coronalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ptAXIAL',
|
||||
viewportType: 'volume',
|
||||
background: [1, 1, 1],
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ptToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'axialSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ptSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'ptToolGroup',
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'sagittalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ptCORONAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'ptToolGroup',
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'coronalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ptWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'axialSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
options: {
|
||||
colormap: 'hsv',
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionSAGITTAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
// initialImageOptions: {
|
||||
// index: 180,
|
||||
// preset: 'middle', // 'first', 'last', 'middle'
|
||||
// },
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'sagittalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
options: {
|
||||
colormap: 'hsv',
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'fusionCoronal',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
toolGroupId: 'fusionToolGroup',
|
||||
// initialImageOptions: {
|
||||
// index: 180,
|
||||
// preset: 'middle', // 'first', 'last', 'middle'
|
||||
// },
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'coronalSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'ctWLSync',
|
||||
source: false,
|
||||
target: true,
|
||||
},
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'fusionWLSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
{
|
||||
options: {
|
||||
colormap: 'hsv',
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'mipSagittal',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
background: [1, 1, 1],
|
||||
toolGroupId: 'mipToolGroup',
|
||||
// Custom props can be used to set custom properties which extensions
|
||||
// can react on.
|
||||
customViewportOptions: {
|
||||
// We use viewportDisplay to filter the viewports which are displayed
|
||||
// in mip and we set the scrollbar according to their rotation index
|
||||
// in the cornerstone extension.
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
blendMode: 'MIP',
|
||||
slabThickness: 'fullVolume',
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
createdDate: '2021-02-23T18:32:42.850Z',
|
||||
},
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
};
|
||||
|
||||
function getHangingProtocolModule() {
|
||||
return [
|
||||
{
|
||||
name: 'ptCT',
|
||||
protocols: [ptCT],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getHangingProtocolModule;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels';
|
||||
|
||||
// TODO:
|
||||
// - No loading UI exists yet
|
||||
// - cancel promises when component is destroyed
|
||||
// - show errors in UI for thumbnails if promise fails
|
||||
|
||||
function getPanelModule({
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
}) {
|
||||
const wrappedPanelPetSuv = () => {
|
||||
return (
|
||||
<PanelPetSUV
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedROIThresholdSeg = () => {
|
||||
return (
|
||||
<PanelROIThresholdSegmentation
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'petSUV',
|
||||
iconName: 'edit-patient',
|
||||
iconLabel: 'PET SUV',
|
||||
label: 'PET-SUV',
|
||||
component: wrappedPanelPetSuv,
|
||||
},
|
||||
{
|
||||
name: 'ROIThresholdSeg',
|
||||
iconName: 'tool-create-threshold',
|
||||
iconLabel: 'Threshold Seg',
|
||||
label: 'Threshold-Seg',
|
||||
component: wrappedROIThresholdSeg,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getPanelModule;
|
||||
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { id } from './id';
|
||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||
import getPanelModule from './getPanelModule';
|
||||
import init from './init';
|
||||
import commandsModule from './commandsModule';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const tmtvExtension = {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id,
|
||||
preRegistration({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
configuration = {},
|
||||
}) {
|
||||
init({ servicesManager, commandsManager, extensionManager, configuration });
|
||||
},
|
||||
getPanelModule,
|
||||
getHangingProtocolModule,
|
||||
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
|
||||
return commandsModule({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default tmtvExtension;
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
addTool,
|
||||
RectangleROIStartEndThresholdTool,
|
||||
} from '@cornerstonejs/tools';
|
||||
|
||||
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
|
||||
import colormaps from './utils/colormaps';
|
||||
|
||||
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
|
||||
const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
|
||||
/**
|
||||
*
|
||||
* @param {Object} servicesManager
|
||||
* @param {Object} configuration
|
||||
* @param {Object|Array} configuration.csToolsConfig
|
||||
*/
|
||||
export default function init({ servicesManager, extensionManager }) {
|
||||
const {
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
CornerstoneViewportService,
|
||||
} = servicesManager.services;
|
||||
|
||||
addTool(RectangleROIStartEndThresholdTool);
|
||||
|
||||
const { RectangleROIStartEndThreshold } = measurementServiceMappingsFactory(
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
CornerstoneViewportService
|
||||
);
|
||||
|
||||
const csTools3DVer1MeasurementSource = MeasurementService.getSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
MeasurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'RectangleROIStartEndThreshold',
|
||||
RectangleROIStartEndThreshold.matchingCriteria,
|
||||
RectangleROIStartEndThreshold.toAnnotation,
|
||||
RectangleROIStartEndThreshold.toMeasurement
|
||||
);
|
||||
|
||||
initColormaps(extensionManager);
|
||||
}
|
||||
|
||||
function initColormaps(extensionManager) {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
const { registerColormap } = utilityModule.exports;
|
||||
|
||||
colormaps.forEach(registerColormap);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Types } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/tools';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
type AnnotationsForThresholding = {
|
||||
data: {
|
||||
handles: {
|
||||
points: Types.Point3[];
|
||||
};
|
||||
cachedStats?: {
|
||||
projectionPoints?: Types.Point3[][];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This method calculates the SUV peak on a segmented ROI from a reference PET
|
||||
* volume. If a rectangle annotation is provided, the peak is calculated within that
|
||||
* rectangle. Otherwise, the calculation is performed on the entire volume which
|
||||
* will be slower but same result.
|
||||
* @param viewport Viewport to use for the calculation
|
||||
* @param labelmap Labelmap from which the mask is taken
|
||||
* @param referenceVolume PET volume to use for SUV calculation
|
||||
* @param toolData [Optional] list of toolData to use for SUV calculation
|
||||
* @param segmentIndex The index of the segment to use for masking
|
||||
* @returns
|
||||
*/
|
||||
function calculateSuvPeak(
|
||||
labelmap: Types.IImageVolume,
|
||||
referenceVolume: Types.IImageVolume,
|
||||
annotations?: AnnotationsForThresholding[],
|
||||
segmentIndex = 1
|
||||
): {
|
||||
max: number;
|
||||
maxIJK: Types.Point3;
|
||||
maxLPS: Types.Point3;
|
||||
mean: number;
|
||||
} {
|
||||
if (referenceVolume.metadata.Modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelmap.scalarData.length !== referenceVolume.scalarData.length) {
|
||||
throw new Error(
|
||||
'labelmap and referenceVolume must have the same number of pixels'
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
scalarData: labelmapData,
|
||||
dimensions,
|
||||
imageData: labelmapImageData,
|
||||
} = labelmap;
|
||||
|
||||
const {
|
||||
scalarData: referenceVolumeData,
|
||||
imageData: referenceVolumeImageData,
|
||||
} = referenceVolume;
|
||||
|
||||
let boundsIJK;
|
||||
// Todo: using the first annotation for now
|
||||
if (annotations && annotations[0].data?.cachedStats) {
|
||||
const { projectionPoints } = annotations[0].data.cachedStats;
|
||||
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now
|
||||
|
||||
const rectangleCornersIJK = pointsToUse.map(world => {
|
||||
const ijk = vec3.fromValues(0, 0, 0);
|
||||
referenceVolumeImageData.worldToIndex(world, ijk);
|
||||
return ijk as Types.Point3;
|
||||
});
|
||||
|
||||
boundsIJK = utilities.boundingBox.getBoundingBoxAroundShape(
|
||||
rectangleCornersIJK,
|
||||
dimensions
|
||||
);
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let maxIJK = [0, 0, 0];
|
||||
let maxLPS = [0, 0, 0];
|
||||
|
||||
const callback = ({ pointIJK, pointLPS }) => {
|
||||
const offset = referenceVolumeImageData.computeOffsetIndex(pointIJK);
|
||||
const value = labelmapData[offset];
|
||||
|
||||
if (value !== segmentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceValue = referenceVolumeData[offset];
|
||||
|
||||
if (referenceValue > max) {
|
||||
max = referenceValue;
|
||||
maxIJK = pointIJK;
|
||||
maxLPS = pointLPS;
|
||||
}
|
||||
};
|
||||
|
||||
utilities.pointInShapeCallback(
|
||||
labelmapImageData,
|
||||
() => true,
|
||||
callback,
|
||||
boundsIJK
|
||||
);
|
||||
|
||||
const direction = labelmapImageData
|
||||
.getDirection()
|
||||
.slice(0, 3) as Types.Point3;
|
||||
|
||||
/**
|
||||
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
|
||||
* V = (4/3)πr3
|
||||
*/
|
||||
const radius = Math.pow(1 / ((4 / 3) * Math.PI), 1 / 3) * 10;
|
||||
const diameter = radius * 2;
|
||||
|
||||
const secondaryCircleWorld = vec3.create();
|
||||
const bottomWorld = vec3.create();
|
||||
const topWorld = vec3.create();
|
||||
referenceVolumeImageData.indexToWorld(<vec3>maxIJK, secondaryCircleWorld);
|
||||
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
|
||||
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
|
||||
const suvPeakCirclePoints = [bottomWorld, topWorld] as [
|
||||
Types.Point3,
|
||||
Types.Point3
|
||||
];
|
||||
|
||||
/**
|
||||
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
|
||||
* sphere
|
||||
*/
|
||||
let count = 0;
|
||||
let acc = 0;
|
||||
const suvPeakMeanCallback = ({ value }) => {
|
||||
acc += value;
|
||||
count += 1;
|
||||
};
|
||||
|
||||
utilities.pointInSurroundingSphereCallback(
|
||||
referenceVolumeImageData,
|
||||
suvPeakCirclePoints,
|
||||
suvPeakMeanCallback
|
||||
);
|
||||
|
||||
const mean = acc / count;
|
||||
|
||||
return {
|
||||
max,
|
||||
maxIJK,
|
||||
maxLPS,
|
||||
mean,
|
||||
};
|
||||
}
|
||||
|
||||
export default calculateSuvPeak;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Types } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/tools';
|
||||
|
||||
/**
|
||||
* Given a list of labelmaps (with the possibility of overlapping regions),
|
||||
* and a referenceVolume, it calculates the total metabolic tumor volume (TMTV)
|
||||
* by flattening and rasterizing each segment into a single labelmap and summing
|
||||
* the total number of volume voxels. It should be noted that for this calculation
|
||||
* we do not double count voxels that are part of multiple labelmaps.
|
||||
* @param {} labelmaps
|
||||
* @param {number} segmentIndex
|
||||
* @returns {number} TMTV in ml
|
||||
*/
|
||||
function calculateTMTV(
|
||||
labelmaps: Array<Types.IImageVolume>,
|
||||
segmentIndex = 1
|
||||
): number {
|
||||
const volumeId = 'mergedLabelmap';
|
||||
|
||||
const mergedLabelmap = utilities.segmentation.createMergedLabelmapForIndex(
|
||||
labelmaps,
|
||||
segmentIndex,
|
||||
volumeId
|
||||
);
|
||||
|
||||
const { imageData, spacing } = mergedLabelmap;
|
||||
const values = imageData
|
||||
.getPointData()
|
||||
.getScalars()
|
||||
.getData();
|
||||
|
||||
// count non-zero values inside the outputData, this would
|
||||
// consider the overlapping regions to be only counted once
|
||||
const numVoxels = values.reduce((acc, curr) => {
|
||||
if (curr > 0) {
|
||||
return acc + 1;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
|
||||
}
|
||||
|
||||
export default calculateTMTV;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
export default function createAndDownloadTMTVReport(
|
||||
segReport,
|
||||
additionalReportRows
|
||||
) {
|
||||
const firstReport = segReport[Object.keys(segReport)[0]];
|
||||
const columns = Object.keys(firstReport);
|
||||
const csv = [columns.join(',')];
|
||||
|
||||
Object.values(segReport).forEach(segmentation => {
|
||||
const row = [];
|
||||
columns.forEach(column => {
|
||||
// if it is array then we need to replace , with space to avoid csv parsing error
|
||||
row.push(
|
||||
Array.isArray(segmentation[column])
|
||||
? segmentation[column].join(' ')
|
||||
: segmentation[column]
|
||||
);
|
||||
});
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
|
||||
csv.push('');
|
||||
csv.push('');
|
||||
csv.push('');
|
||||
|
||||
csv.push(`Patient ID,${firstReport.PatientID}`);
|
||||
csv.push(`Study Date,${firstReport.StudyDate}`);
|
||||
csv.push('');
|
||||
additionalReportRows.forEach(({ key, value: values }) => {
|
||||
const temp = [];
|
||||
temp.push(`${key}`);
|
||||
Object.keys(values).forEach(k => {
|
||||
temp.push(`${k}`);
|
||||
temp.push(`${values[k]}`);
|
||||
});
|
||||
|
||||
csv.push(temp.join(','));
|
||||
});
|
||||
|
||||
const blob = new Blob([csv.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${firstReport.PatientID}_tmtv.csv`;
|
||||
a.click();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import AnnotationToPointData from './measurements/AnnotationToPointData';
|
||||
import dcmjs from 'dcmjs';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
|
||||
const { DicomMetaDictionary } = dcmjs.data;
|
||||
|
||||
export default class RTSSReport {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Convert handles to RTSSReport report object containing the dcmjs dicom dataset.
|
||||
*
|
||||
* Note: The tool data needs to be formatted in a specific way, and currently
|
||||
* it is limited to the RectangleROIStartEndTool in the Cornerstone.
|
||||
*
|
||||
* @param annotations Array of Cornerstone tool annotation data
|
||||
* @param metadataProvider Metadata provider
|
||||
* @param options report generation options
|
||||
* @returns Report object containing the dataset
|
||||
*/
|
||||
static generateReport(annotations, metadataProvider, options) {
|
||||
let dataset = initializeDataset(annotations, metadataProvider);
|
||||
|
||||
annotations.forEach((annotation, index) => {
|
||||
const ContourSequence = AnnotationToPointData.convert(
|
||||
annotation,
|
||||
index,
|
||||
metadataProvider,
|
||||
options
|
||||
);
|
||||
|
||||
dataset.StructureSetROISequence.push(
|
||||
getStructureSetModule(annotation, index, metadataProvider)
|
||||
);
|
||||
|
||||
dataset.ROIContourSequence.push(ContourSequence);
|
||||
dataset.RTROIObservationsSequence.push(
|
||||
getRTROIObservationsSequence(annotation, index, metadataProvider)
|
||||
);
|
||||
|
||||
// ReferencedSeriesSequence
|
||||
// Todo: handle more than one series
|
||||
dataset.ReferencedSeriesSequence = getReferencedSeriesSequence(
|
||||
annotation,
|
||||
index,
|
||||
metadataProvider
|
||||
);
|
||||
|
||||
// ReferencedFrameOfReferenceSequence
|
||||
dataset.ReferencedFrameOfReferenceSequence = getReferencedFrameOfReferenceSequence(
|
||||
annotation,
|
||||
metadataProvider,
|
||||
dataset
|
||||
);
|
||||
});
|
||||
|
||||
const fileMetaInformationVersionArray = new Uint8Array(2);
|
||||
fileMetaInformationVersionArray[1] = 1;
|
||||
|
||||
const _meta = {
|
||||
FileMetaInformationVersion: {
|
||||
Value: [fileMetaInformationVersionArray.buffer],
|
||||
vr: 'OB',
|
||||
},
|
||||
TransferSyntaxUID: {
|
||||
Value: ['1.2.840.10008.1.2.1'],
|
||||
vr: 'UI',
|
||||
},
|
||||
ImplementationClassUID: {
|
||||
Value: [DicomMetaDictionary.uid()], // TODO: could be git hash or other valid id
|
||||
vr: 'UI',
|
||||
},
|
||||
ImplementationVersionName: {
|
||||
Value: ['dcmjs'],
|
||||
vr: 'SH',
|
||||
},
|
||||
};
|
||||
|
||||
dataset._meta = _meta;
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Cornerstone tool state from dataset
|
||||
* @param {object} dataset dataset
|
||||
* @param {object} hooks
|
||||
* @param {function} hooks.getToolClass Function to map dataset to a tool class
|
||||
* @returns
|
||||
*/
|
||||
static generateToolState(dataset, hooks = {}) {
|
||||
// Todo
|
||||
console.warn('RTSSReport.generateToolState not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDataset(annotations, metadataProvider) {
|
||||
const rtSOPInstanceUID = DicomMetaDictionary.uid();
|
||||
|
||||
// get the first annotation data
|
||||
const {
|
||||
referencedImageId: imageId,
|
||||
FrameOfReferenceUID,
|
||||
} = annotations[0].metadata;
|
||||
|
||||
const { studyInstanceUID } = metadataProvider.get(
|
||||
'generalSeriesModule',
|
||||
imageId
|
||||
);
|
||||
|
||||
const patientModule = getPatientModule(imageId, metadataProvider);
|
||||
const rtSeriesModule = getRTSeriesModule(imageId, metadataProvider);
|
||||
|
||||
return {
|
||||
StructureSetROISequence: [],
|
||||
ROIContourSequence: [],
|
||||
RTROIObservationsSequence: [],
|
||||
ReferencedSeriesSequence: [],
|
||||
ReferencedFrameOfReferenceSequence: [],
|
||||
...patientModule,
|
||||
...rtSeriesModule,
|
||||
StudyInstanceUID: studyInstanceUID,
|
||||
SOPClassUID: '1.2.840.10008.5.1.4.1.1.481.3', // RT Structure Set Storage
|
||||
SOPInstanceUID: rtSOPInstanceUID,
|
||||
Manufacturer: 'dcmjs',
|
||||
Modality: 'RTSTRUCT',
|
||||
FrameOfReferenceUID,
|
||||
PositionReferenceIndicator: '',
|
||||
StructureSetLabel: '',
|
||||
StructureSetName: '',
|
||||
ReferringPhysicianName: '',
|
||||
OperatorsName: '',
|
||||
StructureSetDate: DicomMetaDictionary.date(),
|
||||
StructureSetTime: DicomMetaDictionary.time(),
|
||||
};
|
||||
}
|
||||
|
||||
function getPatientModule(imageId, metadataProvider) {
|
||||
const generalSeriesModule = metadataProvider.get(
|
||||
'generalSeriesModule',
|
||||
imageId
|
||||
);
|
||||
const generalStudyModule = metadataProvider.get(
|
||||
'generalStudyModule',
|
||||
imageId
|
||||
);
|
||||
const patientStudyModule = metadataProvider.get(
|
||||
'patientStudyModule',
|
||||
imageId
|
||||
);
|
||||
const patientModule = metadataProvider.get('patientModule', imageId);
|
||||
const patientDemographicModule = metadataProvider.get(
|
||||
'patientDemographicModule',
|
||||
imageId
|
||||
);
|
||||
|
||||
return {
|
||||
Modality: generalSeriesModule.modality,
|
||||
PatientID: patientModule.patientId,
|
||||
PatientName: patientModule.patientName,
|
||||
PatientBirthDate: '',
|
||||
PatientAge: patientStudyModule.patientAge,
|
||||
PatientSex: patientDemographicModule.patientSex,
|
||||
PatientWeight: patientStudyModule.patientWeight,
|
||||
StudyDate: generalStudyModule.studyDate,
|
||||
StudyTime: generalStudyModule.studyTime,
|
||||
StudyID: 'ToDo',
|
||||
AccessionNumber: generalStudyModule.accessionNumber,
|
||||
};
|
||||
}
|
||||
|
||||
function getReferencedFrameOfReferenceSequence(
|
||||
toolData,
|
||||
metadataProvider,
|
||||
dataset
|
||||
) {
|
||||
const { referencedImageId: imageId, FrameOfReferenceUID } = toolData.metadata;
|
||||
const instance = metadataProvider.get('instance', imageId);
|
||||
const { SeriesInstanceUID } = instance;
|
||||
|
||||
const { ReferencedSeriesSequence } = dataset;
|
||||
|
||||
return [
|
||||
{
|
||||
FrameOfReferenceUID,
|
||||
RTReferencedStudySequence: [
|
||||
{
|
||||
ReferencedSOPClassUID: dataset.SOPClassUID,
|
||||
ReferencedSOPInstanceUID: dataset.SOPInstanceUID,
|
||||
RTReferencedSeriesSequence: [
|
||||
{
|
||||
SeriesInstanceUID,
|
||||
ContourImageSequence: [
|
||||
...ReferencedSeriesSequence[0].ReferencedInstanceSequence,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getReferencedSeriesSequence(toolData, index, metadataProvider) {
|
||||
// grab imageId from toolData
|
||||
const { referencedImageId: imageId } = toolData.metadata;
|
||||
const instance = metadataProvider.get('instance', imageId);
|
||||
const { SeriesInstanceUID, StudyInstanceUID } = instance;
|
||||
|
||||
const ReferencedSeriesSequence = [];
|
||||
if (SeriesInstanceUID) {
|
||||
const series = DicomMetadataStore.getSeries(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID
|
||||
);
|
||||
|
||||
const ReferencedSeries = {
|
||||
SeriesInstanceUID,
|
||||
ReferencedInstanceSequence: [],
|
||||
};
|
||||
|
||||
series.instances.forEach(instance => {
|
||||
const { SOPInstanceUID, SOPClassUID } = instance;
|
||||
ReferencedSeries.ReferencedInstanceSequence.push({
|
||||
ReferencedSOPClassUID: SOPClassUID,
|
||||
ReferencedSOPInstanceUID: SOPInstanceUID,
|
||||
});
|
||||
});
|
||||
|
||||
ReferencedSeriesSequence.push(ReferencedSeries);
|
||||
}
|
||||
|
||||
return ReferencedSeriesSequence;
|
||||
}
|
||||
|
||||
function getRTSeriesModule(imageId, metadataProvider) {
|
||||
return {
|
||||
SeriesInstanceUID: DicomMetaDictionary.uid(), // generate a new series instance uid
|
||||
SeriesNumber: '99', // Todo:: what should be the series number?
|
||||
};
|
||||
}
|
||||
|
||||
function getStructureSetModule(toolData, index, metadataProvider) {
|
||||
const { FrameOfReferenceUID } = toolData.metadata;
|
||||
|
||||
return {
|
||||
ROINumber: index + 1,
|
||||
ROIName: `Todo: name ${index + 1}`,
|
||||
ROIDescription: `Todo: description ${index + 1}`,
|
||||
ROIGenerationAlgorithm: 'Todo: algorithm',
|
||||
ReferencedFrameOfReferenceUID: FrameOfReferenceUID,
|
||||
};
|
||||
}
|
||||
|
||||
function getRTROIObservationsSequence(toolData, index, metadataProvider) {
|
||||
return {
|
||||
ObservationNumber: index + 1,
|
||||
ReferencedROINumber: index + 1,
|
||||
RTROIInterpretedType: 'Todo: type',
|
||||
ROIInterpreter: 'Todo: interpreter',
|
||||
};
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import RTSSReport from './RTSSReport';
|
||||
import dcmjs from 'dcmjs';
|
||||
import { classes } from '@ohif/core';
|
||||
|
||||
const { datasetToBlob } = dcmjs.data;
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
|
||||
export default function dicomRTAnnotationExport(annotations) {
|
||||
const dataset = RTSSReport.generateReport(annotations, metadataProvider);
|
||||
const reportBlob = datasetToBlob(dataset);
|
||||
|
||||
//Create a URL for the binary.
|
||||
var objectUrl = URL.createObjectURL(reportBlob);
|
||||
window.location.assign(objectUrl);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import dicomRTAnnotationExport from './dicomRTAnnotationExport';
|
||||
|
||||
export default dicomRTAnnotationExport;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
|
||||
|
||||
function validateAnnotation(annotation) {
|
||||
if (!annotation?.data) {
|
||||
throw new Error('Tool data is empty');
|
||||
}
|
||||
|
||||
if (!annotation.metadata || annotation.metadata.referenceImageId) {
|
||||
throw new Error('Tool data is not associated with any imageId');
|
||||
}
|
||||
}
|
||||
|
||||
class AnnotationToPointData {
|
||||
constructor() {}
|
||||
|
||||
static convert(annotation, index, metadataProvider) {
|
||||
validateAnnotation(annotation);
|
||||
|
||||
const { toolName } = annotation.metadata;
|
||||
const toolClass = AnnotationToPointData.TOOL_NAMES[toolName];
|
||||
|
||||
if (!toolClass) {
|
||||
throw new Error(
|
||||
`Unknown tool type: ${toolName}, cannot convert to RTSSReport`
|
||||
);
|
||||
}
|
||||
|
||||
// Each toolData should become a list of contours, ContourSequence
|
||||
// contains a list of contours with their pointData, their geometry
|
||||
// type and their length.
|
||||
const ContourSequence = toolClass.getContourSequence(
|
||||
annotation,
|
||||
metadataProvider
|
||||
);
|
||||
|
||||
// Todo: random rgb color for now, options should be passed in
|
||||
const color = [
|
||||
Math.floor(Math.random() * 255),
|
||||
Math.floor(Math.random() * 255),
|
||||
Math.floor(Math.random() * 255),
|
||||
];
|
||||
|
||||
return {
|
||||
ReferencedROINumber: index + 1,
|
||||
ROIDisplayColor: color,
|
||||
ContourSequence,
|
||||
};
|
||||
}
|
||||
|
||||
static register(toolClass) {
|
||||
AnnotationToPointData.TOOL_NAMES[toolClass.toolName] = toolClass;
|
||||
}
|
||||
}
|
||||
|
||||
AnnotationToPointData.TOOL_NAMES = {};
|
||||
AnnotationToPointData.register(RectangleROIStartEndThreshold);
|
||||
|
||||
export default AnnotationToPointData;
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// comment
|
||||
class RectangleROIStartEndThreshold {
|
||||
constructor() {}
|
||||
|
||||
static getContourSequence(toolData, metadataProvider) {
|
||||
const { data } = toolData;
|
||||
const { projectionPoints, projectionPointsImageIds } = data.cachedStats;
|
||||
|
||||
return projectionPoints.map((point, index) => {
|
||||
const ContourData = getPointData(point);
|
||||
const ContourImageSequence = getContourImageSequence(
|
||||
projectionPointsImageIds[index],
|
||||
metadataProvider
|
||||
);
|
||||
|
||||
return {
|
||||
NumberOfContourPoints: ContourData.length / 3,
|
||||
ContourImageSequence,
|
||||
ContourGeometricType: 'CLOSED_PLANAR',
|
||||
ContourData,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RectangleROIStartEndThreshold.toolName = 'RectangleROIStartEndThreshold';
|
||||
|
||||
function getPointData(points) {
|
||||
// Since this is a closed contour, the order of the points is important.
|
||||
// re-order the points to be in the correct order clockwise
|
||||
// Spread to make sure Float32Arrays are converted to arrays
|
||||
const orderedPoints = [
|
||||
...points[0],
|
||||
...points[1],
|
||||
...points[3],
|
||||
...points[2],
|
||||
];
|
||||
const pointsArray = orderedPoints.flat();
|
||||
|
||||
// reduce the precision of the points to 2 decimal places
|
||||
const pointsArrayWithPrecision = pointsArray.map(point => {
|
||||
return point.toFixed(2);
|
||||
});
|
||||
|
||||
return pointsArrayWithPrecision;
|
||||
}
|
||||
|
||||
function getContourImageSequence(imageId, metadataProvider) {
|
||||
const sopCommon = metadataProvider.get('sopCommonModule', imageId);
|
||||
|
||||
return {
|
||||
ReferencedSOPClassUID: sopCommon.sopClassUID,
|
||||
ReferencedSOPInstanceUID: sopCommon.sopInstanceUID,
|
||||
};
|
||||
}
|
||||
export default RectangleROIStartEndThreshold;
|
||||
@@ -0,0 +1,29 @@
|
||||
async function getStudiesForPatientByStudyInstanceUID(
|
||||
dataSource,
|
||||
StudyInstanceUID
|
||||
) {
|
||||
if (StudyInstanceUID === undefined) {
|
||||
return;
|
||||
}
|
||||
// TODO: The `DicomMetadataStore` should short-circuit both of these requests
|
||||
// Data _could_ be here from route query, or if using JSON data source
|
||||
// We could also force this to "await" these values being available in the DICOMStore?
|
||||
// Kind of like promise fulfillment in cornerstone-wado-image-loader when there are multiple
|
||||
// outgoing requests for the same data
|
||||
const getStudyResult = await dataSource.query.studies.search({
|
||||
studyInstanceUid: StudyInstanceUID,
|
||||
});
|
||||
|
||||
// TODO: To Erik's point, the data source likely shouldn't deviate from
|
||||
// Naturalized DICOM JSON when returning. It makes things like this awkward (mrn)
|
||||
if (getStudyResult && getStudyResult.length && getStudyResult[0].mrn) {
|
||||
return dataSource.query.studies.search({
|
||||
patientId: getStudyResult[0].mrn,
|
||||
});
|
||||
}
|
||||
console.log('No mrn found for', getStudyResult);
|
||||
// The original study we KNOW belongs to the same set, so just return it
|
||||
return getStudyResult;
|
||||
}
|
||||
|
||||
export default getStudiesForPatientByStudyInstanceUID;
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as csTools from '@cornerstonejs/tools';
|
||||
|
||||
function getThresholdValues(
|
||||
annotationUIDs,
|
||||
referencedVolume,
|
||||
config
|
||||
): { lower: number; upper: number } {
|
||||
if (config.strategy === 'range') {
|
||||
return {
|
||||
lower: Number(config.lower),
|
||||
upper: Number(config.upper),
|
||||
};
|
||||
}
|
||||
|
||||
// roiStats
|
||||
const { weight } = config;
|
||||
const { imageData } = referencedVolume;
|
||||
const values = imageData
|
||||
.getPointData()
|
||||
.getScalars()
|
||||
.getData();
|
||||
|
||||
// Todo: add support for other strategies
|
||||
const { fn, baseValue } = _getStrategyFn('max');
|
||||
let value = baseValue;
|
||||
|
||||
const annotations = annotationUIDs.map(annotationUID =>
|
||||
csTools.annotation.state.getAnnotation(annotationUID)
|
||||
);
|
||||
|
||||
const boundsIJK = csTools.utilities.rectangleROITool.getBoundsIJKFromRectangleAnnotations(
|
||||
annotations,
|
||||
referencedVolume
|
||||
);
|
||||
|
||||
const [[iMin, iMax], [jMin, jMax], [kMin, kMax]] = boundsIJK;
|
||||
|
||||
for (let i = iMin; i <= iMax; i++) {
|
||||
for (let j = jMin; j <= jMax; j++) {
|
||||
for (let k = kMin; k <= kMax; k++) {
|
||||
const offset = imageData.computeOffsetIndex([i, j, k]);
|
||||
value = fn(values[offset], value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lower: weight * value,
|
||||
upper: +Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
function _getStrategyFn(
|
||||
statistic
|
||||
): { fn: (a: number, b: number) => number; baseValue: number } {
|
||||
const baseValue = -Infinity;
|
||||
const fn = (number, maxValue) => {
|
||||
if (number > maxValue) {
|
||||
maxValue = number;
|
||||
}
|
||||
return maxValue;
|
||||
};
|
||||
return { fn, baseValue };
|
||||
}
|
||||
|
||||
export default getThresholdValues;
|
||||
@@ -0,0 +1,75 @@
|
||||
import SUPPORTED_TOOLS from './constants/supportedTools';
|
||||
import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes';
|
||||
|
||||
const RectangleROIStartEndThreshold = {
|
||||
toAnnotation: (measurement, definition) => {},
|
||||
|
||||
/**
|
||||
* Maps cornerstone annotation event data to measurement service format.
|
||||
*
|
||||
* @param {Object} cornerstone Cornerstone event data
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
toMeasurement: (
|
||||
csToolsEventDetail,
|
||||
DisplaySetService,
|
||||
CornerstoneViewportService
|
||||
) => {
|
||||
const { annotation, viewportId } = csToolsEventDetail;
|
||||
const { metadata, data, annotationUID } = annotation;
|
||||
|
||||
if (!metadata || !data) {
|
||||
console.warn('Length tool: Missing metadata or data');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
|
||||
const validToolType = SUPPORTED_TOOLS.includes(toolName);
|
||||
|
||||
if (!validToolType) {
|
||||
throw new Error('Tool not supported');
|
||||
}
|
||||
|
||||
const {
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
} = getSOPInstanceAttributes(
|
||||
referencedImageId,
|
||||
CornerstoneViewportService,
|
||||
viewportId
|
||||
);
|
||||
|
||||
let displaySet;
|
||||
|
||||
if (SOPInstanceUID) {
|
||||
displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID(
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID
|
||||
);
|
||||
} else {
|
||||
displaySet = DisplaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
|
||||
}
|
||||
|
||||
const { cachedStats } = data;
|
||||
|
||||
return {
|
||||
uid: annotationUID,
|
||||
SOPInstanceUID,
|
||||
FrameOfReferenceUID,
|
||||
// points,
|
||||
metadata,
|
||||
referenceSeriesUID: SeriesInstanceUID,
|
||||
referenceStudyUID: StudyInstanceUID,
|
||||
toolName: metadata.toolName,
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
label: metadata.label,
|
||||
// displayText: displayText,
|
||||
data: data.cachedStats,
|
||||
type: 'RectangleROIStartEndThreshold',
|
||||
// getReport,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default RectangleROIStartEndThreshold;
|
||||
@@ -0,0 +1 @@
|
||||
export default ['RectangleROIStartEndThreshold'];
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
|
||||
|
||||
const measurementServiceMappingsFactory = (
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
CornerstoneViewportService
|
||||
) => {
|
||||
return {
|
||||
RectangleROIStartEndThreshold: {
|
||||
toAnnotation: RectangleROIStartEndThreshold.toAnnotation,
|
||||
toMeasurement: csToolsAnnotation =>
|
||||
RectangleROIStartEndThreshold.toMeasurement(
|
||||
csToolsAnnotation,
|
||||
DisplaySetService,
|
||||
CornerstoneViewportService
|
||||
),
|
||||
matchingCriteria: [
|
||||
{
|
||||
valueType: MeasurementService.VALUE_TYPES.ROI_THRESHOLD_MANUAL,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default measurementServiceMappingsFactory;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
export default function getSOPInstanceAttributes(imageId) {
|
||||
if (imageId) {
|
||||
return _getUIDFromImageID(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
function _getUIDFromImageID(imageId) {
|
||||
const instance = metaData.get('instance', imageId);
|
||||
|
||||
return {
|
||||
SOPInstanceUID: instance.SOPInstanceUID,
|
||||
SeriesInstanceUID: instance.SeriesInstanceUID,
|
||||
StudyInstanceUID: instance.StudyInstanceUID,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user