This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { PanelSection, Input, Button } from '@ohif/ui';
import { DicomMetadataStore } from '@ohif/core';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
const DEFAULT_MEATADATA = {
PatientWeight: null,
PatientSex: null,
SeriesTime: null,
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: null,
RadionuclideHalfLife: null,
RadiopharmaceuticalStartTime: null,
},
};
/*
* PETSUV panel enables the user to modify the patient related information, such as
* patient sex, patientWeight. This is allowed since
* sometimes these metadata are missing or wrong. By changing them
* @param param0
* @returns
*/
export default function PanelPetSUV({ servicesManager, commandsManager }: withAppTypes) {
const { t } = useTranslation('PanelSUV');
const { displaySetService, toolGroupService, toolbarService, hangingProtocolService } =
servicesManager.services;
const [metadata, setMetadata] = useState(DEFAULT_MEATADATA);
const [ptDisplaySet, setPtDisplaySet] = useState(null);
const handleMetadataChange = metadata => {
setMetadata(prevState => {
const newState = { ...prevState };
Object.keys(metadata).forEach(key => {
if (typeof metadata[key] === 'object') {
newState[key] = {
...prevState[key],
...metadata[key],
};
} else {
newState[key] = metadata[key];
}
});
return newState;
});
};
const getMatchingPTDisplaySet = viewportMatchDetails => {
const ptDisplaySet = commandsManager.runCommand('getMatchingPTDisplaySet', {
viewportMatchDetails,
});
if (!ptDisplaySet) {
return;
}
const metadata = commandsManager.runCommand('getPTMetadata', {
ptDisplaySet,
});
return {
ptDisplaySet,
metadata,
};
};
useEffect(() => {
const displaySets = displaySetService.getActiveDisplaySets();
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
if (!displaySets.length) {
return;
}
const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails);
if (!displaySetInfo) {
return;
}
const { ptDisplaySet, metadata } = displaySetInfo;
setPtDisplaySet(ptDisplaySet);
setMetadata(metadata);
}, []);
// get the patientMetadata from the StudyInstanceUIDs and update the state
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
({ viewportMatchDetails }) => {
const displaySetInfo = getMatchingPTDisplaySet(viewportMatchDetails);
if (!displaySetInfo) {
return;
}
const { ptDisplaySet, metadata } = displaySetInfo;
setPtDisplaySet(ptDisplaySet);
setMetadata(metadata);
}
);
return () => {
unsubscribe();
};
}, []);
function updateMetadata() {
if (!ptDisplaySet) {
throw new Error('No ptDisplaySet found');
}
// metadata should be dcmjs naturalized
DicomMetadataStore.updateMetadataForSeries(
ptDisplaySet.StudyInstanceUID,
ptDisplaySet.SeriesInstanceUID,
metadata
);
// update the displaySets
displaySetService.setDisplaySetMetadataInvalidated(ptDisplaySet.displaySetInstanceUID);
// Crosshair position depends on the metadata values such as the positioning interaction
// between series, so when the metadata is updated, the crosshairs need to be reset.
setTimeout(() => {
commandsManager.runCommand('resetCrosshairs');
}, 0);
}
return (
<>
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
<div className="flex min-h-0 flex-1 flex-col bg-black text-[13px] font-[300]">
<PanelSection title={t('Patient Information')}>
<div className="flex flex-col">
<div className="bg-primary-dark flex flex-col gap-4 p-2">
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Patient Sex')}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Weight')}
labelChildren={<span className="text-aqua-pale"> kg</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
id="weight-input"
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Total Dose')}
labelChildren={<span className="text-aqua-pale"> bq</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Half Life')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Injection Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ||
''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Acquisition Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
<Button
className="!h-[26px] !w-[115px] self-end !p-0"
onClick={updateMetadata}
>
Reload Data
</Button>
</div>
</div>
</PanelSection>
</div>
</div>
</>
);
}
PanelPetSUV.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
measurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};

View File

@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone';
import { handleROIThresholding } from '../../utils/handleROIThresholding';
import { debounce } from '@ohif/core/src/utils';
export default function PanelRoiThresholdSegmentation({
servicesManager,
commandsManager,
}: withAppTypes) {
const { segmentationService } = servicesManager.services;
const { segmentationsWithRepresentations: segmentationsInfo } =
useActiveViewportSegmentationRepresentations({ servicesManager });
useEffect(() => {
const segmentationIds = segmentationsInfo.map(
segmentationInfo => segmentationInfo.segmentation.segmentationId
);
const initialRun = async () => {
for (const segmentationId of segmentationIds) {
await handleROIThresholding({
segmentationId,
commandsManager,
segmentationService,
});
}
};
initialRun();
}, []);
useEffect(() => {
const debouncedHandleROIThresholding = debounce(async eventDetail => {
const { segmentationId } = eventDetail;
await handleROIThresholding({
segmentationId,
commandsManager,
segmentationService,
});
}, 100);
const dataModifiedCallback = eventDetail => {
debouncedHandleROIThresholding(eventDetail);
};
const dataModifiedSubscription = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
dataModifiedCallback
);
return () => {
dataModifiedSubscription.unsubscribe();
};
}, [commandsManager, segmentationService]);
// Find the first segmentation with a TMTV value since all of them have the same value
const tmtvSegmentation = segmentationsInfo.find(
info => info.segmentation.cachedStats?.tmtv !== undefined
);
const tmtvValue = tmtvSegmentation?.segmentation.cachedStats?.tmtv;
return (
<div className="mt-2 mb-10 flex flex-col">
<div className="invisible-scrollbar overflow-y-auto overflow-x-hidden">
{tmtvValue !== null && tmtvValue !== undefined ? (
<div className="bg-secondary-dark flex items-baseline justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'}
</span>
<div className="text-white">{`${tmtvValue.toFixed(3)} mL`}</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Input, Label, Select, LegacyButton, LegacyButtonGroup } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
export const ROI_STAT = 'roi_stat';
const RANGE = 'range';
const options = [
{ value: ROI_STAT, label: 'Max', placeHolder: 'Max' },
{ value: RANGE, label: 'Range', placeHolder: 'Range' },
];
function ROIThresholdConfiguration({ config, dispatch, runCommand }) {
const { t } = useTranslation('ROIThresholdConfiguration');
return (
<div className="bg-primary-dark flex flex-col space-y-4">
<div className="flex items-end space-x-2">
<div className="flex w-1/2 flex-col">
<Select
label={t('Strategy')}
closeMenuOnSelect={true}
className="border-primary-main mr-2 bg-black text-white "
options={options}
placeholder={options.find(option => option.value === config.strategy).placeHolder}
value={config.strategy}
onChange={({ value }) => {
dispatch({
type: 'setStrategy',
payload: {
strategy: value,
},
});
}}
/>
</div>
<div className="w-1/2">
{/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/}
<LegacyButtonGroup>
<LegacyButton
size="initial"
className="px-2 py-2 text-base text-white"
color="primaryLight"
variant="outlined"
onClick={() => runCommand('setStartSliceForROIThresholdTool')}
>
{t('Start')}
</LegacyButton>
<LegacyButton
size="initial"
color="primaryLight"
variant="outlined"
className="px-2 py-2 text-base text-white"
onClick={() => runCommand('setEndSliceForROIThresholdTool')}
>
{t('End')}
</LegacyButton>
</LegacyButtonGroup>
</div>
</div>
{config.strategy === ROI_STAT && (
<Input
label={t('Percentage of Max SUV')}
labelClassName="text-[13px] font-inter text-white"
className="border-primary-main bg-black"
type="text"
containerClassName="mr-2"
value={config.weight}
onChange={e => {
dispatch({
type: 'setWeight',
payload: {
weight: e.target.value,
},
});
}}
/>
)}
{config.strategy !== ROI_STAT && (
<div className="mr-2 text-sm">
<table>
<tbody>
<tr className="mt-2">
<td
className="pr-4"
colSpan="3"
>
<Label
className="font-inter text-[13px] text-white"
text="Lower & Upper Ranges"
></Label>
</td>
</tr>
<tr className="mt-2">
<td className="pr-4 pt-2 text-center">
<Label
className="text-white"
text="CT"
></Label>
</td>
<td>
<div className="flex justify-between">
<Input
label={t('')}
labelClassName="text-white"
className="border-primary-main mt-2 bg-black"
type="text"
containerClassName="mr-2"
value={config.ctLower}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
ctLower: e.target.value,
},
});
}}
/>
<Input
label={t('')}
labelClassName="text-white"
className="border-primary-main mt-2 bg-black"
type="text"
containerClassName="mr-2"
value={config.ctUpper}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
ctUpper: e.target.value,
},
});
}}
/>
</div>
</td>
</tr>
<tr>
<td className="pr-4 pt-2 text-center">
<Label
className="text-white"
text="PT"
></Label>
</td>
<td>
<div className="flex justify-between">
<Input
label={t('')}
labelClassName="text-white"
className="border-primary-main mt-2 bg-black"
type="text"
containerClassName="mr-2"
value={config.ptLower}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
ptLower: e.target.value,
},
});
}}
/>
<Input
label={t('')}
labelClassName="text-white"
className="border-primary-main mt-2 bg-black"
type="text"
containerClassName="mr-2"
value={config.ptUpper}
onChange={e => {
dispatch({
type: 'setThreshold',
payload: {
ptUpper: e.target.value,
},
});
}}
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
);
}
export default ROIThresholdConfiguration;

