feat: cornerstone3D stack and volume viewports (#2787)

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

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

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

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

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

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

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

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

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

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

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

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

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

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

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

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

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

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

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

* update yarn lock

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

* update pathnames to match v3-stable

* update the e2e pathname

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

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

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

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

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

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

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

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

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

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

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

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

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

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

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

* fix: bump cornerstone to fix magnify tool

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

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

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

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

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

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

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

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

No files matched your search

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