View File

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

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Input, Dialog, ButtonEnums } from '@ohif/ui';
function segmentationItemEditHandler({ id, servicesManager }: withAppTypes) {
const { segmentationService, uiDialogService } = servicesManager.services;
const segmentation = segmentationService.getSegmentation(id);
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save': {
segmentationService.addOrUpdateSegmentation({
...segmentation,
...value,
});
}
}
uiDialogService.dismiss({ id: 'enter-annotation' });
};
uiDialogService.create({
id: 'enter-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Enter your Segmentation',
noCloseButton: true,
value: { label: segmentation.label || '' },
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
};
return (
<Input
autoFocus
className="border-primary-main bg-black"
type="text"
containerClassName="mr-2"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
/>
);
},
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
},
});
}
export default segmentationItemEditHandler;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import {
PanelSegmentation,
useActiveViewportSegmentationRepresentations,
} from '@ohif/extension-cornerstone';
import { Button, Icons } from '@ohif/ui-next';
export default function PanelTMTV({
servicesManager,
commandsManager,
extensionManager,
configuration,
}: withAppTypes) {
return (
<>
<PanelSegmentation
servicesManager={servicesManager}
commandsManager={commandsManager}
extensionManager={extensionManager}
configuration={configuration}
>
<ExportCSV
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
</PanelSegmentation>
</>
);
}
const ExportCSV = ({ servicesManager, commandsManager }: withAppTypes) => {
const { segmentationsWithRepresentations: representations } =
useActiveViewportSegmentationRepresentations({ servicesManager });
const tmtv = representations[0]?.segmentation.cachedStats?.tmtv;
const segmentations = representations.map(representation => representation.segmentation);
if (!segmentations.length) {
return null;
}
return (
<div className="flex h-8 w-full items-center rounded pr-0.5">
<Button
size="sm"
variant="ghost"
className="pl-1.5"
onClick={() => {
commandsManager.runCommand('exportTMTVReportCSV', {
segmentations,
tmtv,
config: {},
});
}}
>
<Icons.Download />
<span className="pl-1">CSV</span>
</Button>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import React, { useState, useCallback, useReducer, useEffect } from 'react';
import { Button } from '@ohif/ui';
import ROIThresholdConfiguration, {
ROI_STAT,
} from './PanelROIThresholdSegmentation/ROIThresholdConfiguration';
import * as cs3dTools from '@cornerstonejs/tools';
const LOWER_CT_THRESHOLD_DEFAULT = -1024;
const UPPER_CT_THRESHOLD_DEFAULT = 1024;
const LOWER_PT_THRESHOLD_DEFAULT = 2.5;
const UPPER_PT_THRESHOLD_DEFAULT = 100;
const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature
const DEFAULT_STRATEGY = ROI_STAT;
function reducer(state, action) {
const { payload } = action;
const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload;
switch (action.type) {
case 'setStrategy':
return {
...state,
strategy,
};
case 'setThreshold':
return {
...state,
ctLower: ctLower ? ctLower : state.ctLower,
ctUpper: ctUpper ? ctUpper : state.ctUpper,
ptLower: ptLower ? ptLower : state.ptLower,
ptUpper: ptUpper ? ptUpper : state.ptUpper,
};
case 'setWeight':
return {
...state,
weight,
};
default:
return state;
}
}
function RectangleROIOptions({ servicesManager, commandsManager }: withAppTypes) {
const { segmentationService } = servicesManager.services;
const [selectedSegmentationId, setSelectedSegmentationId] = useState(null);
const runCommand = useCallback(
(commandName, commandOptions = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
const [config, dispatch] = useReducer(reducer, {
strategy: DEFAULT_STRATEGY,
ctLower: LOWER_CT_THRESHOLD_DEFAULT,
ctUpper: UPPER_CT_THRESHOLD_DEFAULT,
ptLower: LOWER_PT_THRESHOLD_DEFAULT,
ptUpper: UPPER_PT_THRESHOLD_DEFAULT,
weight: WEIGHT_DEFAULT,
});
const handleROIThresholding = useCallback(() => {
const segmentationId = selectedSegmentationId;
const activeSegmentIndex =
cs3dTools.segmentation.segmentIndex.getActiveSegmentIndex(segmentationId);
// run the threshold based on the active segment index
// Todo: later find a way to associate each rectangle with a segment (e.g., maybe with color?)
runCommand('thresholdSegmentationByRectangleROITool', {
segmentationId,
config,
segmentIndex: activeSegmentIndex,
});
}, [selectedSegmentationId, config]);
useEffect(() => {
const segmentations = segmentationService.getSegmentationRepresentations();
if (!segmentations.length) {
return;
}
const isActive = segmentations.find(seg => seg.isActive);
setSelectedSegmentationId(isActive.id);
}, []);
/**
* Update UI based on segmentation changes (added, removed, updated)
*/
useEffect(() => {
// ~~ Subscription
const updated = segmentationService.EVENTS.SEGMENTATION_MODIFIED;
const subscriptions = [];
[updated].forEach(evt => {
const { unsubscribe } = segmentationService.subscribe(evt, () => {
const segmentations = segmentationService.getSegmentationRepresentations();
if (!segmentations.length) {
return;
}
const isActive = segmentations.find(seg => seg.isActive);
setSelectedSegmentationId(isActive.id);
});
subscriptions.push(unsubscribe);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, []);
return (
<div className="invisible-scrollbar mb-2 flex flex-col overflow-y-auto overflow-x-hidden">
<ROIThresholdConfiguration
config={config}
dispatch={dispatch}
runCommand={runCommand}
/>
{selectedSegmentationId !== null && (
<Button
className="mt-2 !h-[26px] !w-[75px]"
onClick={handleROIThresholding}
>
Run
</Button>
)}
</div>
);
}
export default RectangleROIOptions;

View File

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

View File

@@ -0,0 +1,703 @@
import OHIF from '@ohif/core';
import * as cs from '@cornerstonejs/core';
import * as csTools from '@cornerstonejs/tools';
import { classes } from '@ohif/core';
import getThresholdValues from './utils/getThresholdValue';
import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport';
import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet';
import { getWebWorkerManager } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
const { SegmentationRepresentations } = Enums;
const metadataProvider = classes.MetadataProvider;
const ROI_THRESHOLD_MANUAL_TOOL_IDS = [
'RectangleROIStartEndThreshold',
'RectangleROIThreshold',
'CircleROIStartEndThreshold',
];
const workerManager = getWebWorkerManager();
const options = {
maxWorkerInstances: 1,
autoTerminateOnIdle: {
enabled: true,
idleTimeThreshold: 3000,
},
};
// Register the task
const workerFn = () => {
return new Worker(new URL('./utils/calculateSUVPeakWorker.js', import.meta.url), {
name: 'suv-peak-worker', // name used by the browser to name the worker
});
};
function getVolumesFromSegmentation(segmentationId) {
const csSegmentation = csTools.segmentation.state.getSegmentation(segmentationId);
const labelmapData = csSegmentation.representationData[
SegmentationRepresentations.Labelmap
] as csTools.Types.LabelmapToolOperationDataVolume;
const { volumeId, referencedVolumeId } = labelmapData;
const labelmapVolume = cs.cache.getVolume(volumeId);
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
return { labelmapVolume, referencedVolume };
}
function getLabelmapVolumeFromSegmentation(segmentation) {
const { representationData } = segmentation;
const { volumeId } = representationData[
SegmentationRepresentations.Labelmap
] as csTools.Types.LabelmapToolOperationDataVolume;
return cs.cache.getVolume(volumeId);
}
const commandsModule = ({ servicesManager, commandsManager, extensionManager }: withAppTypes) => {
const {
viewportGridService,
uiNotificationService,
displaySetService,
hangingProtocolService,
toolGroupService,
cornerstoneViewportService,
segmentationService,
} = servicesManager.services;
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { getEnabledElement } = utilityModule.exports;
function _getActiveViewportsEnabledElement() {
const { activeViewportId } = viewportGridService.getState();
const { element } = getEnabledElement(activeViewportId) || {};
const enabledElement = cs.getEnabledElement(element);
return enabledElement;
}
function _getAnnotationsSelectedByToolNames(toolNames) {
return toolNames.reduce((allAnnotationUIDs, toolName) => {
const annotationUIDs =
csTools.annotation.selection.getAnnotationsSelectedByToolName(toolName);
return allAnnotationUIDs.concat(annotationUIDs);
}, []);
}
const actions = {
getMatchingPTDisplaySet: ({ viewportMatchDetails }) => {
// Todo: this is assuming that the hanging protocol has successfully matched
// the correct PT. For future, we should have a way to filter out the PTs
// that are in the viewer layout (but then we have the problem of the attenuation
// corrected PT vs the non-attenuation correct PT)
let ptDisplaySet = null;
for (const [viewportId, viewportDetails] of viewportMatchDetails) {
const { displaySetsInfo } = viewportDetails;
const displaySets = displaySetsInfo.map(({ displaySetInstanceUID }) =>
displaySetService.getDisplaySetByUID(displaySetInstanceUID)
);
if (!displaySets || displaySets.length === 0) {
continue;
}
ptDisplaySet = displaySets.find(displaySet => displaySet.Modality === 'PT');
if (ptDisplaySet) {
break;
}
}
return ptDisplaySet;
},
getPTMetadata: ({ ptDisplaySet }) => {
const dataSource = extensionManager.getDataSources()[0];
const imageIds = dataSource.getImageIdsForDisplaySet(ptDisplaySet);
const firstImageId = imageIds[0];
const instance = metadataProvider.get('instance', firstImageId);
if (instance.Modality !== 'PT') {
return;
}
const metadata = {
SeriesTime: instance.SeriesTime,
Modality: instance.Modality,
PatientSex: instance.PatientSex,
PatientWeight: instance.PatientWeight,
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose:
instance.RadiopharmaceuticalInformationSequence[0].RadionuclideTotalDose,
RadionuclideHalfLife:
instance.RadiopharmaceuticalInformationSequence[0].RadionuclideHalfLife,
RadiopharmaceuticalStartTime:
instance.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalStartTime,
RadiopharmaceuticalStartDateTime:
instance.RadiopharmaceuticalInformationSequence[0].RadiopharmaceuticalStartDateTime,
},
};
return metadata;
},
createNewLabelmapFromPT: async ({ label }) => {
// Create a segmentation of the same resolution as the source data
// using volumeLoader.createAndCacheDerivedVolume.
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
const ptDisplaySet = actions.getMatchingPTDisplaySet({
viewportMatchDetails,
});
let withPTViewportId = null;
for (const [viewportId, { displaySetsInfo }] of viewportMatchDetails.entries()) {
const isPT = displaySetsInfo.some(
({ displaySetInstanceUID }) =>
displaySetInstanceUID === ptDisplaySet.displaySetInstanceUID
);
if (isPT) {
withPTViewportId = viewportId;
break;
}
}
if (!ptDisplaySet) {
uiNotificationService.error('No matching PT display set found');
return;
}
const currentSegmentations =
segmentationService.getSegmentationRepresentations(withPTViewportId);
const displaySet = displaySetService.getDisplaySetByUID(ptDisplaySet.displaySetInstanceUID);
const segmentationId = await segmentationService.createLabelmapForDisplaySet(displaySet, {
label: `Segmentation ${currentSegmentations.length + 1}`,
segments: { 1: { label: 'Segment 1', active: true } },
});
segmentationService.addSegmentationRepresentation(withPTViewportId, {
segmentationId,
});
return segmentationId;
},
thresholdSegmentationByRectangleROITool: ({ segmentationId, config, segmentIndex }) => {
const segmentation = csTools.segmentation.state.getSegmentation(segmentationId);
const { representationData } = segmentation;
const { displaySetMatchDetails: matchDetails } = hangingProtocolService.getMatchDetails();
const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use
const ctDisplaySet = matchDetails.get('ctDisplaySet');
const ctVolumeId = `${volumeLoaderScheme}:${ctDisplaySet.displaySetInstanceUID}`; // VolumeId with loader id + volume id
const { volumeId: segVolumeId } = representationData[
SegmentationRepresentations.Labelmap
] as csTools.Types.LabelmapToolOperationDataVolume;
const { referencedVolumeId } = cs.cache.getVolume(segVolumeId);
const annotationUIDs = _getAnnotationsSelectedByToolNames(ROI_THRESHOLD_MANUAL_TOOL_IDS);
if (annotationUIDs.length === 0) {
uiNotificationService.show({
title: 'Commands Module',
message: 'No ROIThreshold Tool is Selected',
type: 'error',
});
return;
}
const labelmapVolume = cs.cache.getVolume(segmentationId);
let referencedVolume = cs.cache.getVolume(referencedVolumeId);
const ctReferencedVolume = cs.cache.getVolume(ctVolumeId);
// check if viewport is
if (!referencedVolume) {
throw new Error('No Reference volume found');
}
if (!labelmapVolume) {
throw new Error('No Reference labelmap found');
}
const annotation = csTools.annotation.state.getAnnotation(annotationUIDs[0]);
const {
metadata: {
enabledElement: { viewport },
},
} = annotation;
const showingReferenceVolume = viewport.hasVolumeId(referencedVolumeId);
if (!showingReferenceVolume) {
// if the reference volume is not being displayed, we can't
// rely on it for thresholding, we have couple of options here
// 1. We choose whatever volume is being displayed
// 2. We check if it is a fusion viewport, we pick the volume
// that matches the size and dimensions of the labelmap. This might
// happen if the 4D PT is converted to a computed volume and displayed
// and wants to threshold the labelmap
// 3. We throw an error
const displaySetInstanceUIDs = viewportGridService.getDisplaySetsUIDsForViewport(
viewport.id
);
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
const volume = cs.cache
.getVolumes()
.find(volume => volume.volumeId.includes(displaySetInstanceUID));
if (
cs.utilities.isEqual(volume.dimensions, labelmapVolume.dimensions) &&
cs.utilities.isEqual(volume.spacing, labelmapVolume.spacing)
) {
referencedVolume = volume;
}
});
}
const { ptLower, ptUpper, ctLower, ctUpper } = getThresholdValues(
annotationUIDs,
[referencedVolume, ctReferencedVolume],
config
);
return csTools.utilities.segmentation.rectangleROIThresholdVolumeByRange(
annotationUIDs,
labelmapVolume,
[
{ volume: referencedVolume, lower: ptLower, upper: ptUpper },
{ volume: ctReferencedVolume, lower: ctLower, upper: ctUpper },
],
{ overwrite: true, segmentIndex }
);
},
calculateSuvPeak: async ({ segmentationId, segmentIndex }) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
const { representationData } = segmentation;
const { volumeId, referencedVolumeId } = representationData[
SegmentationRepresentations.Labelmap
] as csTools.Types.LabelmapToolOperationDataVolume;
const labelmap = cs.cache.getVolume(volumeId);
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
// if we put it in the top, it will appear in other modes
workerManager.registerWorker('suv-peak-worker', workerFn, options);
const annotationUIDs = _getAnnotationsSelectedByToolNames(ROI_THRESHOLD_MANUAL_TOOL_IDS);
const annotations = annotationUIDs.map(annotationUID =>
csTools.annotation.state.getAnnotation(annotationUID)
);
const labelmapProps = {
dimensions: labelmap.dimensions,
origin: labelmap.origin,
direction: labelmap.direction,
spacing: labelmap.spacing,
metadata: labelmap.metadata,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
};
const referenceVolumeProps = {
dimensions: referencedVolume.dimensions,
origin: referencedVolume.origin,
direction: referencedVolume.direction,
spacing: referencedVolume.spacing,
metadata: referencedVolume.metadata,
scalarData: referencedVolume.voxelManager.getCompleteScalarDataArray(),
};
// metadata in annotations has enabledElement which is not serializable
// we need to remove it
// Todo: we should probably have a sanitization function for this
const annotationsToSend = annotations.map(annotation => {
return {
...annotation,
metadata: {
...annotation.metadata,
enabledElement: {
...annotation.metadata.enabledElement,
viewport: null,
renderingEngine: null,
element: null,
},
},
};
});
const suvPeak =
(await workerManager.executeTask('suv-peak-worker', 'calculateSuvPeak', {
labelmapProps,
referenceVolumeProps,
annotations: annotationsToSend,
segmentIndex,
})) || {};
return {
suvPeak: suvPeak.mean,
suvMax: suvPeak.max,
suvMaxIJK: suvPeak.maxIJK,
suvMaxLPS: suvPeak.maxLPS,
};
},
getLesionStats: ({ segmentationId, segmentIndex = 1 }) => {
const { labelmapVolume, referencedVolume } = getVolumesFromSegmentation(segmentationId);
const { voxelManager: segVoxelManager, imageData, spacing } = labelmapVolume;
const { voxelManager: refVoxelManager } = referencedVolume;
let segmentationMax = -Infinity;
let segmentationMin = Infinity;
const segmentationValues = [];
let voxelCount = 0;
const callback = ({ value, index }) => {
if (value === segmentIndex) {
const refValue = refVoxelManager.getAtIndex(index) as number;
segmentationValues.push(refValue);
if (refValue > segmentationMax) {
segmentationMax = refValue;
}
if (refValue < segmentationMin) {
segmentationMin = refValue;
}
voxelCount++;
}
};
segVoxelManager.forEach(callback, { imageData });
const mean = segmentationValues.reduce((a, b) => a + b, 0) / voxelCount;
const stats = {
minValue: segmentationMin,
maxValue: segmentationMax,
meanValue: mean,
stdValue: Math.sqrt(
segmentationValues.map(k => (k - mean) ** 2).reduce((acc, curr) => acc + curr, 0) /
voxelCount
),
volume: voxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3,
};
return stats;
},
calculateLesionGlycolysis: ({ lesionStats }) => {
const { meanValue, volume } = lesionStats;
return {
lesionGlyoclysisStats: volume * meanValue,
};
},
calculateTMTV: async ({ segmentations }) => {
const labelmapProps = segmentations.map(segmentation => {
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
return {
dimensions: labelmap.dimensions,
spacing: labelmap.spacing,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
origin: labelmap.origin,
direction: labelmap.direction,
};
});
if (!labelmapProps.length) {
return;
}
return await workerManager.executeTask('suv-peak-worker', 'calculateTMTV', labelmapProps);
},
exportTMTVReportCSV: async ({ segmentations, tmtv, config, options }) => {
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
segmentations,
});
const tlg = await actions.getTotalLesionGlycolysis({ segmentations });
const additionalReportRows = [
{ key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } },
{ key: 'Threshold Configuration', value: { ...config } },
];
if (tmtv !== undefined) {
additionalReportRows.unshift({
key: 'Total Metabolic Tumor Volume',
value: { tmtv },
});
}
createAndDownloadTMTVReport(segReport, additionalReportRows, options);
},
getTotalLesionGlycolysis: async ({ segmentations }) => {
const labelmapProps = segmentations.map(segmentation => {
const labelmap = getLabelmapVolumeFromSegmentation(segmentation);
return {
dimensions: labelmap.dimensions,
spacing: labelmap.spacing,
scalarData: labelmap.voxelManager.getCompleteScalarDataArray(),
origin: labelmap.origin,
direction: labelmap.direction,
};
});
const { referencedVolume: ptVolume } = getVolumesFromSegmentation(
segmentations[0].segmentationId
);
const ptVolumeProps = {
dimensions: ptVolume.dimensions,
spacing: ptVolume.spacing,
scalarData: ptVolume.voxelManager.getCompleteScalarDataArray(),
origin: ptVolume.origin,
direction: ptVolume.direction,
};
return await workerManager.executeTask('suv-peak-worker', 'getTotalLesionGlycolysis', {
labelmapProps,
referenceVolumeProps: ptVolumeProps,
});
},
setStartSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const { focalPoint } = viewport.getCamera();
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
ROI_THRESHOLD_MANUAL_TOOL_IDS
);
const annotationUID = selectedAnnotationUIDs[0];
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
// set the current focal point
annotation.data.startCoordinate = focalPoint;
// IMPORTANT: invalidate the toolData for the cached stat to get updated
// and re-calculate the projection points
annotation.invalidated = true;
viewport.render();
},
setEndSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
ROI_THRESHOLD_MANUAL_TOOL_IDS
);
const annotationUID = selectedAnnotationUIDs[0];
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
// get the current focal point
const focalPointToEnd = viewport.getCamera().focalPoint;
annotation.data.endCoordinate = focalPointToEnd;
// IMPORTANT: invalidate the toolData for the cached stat to get updated
// and re-calculate the projection points
annotation.invalidated = true;
viewport.render();
},
createTMTVRTReport: () => {
// get all Rectangle ROI annotation
const stateManager = csTools.annotation.state.getAnnotationManager();
const annotations = [];
Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => {
const forAnnotations = stateManager.annotations[frameOfReferenceUID];
const ROIAnnotations = ROI_THRESHOLD_MANUAL_TOOL_IDS.reduce(
(annotations, toolName) => [...annotations, ...(forAnnotations[toolName] ?? [])],
[]
);
annotations.push(...ROIAnnotations);
});
commandsManager.runCommand('exportRTReportForAnnotations', {
annotations,
});
},
getSegmentationCSVReport: ({ segmentations }) => {
if (!segmentations || !segmentations.length) {
segmentations = segmentationService.getSegmentations();
}
const report = {};
for (const segmentation of segmentations) {
const { label, segmentationId, representationData } =
segmentation as csTools.Types.Segmentation;
const id = segmentationId;
const segReport = { id, label };
if (!representationData) {
report[id] = segReport;
continue;
}
const { cachedStats } = segmentation.segments[1] || {}; // Assuming we want stats from the first segment
if (cachedStats) {
Object.entries(cachedStats).forEach(([key, value]) => {
if (typeof value !== 'object') {
segReport[key] = value;
} else {
Object.entries(value).forEach(([subKey, subValue]) => {
const newKey = `${key}_${subKey}`;
segReport[newKey] = subValue;
});
}
});
}
const labelmapVolume =
segmentation.representationData[SegmentationRepresentations.Labelmap];
if (!labelmapVolume) {
report[id] = segReport;
continue;
}
const referencedVolumeId = labelmapVolume.referencedVolumeId;
const referencedVolume = cs.cache.getVolume(referencedVolumeId);
if (!referencedVolume) {
report[id] = segReport;
continue;
}
if (!referencedVolume.imageIds || !referencedVolume.imageIds.length) {
report[id] = segReport;
continue;
}
const firstImageId = referencedVolume.imageIds[0];
const instance = OHIF.classes.MetadataProvider.get('instance', firstImageId);
if (!instance) {
report[id] = segReport;
continue;
}
report[id] = {
...segReport,
PatientID: instance.PatientID ?? '000000',
PatientName: instance.PatientName.Alphabetic,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
StudyDate: instance.StudyDate,
};
}
return report;
},
exportRTReportForAnnotations: ({ annotations }) => {
dicomRTAnnotationExport(annotations);
},
setFusionPTColormap: ({ toolGroupId, colormap }) => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
if (!toolGroup) {
return;
}
const { viewportMatchDetails } = hangingProtocolService.getMatchDetails();
const ptDisplaySet = actions.getMatchingPTDisplaySet({
viewportMatchDetails,
});
if (!ptDisplaySet) {
return;
}
const fusionViewportIds = toolGroup.getViewportIds();
const viewports = [];
fusionViewportIds.forEach(viewportId => {
commandsManager.runCommand('setViewportColormap', {
viewportId,
displaySetInstanceUID: ptDisplaySet.displaySetInstanceUID,
colormap: {
name: colormap,
},
});
viewports.push(cornerstoneViewportService.getCornerstoneViewport(viewportId));
});
viewports.forEach(viewport => {
viewport.render();
});
},
};
const definitions = {
setEndSliceForROIThresholdTool: {
commandFn: actions.setEndSliceForROIThresholdTool,
},
setStartSliceForROIThresholdTool: {
commandFn: actions.setStartSliceForROIThresholdTool,
},
getMatchingPTDisplaySet: {
commandFn: actions.getMatchingPTDisplaySet,
},
getPTMetadata: {
commandFn: actions.getPTMetadata,
},
createNewLabelmapFromPT: {
commandFn: actions.createNewLabelmapFromPT,
},
thresholdSegmentationByRectangleROITool: {
commandFn: actions.thresholdSegmentationByRectangleROITool,
},
getTotalLesionGlycolysis: {
commandFn: actions.getTotalLesionGlycolysis,
},
calculateSuvPeak: {
commandFn: actions.calculateSuvPeak,
},
getLesionStats: {
commandFn: actions.getLesionStats,
},
calculateTMTV: {
commandFn: actions.calculateTMTV,
},
exportTMTVReportCSV: {
commandFn: actions.exportTMTVReportCSV,
},
createTMTVRTReport: {
commandFn: actions.createTMTVRTReport,
},
getSegmentationCSVReport: {
commandFn: actions.getSegmentationCSVReport,
},
exportRTReportForAnnotations: {
commandFn: actions.exportRTReportForAnnotations,
},
setFusionPTColormap: {
commandFn: actions.setFusionPTColormap,
},
};
return {
actions,
definitions,
defaultContext: 'TMTV:CORNERSTONE',
};
};
export default commandsModule;

View File

@@ -0,0 +1,350 @@
import {
ctAXIAL,
ctCORONAL,
ctSAGITTAL,
fusionAXIAL,
fusionCORONAL,
fusionSAGITTAL,
mipSAGITTAL,
ptAXIAL,
ptCORONAL,
ptSAGITTAL,
} from './utils/hpViewports';
/**
* represents a 3x4 viewport layout configuration. The layout displays CT axial, sagittal, and coronal
* images in the first row, PT axial, sagittal, and coronal images in the second row, and fusion axial,
* sagittal, and coronal images in the third row. The fourth column is fully spanned by a MIP sagittal
* image, covering all three rows. It has synchronizers for windowLevel for all CT and PT images, and
* also camera synchronizer for each orientation
*/
const stage1: AppTypes.HangingProtocol.ProtocolStage = {
name: 'default',
id: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
],
},
},
viewports: [
ctAXIAL,
ctSAGITTAL,
ctCORONAL,
ptAXIAL,
ptSAGITTAL,
ptCORONAL,
fusionAXIAL,
fusionSAGITTAL,
fusionCORONAL,
mipSAGITTAL,
],
createdDate: '2021-02-23T18:32:42.850Z',
};
/**
* The layout displays CT axial image in the top-left viewport, fusion axial image
* in the top-right viewport, PT axial image in the bottom-left viewport, and MIP
* sagittal image in the bottom-right viewport. The layout follows a simple grid
* pattern with 2 rows and 2 columns. It includes synchronizers as well.
*/
const stage2 = {
name: 'Fusion 2x2',
id: 'Fusion-2x2',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [ctAXIAL, fusionAXIAL, ptAXIAL, mipSAGITTAL],
};
/**
* The top row displays CT images in axial, sagittal, and coronal orientations from
* left to right, respectively. The bottom row displays PT images in axial, sagittal,
* and coronal orientations from left to right, respectively.
* The layout follows a simple grid pattern with 2 rows and 3 columns.
* It includes synchronizers as well.
*/
const stage3: AppTypes.HangingProtocol.ProtocolStage = {
name: '2x3-layout',
id: '2x3-layout',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 3,
},
},
viewports: [ctAXIAL, ctSAGITTAL, ctCORONAL, ptAXIAL, ptSAGITTAL, ptCORONAL],
};
/**
* In this layout, the top row displays PT images in coronal, sagittal, and axial
* orientations from left to right, respectively, followed by a MIP sagittal image
* that spans both rows on the rightmost side. The bottom row displays fusion images
* in coronal, sagittal, and axial orientations from left to right, respectively.
* There is no viewport in the bottom row's rightmost position, as the MIP sagittal viewport
* from the top row spans the full height of both rows.
* It includes synchronizers as well.
*/
const stage4: AppTypes.HangingProtocol.ProtocolStage = {
name: '2x4-layout',
id: '2x4-layout',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
{
x: 0,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
{
x: 1 / 4,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
{
x: 2 / 4,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
],
},
},
viewports: [
ptCORONAL,
ptSAGITTAL,
ptAXIAL,
mipSAGITTAL,
fusionCORONAL,
fusionSAGITTAL,
fusionAXIAL,
],
};
/**
* This layout displays three fusion viewports: axial, sagittal, and coronal.
* It follows a simple grid pattern with 1 row and 3 columns.
*/
// const stage0: AppTypes.HangingProtocol.ProtocolStage = {
// name: 'Fusion 1x3',
// viewportStructure: {
// layoutType: 'grid',
// properties: {
// rows: 1,
// columns: 3,
// },
// },
// viewports: [fusionAXIAL, fusionSAGITTAL, fusionCORONAL],
// };
const ptCT: AppTypes.HangingProtocol.Protocol = {
id: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
locked: true,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2022-10-04T19:22:08.894Z',
availableTo: {},
editableBy: {},
imageLoadStrategy: 'interleaveTopToBottom', // "default" , "interleaveTopToBottom", "interleaveCenter"
protocolMatchingRules: [
{
attribute: 'ModalitiesInStudy',
constraint: {
contains: ['CT', 'PT'],
},
},
{
attribute: 'StudyDescription',
constraint: {
contains: 'PETCT',
},
},
{
attribute: 'StudyDescription',
constraint: {
contains: 'PET/CT',
},
},
],
displaySetSelectors: {
ctDisplaySet: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: {
value: 'CT',
},
},
required: true,
},
{
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'SeriesDescription',
constraint: {
contains: 'CT',
},
},
{
attribute: 'SeriesDescription',
constraint: {
contains: 'CT WB',
},
},
],
},
ptDisplaySet: {
seriesMatchingRules: [
{
attribute: 'Modality',
constraint: {
equals: 'PT',
},
required: true,
},
{
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'SeriesDescription',
constraint: {
contains: 'Corrected',
},
},
{
weight: 2,
attribute: 'SeriesDescription',
constraint: {
doesNotContain: {
value: 'Uncorrected',
},
},
},
],
},
},
stages: [stage1, stage2, stage3, stage4],
numberOfPriorsReferenced: -1,
};
function getHangingProtocolModule() {
return [
{
name: ptCT.id,
protocol: ptCT,
},
];
}
export default getHangingProtocolModule;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdExport } from './Panels';
import { Toolbox } from '@ohif/ui-next';
import PanelTMTV from './Panels/PanelTMTV';
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedPanelPetSuv = () => {
return (
<PanelPetSUV
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
const wrappedROIThresholdToolbox = () => {
return (
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="ROIThresholdToolbox"
title="Threshold Tools"
/>
);
};
const wrappedROIThresholdExport = () => {
return (
<PanelROIThresholdExport
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
);
};
const wrappedPanelTMTV = () => {
return (
<>
<Toolbox
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="ROIThresholdToolbox"
title="Threshold Tools"
/>
<PanelTMTV
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
<PanelROIThresholdExport
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
</>
);
};
return [
{
name: 'petSUV',
iconName: 'tab-patient-info',
iconLabel: 'Patient Info',
label: 'Patient Info',
component: wrappedPanelPetSuv,
},
{
name: 'tmtv',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
component: wrappedPanelTMTV,
},
{
name: 'tmtvBox',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation Toolbox',
component: wrappedROIThresholdToolbox,
},
{
name: 'tmtvExport',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation Export',
component: wrappedROIThresholdExport,
},
];
}
export default getPanelModule;

View File

@@ -0,0 +1,10 @@
import RectangleROIOptions from './Panels/RectangleROIOptions';
export default function getToolbarModule({ commandsManager, servicesManager }) {
return [
{
name: 'tmtv.RectangleROIThresholdOptions',
defaultComponent: () => RectangleROIOptions({ commandsManager, servicesManager }),
},
];
}

View File

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

View File

@@ -0,0 +1,31 @@
import { id } from './id';
import getHangingProtocolModule from './getHangingProtocolModule';
import getPanelModule from './getPanelModule';
import init from './init';
import commandsModule from './commandsModule';
import getToolbarModule from './getToolbarModule';
/**
*
*/
const tmtvExtension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration({ servicesManager, commandsManager, extensionManager, configuration = {} }) {
init({ servicesManager, commandsManager, extensionManager, configuration });
},
getToolbarModule,
getPanelModule,
getHangingProtocolModule,
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
};
export default tmtvExtension;

View File

@@ -0,0 +1,52 @@
import {
addTool,
RectangleROIStartEndThresholdTool,
CircleROIStartEndThresholdTool,
} from '@cornerstonejs/tools';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
/**
*
* @param {Object} servicesManager
* @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig
*/
export default function init({ servicesManager }) {
const { measurementService, displaySetService, cornerstoneViewportService } =
servicesManager.services;
addTool(RectangleROIStartEndThresholdTool);
addTool(CircleROIStartEndThresholdTool);
const { RectangleROIStartEndThreshold, CircleROIStartEndThreshold } =
measurementServiceMappingsFactory(
measurementService,
displaySetService,
cornerstoneViewportService
);
const csTools3DVer1MeasurementSource = measurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'RectangleROIStartEndThreshold',
RectangleROIStartEndThreshold.matchingCriteria,
RectangleROIStartEndThreshold.toAnnotation,
RectangleROIStartEndThreshold.toMeasurement
);
measurementService.addMapping(
csTools3DVer1MeasurementSource,
'CircleROIStartEndThreshold',
CircleROIStartEndThreshold.matchingCriteria,
CircleROIStartEndThreshold.toAnnotation,
CircleROIStartEndThreshold.toMeasurement
);
}

View File

@@ -0,0 +1,209 @@
import { utilities } from '@cornerstonejs/core';
import { utilities as cstUtils } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import { expose } from 'comlink';
const createVolume = ({ dimensions, origin, direction, spacing, metadata, scalarData }) => {
const imageData = vtkImageData.newInstance();
imageData.setDimensions(dimensions);
imageData.setOrigin(origin);
imageData.setDirection(direction);
imageData.setSpacing(spacing);
const scalarArray = vtkDataArray.newInstance({
name: 'Pixels',
numberOfComponents: 1,
values: scalarData,
});
imageData.getPointData().setScalars(scalarArray);
imageData.modified();
const voxelManager = utilities.VoxelManager.createScalarVolumeVoxelManager({
scalarData,
dimensions,
numberOfComponents: 1,
});
return {
imageData,
spacing,
origin,
direction,
metadata,
voxelManager,
};
};
/**
* This method calculates the SUV peak on a segmented ROI from a reference PET
* volume. If a rectangle annotation is provided, the peak is calculated within that
* rectangle. Otherwise, the calculation is performed on the entire volume which
* will be slower but same result.
* @param viewport Viewport to use for the calculation
* @param labelmap Labelmap from which the mask is taken
* @param referenceVolume PET volume to use for SUV calculation
* @param toolData [Optional] list of toolData to use for SUV calculation
* @param segmentIndex The index of the segment to use for masking
* @returns
*/
function calculateSuvPeak({ labelmapProps, referenceVolumeProps, annotations, segmentIndex = 1 }) {
const labelmapInfo = createVolume(labelmapProps);
const referenceInfo = createVolume(referenceVolumeProps);
if (referenceInfo.metadata.Modality !== 'PT') {
return;
}
const { dimensions, imageData: labelmapImageData } = labelmapInfo;
const { imageData: referenceVolumeImageData } = referenceInfo;
let boundsIJK;
// Todo: using the first annotation for now
if (annotations?.length && annotations[0].data?.cachedStats) {
const { projectionPoints } = annotations[0].data.cachedStats;
const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now
const rectangleCornersIJK = pointsToUse.map(world => {
const ijk = vec3.fromValues(0, 0, 0);
referenceVolumeImageData.worldToIndex(world, ijk);
return ijk;
});
boundsIJK = cstUtils.boundingBox.getBoundingBoxAroundShape(rectangleCornersIJK, dimensions);
}
let max = 0;
let maxIJK = [0, 0, 0];
let maxLPS = [0, 0, 0];
const callback = ({ pointIJK, pointLPS }) => {
const value = labelmapInfo.voxelManager.getAtIJKPoint(pointIJK);
if (value !== segmentIndex) {
return;
}
const referenceValue = referenceInfo.voxelManager.getAtIJKPoint(pointIJK);
if (referenceValue > max) {
max = referenceValue;
maxIJK = pointIJK;
maxLPS = pointLPS;
}
};
labelmapInfo.voxelManager.forEach(callback, {
boundsIJK,
imageData: labelmapImageData,
isInObject: () => true,
returnPoints: true,
});
const direction = labelmapImageData.getDirection().slice(0, 3);
/**
* 2. Find the bottom and top of the great circle for the second sphere (1cc sphere)
* V = (4/3)πr3
*/
const radius = Math.pow(1 / ((4 / 3) * Math.PI), 1 / 3) * 10;
const diameter = radius * 2;
const secondaryCircleWorld = vec3.create();
const bottomWorld = vec3.create();
const topWorld = vec3.create();
referenceVolumeImageData.indexToWorld(maxIJK, secondaryCircleWorld);
vec3.scaleAndAdd(bottomWorld, secondaryCircleWorld, direction, -diameter / 2);
vec3.scaleAndAdd(topWorld, secondaryCircleWorld, direction, diameter / 2);
const suvPeakCirclePoints = [bottomWorld, topWorld];
/**
* 3. Find the Mean and Max of the 1cc sphere centered on the suv Max of the previous
* sphere
*/
let count = 0;
let acc = 0;
const suvPeakMeanCallback = ({ value }) => {
acc += value;
count += 1;
};
cstUtils.pointInSurroundingSphereCallback(
referenceVolumeImageData,
suvPeakCirclePoints,
suvPeakMeanCallback
);
const mean = acc / count;
return {
max,
maxIJK,
maxLPS,
mean,
};
}
function calculateTMTV(labelmapProps, segmentIndex = 1) {
const labelmaps = labelmapProps.map(props => createVolume(props));
const mergedLabelmap =
labelmaps.length === 1
? labelmaps[0]
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
const { imageData, spacing } = mergedLabelmap;
const values = imageData.getPointData().getScalars().getData();
// count non-zero values inside the outputData, this would
// consider the overlapping regions to be only counted once
const numVoxels = values.reduce((acc, curr) => {
if (curr > 0) {
return acc + 1;
}
return acc;
}, 0);
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
}
function getTotalLesionGlycolysis({ labelmapProps, referenceVolumeProps }) {
const labelmaps = labelmapProps.map(props => createVolume(props));
const mergedLabelmap =
labelmaps.length === 1
? labelmaps[0]
: cstUtils.segmentation.createMergedLabelmapForIndex(labelmaps);
// grabbing the first labelmap referenceVolume since it will be the same for all
const { spacing } = labelmaps[0];
const ptVolume = createVolume(referenceVolumeProps);
let suv = 0;
let totalLesionVoxelCount = 0;
const scalarDataLength = mergedLabelmap.voxelManager.getScalarDataLength();
for (let i = 0; i < scalarDataLength; i++) {
// if not background
if (mergedLabelmap.voxelManager.getAtIndex(i) !== 0) {
suv += ptVolume.voxelManager.getAtIndex(i);
totalLesionVoxelCount += 1;
}
}
// Average SUV for the merged labelmap
const averageSuv = suv / totalLesionVoxelCount;
// total Lesion Glycolysis [suv * ml]
return averageSuv * totalLesionVoxelCount * spacing[0] * spacing[1] * spacing[2] * 1e-3;
}
const obj = {
calculateSuvPeak,
calculateTMTV,
getTotalLesionGlycolysis,
};
expose(obj);

View File

@@ -0,0 +1,42 @@
import { Types } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
/**
* Given a list of labelmaps (with the possibility of overlapping regions),
* and a referenceVolume, it calculates the total metabolic tumor volume (TMTV)
* by flattening and rasterizing each segment into a single labelmap and summing
* the total number of volume voxels. It should be noted that for this calculation
* we do not double count voxels that are part of multiple labelmaps.
* @param {} labelmaps
* @param {number} segmentIndex
* @returns {number} TMTV in ml
*/
function calculateTMTV(labelmaps: Array<Types.IImageVolume>, segmentIndex = 1): number {
const volumeId = 'mergedLabelmap';
const mergedLabelmap = utilities.segmentation.createMergedLabelmapForIndex(
labelmaps,
segmentIndex,
volumeId
);
const { imageData, spacing, voxelManager } = mergedLabelmap;
// count non-zero values inside the outputData, this would
// consider the overlapping regions to be only counted once
let numVoxels = 0;
const callback = ({ value }) => {
if (value > 0) {
numVoxels += 1;
}
};
voxelManager.forEach(callback, {
imageData,
isInObject: () => true,
});
return 1e-3 * numVoxels * spacing[0] * spacing[1] * spacing[2];
}
export default calculateTMTV;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
export default function createAndDownloadTMTVReport(segReport, additionalReportRows, options = {}) {
const firstReport = segReport[Object.keys(segReport)[0]];
const columns = Object.keys(firstReport);
const csv = [columns.join(',')];
Object.values(segReport).forEach(segmentation => {
const row = [];
columns.forEach(column => {
// if it is array then we need to replace , with space to avoid csv parsing error
row.push(
Array.isArray(segmentation[column]) ? segmentation[column].join(' ') : segmentation[column]
);
});
csv.push(row.join(','));
});
csv.push('');
csv.push('');
csv.push('');
csv.push(`Patient ID,${firstReport.PatientID}`);
csv.push(`Study Date,${firstReport.StudyDate}`);
csv.push('');
additionalReportRows.forEach(({ key, value: values }) => {
const temp = [];
temp.push(`${key}`);
Object.keys(values).forEach(k => {
temp.push(`${k}`);
temp.push(`${values[k]}`);
});
csv.push(temp.join(','));
});
const blob = new Blob([csv.join('\n')], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = options.filename ?? `${firstReport.PatientID}_tmtv.csv`;
a.click();
}

View File

@@ -0,0 +1,19 @@
import dcmjs from 'dcmjs';
import { classes, DicomMetadataStore } from '@ohif/core';
import { adaptersRT } from '@cornerstonejs/adapters';
const { datasetToBlob } = dcmjs.data;
const metadataProvider = classes.MetadataProvider;
export default function dicomRTAnnotationExport(annotations) {
const dataset = adaptersRT.Cornerstone3D.RTSS.generateRTSSFromAnnotations(
annotations,
metadataProvider,
DicomMetadataStore
);
const reportBlob = datasetToBlob(dataset);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
}

View File

@@ -0,0 +1,3 @@
import dicomRTAnnotationExport from './dicomRTAnnotationExport';
export default dicomRTAnnotationExport;

View File

@@ -0,0 +1,73 @@
import * as csTools from '@cornerstonejs/tools';
function getRoiStats(referencedVolume, annotations) {
// roiStats
const { imageData } = referencedVolume;
const values = imageData.getPointData().getScalars().getData();
// Todo: add support for other strategies
const { fn, baseValue } = _getStrategyFn('max');
let value = baseValue;
const boundsIJK = csTools.utilities.rectangleROITool.getBoundsIJKFromRectangleAnnotations(
annotations,
referencedVolume
);
const [[iMin, iMax], [jMin, jMax], [kMin, kMax]] = boundsIJK;
for (let i = iMin; i <= iMax; i++) {
for (let j = jMin; j <= jMax; j++) {
for (let k = kMin; k <= kMax; k++) {
const offset = imageData.computeOffsetIndex([i, j, k]);
value = fn(values[offset], value);
}
}
}
return value;
}
function getThresholdValues(
annotationUIDs,
referencedVolumes,
config
): { ptLower: number; ptUpper: number; ctLower: number; ctUpper: number } {
if (config.strategy === 'range') {
return {
ptLower: Number(config.ptLower),
ptUpper: Number(config.ptUpper),
ctLower: Number(config.ctLower),
ctUpper: Number(config.ctUpper),
};
}
const { weight } = config;
const annotations = annotationUIDs.map(annotationUID =>
csTools.annotation.state.getAnnotation(annotationUID)
);
const ptValue = getRoiStats(referencedVolumes[0], annotations);
return {
ctLower: -Infinity,
ctUpper: +Infinity,
ptLower: weight * ptValue,
ptUpper: +Infinity,
};
}
function _getStrategyFn(statistic): {
fn: (a: number, b: number) => number;
baseValue: number;
} {
const baseValue = -Infinity;
const fn = (number, maxValue) => {
if (number > maxValue) {
maxValue = number;
}
return maxValue;
};
return { fn, baseValue };
}
export default getThresholdValues;

View File

@@ -0,0 +1,97 @@
import { Segment, Segmentation } from '@cornerstonejs/tools/types';
import { triggerEvent, eventTarget, Enums } from '@cornerstonejs/core';
export const handleROIThresholding = async ({
segmentationId,
commandsManager,
segmentationService,
}: withAppTypes<{
segmentationId: string;
}>) => {
const segmentation = segmentationService.getSegmentation(segmentationId);
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
progress: 0,
type: 'Calculate Lesion Stats',
id: segmentationId,
});
// re-calculating the cached stats for the active segmentation
const updatedPerSegmentCachedStats = {};
for (const [segmentIndex, segment] of Object.entries(segmentation.segments)) {
if (!segment) {
continue;
}
const numericSegmentIndex = Number(segmentIndex);
const lesionStats = await commandsManager.run('getLesionStats', {
segmentationId,
segmentIndex: numericSegmentIndex,
});
const suvPeak = await commandsManager.run('calculateSuvPeak', {
segmentationId,
segmentIndex: numericSegmentIndex,
});
const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue;
// update segDetails with the suv peak for the active segmentation
const cachedStats = {
lesionStats,
suvPeak,
lesionGlyoclysisStats,
};
const updatedSegment: Segment = {
...segment,
cachedStats: {
...segment.cachedStats,
...cachedStats,
},
};
updatedPerSegmentCachedStats[numericSegmentIndex] = cachedStats;
segmentation.segments[segmentIndex] = updatedSegment;
}
// all available segmentations
const segmentations = segmentationService.getSegmentations();
const tmtv = await commandsManager.run('calculateTMTV', { segmentations });
triggerEvent(eventTarget, Enums.Events.WEB_WORKER_PROGRESS, {
progress: 100,
type: 'Calculate Lesion Stats',
id: segmentationId,
});
// add the tmtv to all the segment cachedStats, although it is a global
// value but we don't have any other way to display it for now
// Update all segmentations with the calculated TMTV
segmentations.forEach(segmentation => {
segmentation.cachedStats = {
...segmentation.cachedStats,
tmtv,
};
// Update each segment within the segmentation
Object.keys(segmentation.segments).forEach(segmentIndex => {
segmentation.segments[segmentIndex].cachedStats = {
...segmentation.segments[segmentIndex].cachedStats,
tmtv,
};
});
// Update the segmentation object
const updatedSegmentation: Segmentation = {
...segmentation,
segments: {
...segmentation.segments,
},
};
segmentationService.addOrUpdateSegmentation(updatedSegmentation);
});
};

View File

@@ -0,0 +1,494 @@
// Common sync group configurations
const cameraPositionSync = (id: string) => ({
type: 'cameraPosition',
id,
source: true,
target: true,
});
const hydrateSegSync = {
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
};
const ctAXIAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
cameraPositionSync('axialSync'),
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ctSAGITTAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ctSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'ctToolGroup',
syncGroups: [
cameraPositionSync('sagittalSync'),
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ctCORONAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ctCORONAL',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'ctToolGroup',
syncGroups: [
cameraPositionSync('coronalSync'),
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ptAXIAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ptAXIAL',
viewportType: 'volume',
background: [1, 1, 1],
orientation: 'axial',
toolGroupId: 'ptToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
cameraPositionSync('axialSync'),
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
options: {
voi: {
custom: 'getPTVOIRange',
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const ptSAGITTAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ptSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
cameraPositionSync('sagittalSync'),
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
options: {
voi: {
custom: 'getPTVOIRange',
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const ptCORONAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'ptCORONAL',
viewportType: 'volume',
orientation: 'coronal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
cameraPositionSync('coronalSync'),
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
options: {
voi: {
custom: 'getPTVOIRange',
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const fusionAXIAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'fusionAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'fusionToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
cameraPositionSync('axialSync'),
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
id: 'ptDisplaySet',
options: {
colormap: {
name: 'hsv',
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.1, opacity: 0.8 },
{ value: 1, opacity: 0.9 },
],
},
voi: {
custom: 'getPTVOIRange',
},
},
},
],
};
const fusionSAGITTAL = {
viewportOptions: {
viewportId: 'fusionSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
cameraPositionSync('sagittalSync'),
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
id: 'ptDisplaySet',
options: {
colormap: {
name: 'hsv',
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.1, opacity: 0.8 },
{ value: 1, opacity: 0.9 },
],
},
voi: {
custom: 'getPTVOIRange',
},
},
},
],
};
const fusionCORONAL = {
viewportOptions: {
viewportId: 'fusionCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
cameraPositionSync('coronalSync'),
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
id: 'ptDisplaySet',
options: {
colormap: {
name: 'hsv',
opacity: [
{ value: 0, opacity: 0 },
{ value: 0.1, opacity: 0.8 },
{ value: 1, opacity: 0.9 },
],
},
voi: {
custom: 'getPTVOIRange',
},
},
},
],
};
const mipSAGITTAL: AppTypes.HangingProtocol.Viewport = {
viewportOptions: {
viewportId: 'mipSagittal',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'mipToolGroup',
syncGroups: [
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
options: {
syncColormap: true,
},
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
options: {
syncColormap: false,
syncInvertState: false,
},
},
hydrateSegSync,
],
// Custom props can be used to set custom properties which extensions
// can react on.
customViewportProps: {
// We use viewportDisplay to filter the viewports which are displayed
// in mip and we set the scrollbar according to their rotation index
// in the cornerstone extension.
hideOverlays: true,
},
},
displaySets: [
{
options: {
blendMode: 'MIP',
slabThickness: 'fullVolume',
voi: {
custom: 'getPTVOIRange',
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
export {
ctAXIAL,
ctSAGITTAL,
ctCORONAL,
ptAXIAL,
ptSAGITTAL,
ptCORONAL,
fusionAXIAL,
fusionSAGITTAL,
fusionCORONAL,
mipSAGITTAL,
};

View File

@@ -0,0 +1,67 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone';
const CircleROIStartEndThreshold = {
toAnnotation: (measurement, definition) => {},
/**
* Maps cornerstone annotation event data to measurement service format.
*
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
toMeasurement: (csToolsEventDetail, displaySetService, cornerstoneViewportService) => {
const { annotation, viewportId } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
if (!metadata || !data) {
console.warn('Length tool: Missing metadata or data');
return null;
}
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
const validToolType = SUPPORTED_TOOLS.includes(toolName);
if (!validToolType) {
throw new Error('Tool not supported');
}
const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } = getSOPInstanceAttributes(
referencedImageId,
cornerstoneViewportService,
viewportId
);
let displaySet;
if (SOPInstanceUID) {
displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
}
const { cachedStats } = data;
return {
uid: annotationUID,
SOPInstanceUID,
FrameOfReferenceUID,
// points,
metadata,
referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID,
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: metadata.label,
// displayText: displayText,
data: data.cachedStats,
type: 'CircleROIStartEndThreshold',
// getReport,
};
},
};
export default CircleROIStartEndThreshold;

View File

@@ -0,0 +1,63 @@
import SUPPORTED_TOOLS from './constants/supportedTools';
import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone';
const RectangleROIStartEndThreshold = {
toAnnotation: (measurement, definition) => {},
/**
* Maps cornerstone annotation event data to measurement service format.
*
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
toMeasurement: (csToolsEventDetail, displaySetService, cornerstoneViewportService) => {
const { annotation, viewportId } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
if (!metadata || !data) {
console.warn('Length tool: Missing metadata or data');
return null;
}
const { toolName, referencedImageId, FrameOfReferenceUID } = metadata;
const validToolType = SUPPORTED_TOOLS.includes(toolName);
if (!validToolType) {
throw new Error('Tool not supported');
}
const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } = getSOPInstanceAttributes(
referencedImageId,
cornerstoneViewportService,
viewportId
);
let displaySet;
if (SOPInstanceUID) {
displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
} else {
displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID);
}
return {
uid: annotationUID,
SOPInstanceUID,
FrameOfReferenceUID,
// points,
metadata,
referenceSeriesUID: SeriesInstanceUID,
referenceStudyUID: StudyInstanceUID,
toolName: metadata.toolName,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
label: metadata.label,
data: data.cachedStats,
type: 'RectangleROIStartEndThreshold',
};
},
};
export default RectangleROIStartEndThreshold;

View File

@@ -0,0 +1 @@
export default ['RectangleROIStartEndThreshold'];

View File

@@ -0,0 +1,41 @@
import RectangleROIStartEndThreshold from './RectangleROIStartEndThreshold';
import CircleROIStartEndThreshold from './CircleROIStartEndThreshold';
const measurementServiceMappingsFactory = (
measurementService,
displaySetService,
cornerstoneViewportService
) => {
return {
RectangleROIStartEndThreshold: {
toAnnotation: RectangleROIStartEndThreshold.toAnnotation,
toMeasurement: csToolsAnnotation =>
RectangleROIStartEndThreshold.toMeasurement(
csToolsAnnotation,
displaySetService,
cornerstoneViewportService
),
matchingCriteria: [
{
valueType: measurementService.VALUE_TYPES.ROI_THRESHOLD_MANUAL,
},
],
},
CircleROIStartEndThreshold: {
toAnnotation: CircleROIStartEndThreshold.toAnnotation,
toMeasurement: csToolsAnnotation =>
CircleROIStartEndThreshold.toMeasurement(
csToolsAnnotation,
displaySetService,
cornerstoneViewportService
),
matchingCriteria: [
{
valueType: measurementService.VALUE_TYPES.ROI_THRESHOLD_MANUAL,
},
],
},
};
};
export default measurementServiceMappingsFactory;