OHIF with Cornerstone3D 2.0 (#4347)

This commit is contained in:
Alireza authored and GitHub committed 2024-11-06 07:15:27 -05:00
1 parent 8981a61b8c
commit 7ce7127d7c
803 files changed
+32760 -15893

No files matched your search

@@ -1,10 +0,0 @@
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
CREATE_REPORT: 1,
ADD_SERIES: 2,
SET_STUDY_AND_SERIES: 3,
NO_NOT_FOR_SERIES: 4,
};
export default RESPONSE;
@@ -1,10 +0,0 @@
const MIN_SR_SERIES_NUMBER = 4700;
export default function getNextSRSeriesNumber(displaySetService) {
const activeDisplaySets = displaySetService.getActiveDisplaySets();
const srDisplaySets = activeDisplaySets.filter(ds => ds.Modality === 'SR');
const srSeriesNumbers = srDisplaySets.map(ds => ds.SeriesNumber);
const maxSeriesNumber = Math.max(...srSeriesNumbers, MIN_SR_SERIES_NUMBER);
return maxSeriesNumber + 1;
}
@@ -1,17 +1,16 @@
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Machine } from 'xstate';
import { useMachine } from '@xstate/react';
import { useViewportGrid } from '@ohif/ui';
import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default';
import { machineConfiguration, defaultOptions, RESPONSE } from './measurementTrackingMachine';
import promptBeginTracking from './promptBeginTracking';
import promptTrackNewSeries from './promptTrackNewSeries';
import promptTrackNewStudy from './promptTrackNewStudy';
import promptSaveReport from './promptSaveReport';
import promptHydrateStructuredReport from './promptHydrateStructuredReport';
import hydrateStructuredReport from './hydrateStructuredReport';
import { useAppConfig } from '@state';
import promptLabelAnnotation from './promptLabelAnnotation';
const TrackedMeasurementsContext = React.createContext();
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
@@ -96,17 +95,18 @@ function TrackedMeasurementsContextProvider(
const trackedMeasurement = trackedMeasurements[0];
const referencedDisplaySetUID = trackedMeasurement.displaySetInstanceUID;
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
const imageIndex = viewport.getCurrentImageIdIndex();
// update the previously stored positionPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId: activeViewportId,
displaySetInstanceUID: referencedDisplaySetUID,
});
viewportGridService.setDisplaySetsForViewport({
viewportId: activeViewportId,
displaySetInstanceUIDs: [referencedDisplaySetUID],
viewportOptions: {
initialImageOptions: {
index: imageIndex,
},
},
});
},
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
@@ -196,7 +196,9 @@ function TrackedMeasurementsContextProvider(
// - Fix viewport border resize
// - created/destroyed hooks for extensions (cornerstone measurement subscriptions in it's `init`)
const measurementTrackingMachine = Machine(machineConfiguration, machineOptions);
const measurementTrackingMachine = useMemo(() => {
return Machine(machineConfiguration, machineOptions);
}, []); // Empty dependency array ensures this is only created once
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useMachine(
measurementTrackingMachine
@@ -1,4 +1,3 @@
import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
import { assign } from 'xstate';
const RESPONSE = {
@@ -1,33 +0,0 @@
function promptLabelAnnotation({ servicesManager, extensionManager }, ctx, evt) {
const { measurementService, customizationService } = servicesManager.services;
const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId } = evt;
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { showLabelAnnotationPopup } = utilityModule.exports;
return new Promise(async function (resolve) {
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(measurementId);
const value = await showLabelAnnotationPopup(
measurement,
servicesManager.services.uiDialogService,
labelConfig
);
measurementService.update(
measurementId,
{
...value,
},
true
);
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
});
});
}
export default promptLabelAnnotation;
@@ -1,74 +0,0 @@
import { createReportAsync, createReportDialogPrompt } from '@ohif/extension-default';
import getNextSRSeriesNumber from '../../_shared/getNextSRSeriesNumber';
import RESPONSE from '../../_shared/PROMPT_RESPONSES';
async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) {
const { uiDialogService, measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId;
const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const StudyInstanceUID = evt?.data?.StudyInstanceUID;
const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const { trackedStudy, trackedSeries } = ctx;
let displaySetInstanceUIDs;
try {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action === RESPONSE.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements
.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
)
.filter(m => m.referencedImageId != null);
const SeriesDescription =
// isUndefinedOrEmpty
promptResult.value === undefined || promptResult.value === ''
? 'Research Derived Series' // default
: promptResult.value; // provided value
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
const getReport = async () => {
return commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport,
});
} else if (promptResult.action === RESPONSE.CANCEL) {
// Do nothing
}
return {
userResponse: promptResult.action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
isBackupSave,
};
} catch (error) {
return null;
}
}
export default promptSaveReport;
@@ -24,7 +24,6 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }):
/>
),
},
{
name: 'trackedMeasurements',
iconName: 'tab-linear',
@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { PanelMeasurement } from '@ohif/extension-cornerstone';
import { useViewportGrid } from '@ohif/ui';
import { StudySummary } from '@ohif/ui-next';
import { Button, Icons } from '@ohif/ui-next';
import { DicomMetadataStore, utils } from '@ohif/core';
import { useTrackedMeasurements } from '../getContextModule';
import { useTranslation } from 'react-i18next';
const { downloadCSVReport, formatDate } = utils;
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
key: undefined, //
date: '', // '07-Sep-2010',
modality: '', // 'CT',
description: '', // 'CHEST/ABD/PELVIS W CONTRAST',
};
function PanelMeasurementTableTracking({
servicesManager,
extensionManager,
commandsManager,
}: withAppTypes) {
const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable');
const { measurementService, customizationService } = servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState(
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
);
useEffect(() => {
const updateDisplayStudySummary = async () => {
if (trackedMeasurements.matches('tracking') && trackedStudy) {
const studyMeta = DicomMetadataStore.getStudy(trackedStudy);
if (!studyMeta || !studyMeta.series || studyMeta.series.length === 0) {
console.debug('Study metadata not available');
return;
}
const instanceMeta = studyMeta.series[0].instances[0];
const { StudyDate, StudyDescription } = instanceMeta;
const modalities = new Set();
studyMeta.series.forEach(series => {
if (trackedSeries.includes(series.SeriesInstanceUID)) {
modalities.add(series.instances[0].Modality);
}
});
const modality = Array.from(modalities).join('/');
setDisplayStudySummary(prevSummary => {
if (prevSummary.key !== trackedStudy) {
return {
key: trackedStudy,
date: StudyDate,
modality,
description: StudyDescription,
};
}
return prevSummary;
});
} else if (!trackedStudy) {
setDisplayStudySummary(DISPLAY_STUDY_SUMMARY_INITIAL_VALUE);
}
};
updateDisplayStudySummary();
}, [trackedMeasurements, trackedStudy, trackedSeries]);
const { disableEditing } = customizationService.getCustomization(
'PanelMeasurement.disableEditing',
{
id: 'default.disableEditing',
disableEditing: false,
}
);
return (
<>
{displayStudySummary.key && (
<StudySummary
date={formatDate(displayStudySummary.date)}
description={displayStudySummary.description}
/>
)}
<PanelMeasurement
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
measurementFilter={measurement =>
trackedStudy === measurement.referenceStudyUID &&
trackedSeries.includes(measurement.referenceSeriesUID)
}
customHeader={({ additionalFindings, measurements }) => {
const disabled = additionalFindings.length === 0 && measurements.length === 0;
if (disableEditing || disabled) {
return null;
}
return (
<div className="bg-background flex h-9 w-full items-center rounded pr-0.5">
<div className="flex space-x-1">
<Button
size="sm"
variant="ghost"
className="pl-1.5"
onClick={() => {
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m =>
trackedStudy === m.referenceStudyUID &&
trackedSeries.includes(m.referenceSeriesUID)
);
downloadCSVReport(trackedMeasurements);
}}
>
<Icons.Download />
<span className="pl-1">CSV</span>
</Button>
<Button
size="sm"
variant="ghost"
className="pl-0.5"
onClick={() => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
});
}}
>
<Icons.Add />
Create DICOM SR
</Button>
</div>
</div>
);
}}
></PanelMeasurement>
</>
);
}
export default PanelMeasurementTableTracking;
@@ -1,314 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { StudySummary, MeasurementTable, useViewportGrid, ActionButtons } from '@ohif/ui';
import { DicomMetadataStore, utils } from '@ohif/core';
import { useDebounce } from '@hooks';
import { useAppConfig } from '@state';
import debounce from 'lodash.debounce';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
import { useTrackedMeasurements } from '../../getContextModule';
const { downloadCSVReport } = utils;
const { formatDate } = utils;
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
key: undefined, //
date: '', // '07-Sep-2010',
modality: '', // 'CT',
description: '', // 'CHEST/ABD/PELVIS W CONTRAST',
};
function PanelMeasurementTableTracking({ servicesManager, extensionManager }: withAppTypes) {
const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable');
const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(Date.now().toString());
const debouncedMeasurementChangeTimestamp = useDebounce(measurementChangeTimestamp, 200);
const { measurementService, uiDialogService, displaySetService, customizationService } =
servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState(
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
);
const [displayMeasurements, setDisplayMeasurements] = useState([]);
const measurementsPanelRef = useRef(null);
const [appConfig] = useAppConfig();
useEffect(() => {
const measurements = measurementService.getMeasurements();
const mappedMeasurements = measurements.map(m =>
_mapMeasurementToDisplay(m, displaySetService)
);
setDisplayMeasurements(mappedMeasurements);
// eslint-ignore-next-line
}, [
measurementService,
displaySetService,
trackedStudy,
trackedSeries,
debouncedMeasurementChangeTimestamp,
]);
// ~~ DisplayStudySummary
useEffect(() => {
const updateDisplayStudySummary = async () => {
if (trackedMeasurements.matches('tracking')) {
const StudyInstanceUID = trackedStudy;
const studyMeta = DicomMetadataStore.getStudy(StudyInstanceUID);
const instanceMeta = studyMeta.series[0].instances[0];
const { StudyDate, StudyDescription } = instanceMeta;
const modalities = new Set();
studyMeta.series.forEach(series => {
if (trackedSeries.includes(series.SeriesInstanceUID)) {
modalities.add(series.instances[0].Modality);
}
});
const modality = Array.from(modalities).join('/');
if (displayStudySummary.key !== StudyInstanceUID) {
setDisplayStudySummary({
key: StudyInstanceUID,
date: StudyDate, // TODO: Format: '07-Sep-2010'
modality,
description: StudyDescription,
});
}
} else if (trackedStudy === '' || trackedStudy === undefined) {
setDisplayStudySummary(DISPLAY_STUDY_SUMMARY_INITIAL_VALUE);
}
};
updateDisplayStudySummary();
}, [displayStudySummary.key, trackedSeries, trackedMeasurements, trackedStudy]);
// TODO: Better way to consolidated, debounce, check on change?
// Are we exposing the right API for measurementService?
// This watches for ALL measurementService changes. It updates a timestamp,
// which is debounced. After a brief period of inactivity, this triggers
// a re-render where we grab up-to-date measurements
useEffect(() => {
const added = measurementService.EVENTS.MEASUREMENT_ADDED;
const addedRaw = measurementService.EVENTS.RAW_MEASUREMENT_ADDED;
const updated = measurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = measurementService.EVENTS.MEASUREMENT_REMOVED;
const cleared = measurementService.EVENTS.MEASUREMENTS_CLEARED;
const subscriptions = [];
[added, addedRaw, updated, removed, cleared].forEach(evt => {
subscriptions.push(
measurementService.subscribe(evt, () => {
setMeasurementsUpdated(Date.now().toString());
if (evt === added) {
debounce(() => {
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight;
}, 300)();
}
}).unsubscribe
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, [measurementService, sendTrackedMeasurementsEvent]);
async function exportReport() {
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
);
downloadCSVReport(trackedMeasurements);
}
const jumpToImage = ({ uid, isActive }) => {
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid);
onMeasurementItemClickHandler({ uid, isActive });
};
const onMeasurementItemEditHandler = ({ uid, isActive }) => {
jumpToImage({ uid, isActive });
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(uid);
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { showLabelAnnotationPopup } = utilityModule.exports;
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(
(val: Map<any, any>) => {
measurementService.update(
uid,
{
...val,
},
true
);
}
);
};
const onMeasurementItemClickHandler = ({ uid, isActive }) => {
if (!isActive) {
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
setDisplayMeasurements(measurements);
}
};
const displayMeasurementsWithoutFindings = displayMeasurements.filter(
dm =>
dm.measurementType !== measurementService.VALUE_TYPES.POINT &&
dm.referencedImageId &&
trackedStudy === dm.referenceStudyUID &&
trackedSeries.includes(dm.referenceSeriesUID)
);
const additionalFindings = displayMeasurements.filter(
dm =>
dm.measurementType === measurementService.VALUE_TYPES.POINT &&
dm.referencedImageId &&
trackedStudy === dm.referenceStudyUID &&
trackedSeries.includes(dm.referenceSeriesUID)
);
const nonAcquisitionMeasurements = displayMeasurements.filter(
dm =>
dm.referencedImageId == null ||
trackedStudy !== dm.referenceStudyUID ||
trackedSeries.includes(dm.referenceSeriesUID)
);
const disabled =
additionalFindings.length === 0 &&
displayMeasurementsWithoutFindings.length === 0 &&
nonAcquisitionMeasurements.length === 0;
return (
<>
<div
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
ref={measurementsPanelRef}
data-cy={'trackedMeasurements-panel'}
>
{displayStudySummary.key && (
<StudySummary
date={formatDate(displayStudySummary.date)}
modality={displayStudySummary.modality}
description={displayStudySummary.description}
/>
)}
<MeasurementTable
title="Measurements"
data={displayMeasurementsWithoutFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
{additionalFindings.length !== 0 && (
<MeasurementTable
title="Additional Findings"
data={additionalFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
)}
{nonAcquisitionMeasurements.length !== 0 && (
<MeasurementTable
title="Non-tracked"
data={nonAcquisitionMeasurements}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
)}
</div>
{!appConfig?.disableEditing && (
<div className="flex justify-center p-4">
<ActionButtons
t={t}
actions={[
{
label: 'Download CSV',
onClick: exportReport,
},
{
label: 'Create Report',
onClick: () => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
});
},
},
]}
disabled={disabled}
/>
</div>
)}
</>
);
}
PanelMeasurementTableTracking.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
measurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
// TODO: This could be a measurementService mapper
function _mapMeasurementToDisplay(measurement, displaySetService) {
const { referenceSeriesUID } = measurement;
// TODO: We don't deal with multiframe well yet, would need to update
// This in OHIF-312 when we add FrameIndex to measurements
const {
displayText: baseDisplayText,
uid,
label: baseLabel,
type,
selected,
findingSites,
finding,
referencedImageId,
} = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
let displayText = baseDisplayText || [];
if (findingSites) {
const siteText = [];
findingSites.forEach(site => {
if (site?.text !== label) {
siteText.push(site.text);
}
});
displayText = [...siteText, ...displayText];
}
if (finding && finding?.text !== label) {
displayText = [finding.text, ...displayText];
}
return {
uid,
label,
baseLabel,
measurementType: type,
displayText,
baseDisplayText,
isActive: selected,
finding,
findingSites,
referencedImageId,
};
}
export default PanelMeasurementTableTracking;
@@ -12,7 +12,17 @@ import { PanelStudyBrowserTrackingHeader } from './PanelStudyBrowserTrackingHead
import { defaultActionIcons, defaultViewPresets } from './constants';
const { formatDate, createStudyBrowserTabs } = utils;
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',
'DOC',
'OT',
'PMAP',
];
/**
*
* @param {*} param0
@@ -94,7 +104,7 @@ function PanelStudyBrowserTracking({
title: 'Thumbnail Double Click',
message:
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'info',
type: 'error',
duration: 3000,
});
}
@@ -171,7 +181,11 @@ function PanelStudyBrowserTracking({
return;
}
const currentDisplaySets = displaySetService.activeDisplaySets;
let currentDisplaySets = displaySetService.activeDisplaySets;
// filter non based on the list of modalities that are supported by cornerstone
currentDisplaySets = currentDisplaySets.filter(
ds => !thumbnailNoImageModalities.includes(ds.Modality)
);
if (!currentDisplaySets.length) {
return;
@@ -677,18 +691,6 @@ function _mapDisplaySets(
return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets];
}
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',
'DOC',
'OT',
'PMAP',
];
function _getComponentType(ds) {
if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) {
return 'thumbnailNoImage';
@@ -8,7 +8,9 @@ import { useTrackedMeasurements } from './../getContextModule';
import { BaseVolumeViewport, Enums } from '@cornerstonejs/core';
import { useTranslation } from 'react-i18next';
function TrackedCornerstoneViewport(props: withAppTypes) {
function TrackedCornerstoneViewport(
props: withAppTypes<{ viewportId: string; displaySets: AppTypes.DisplaySet[] }>
) {
const { displaySets, viewportId, servicesManager, extensionManager } = props;
const {
@@ -193,7 +195,7 @@ function TrackedCornerstoneViewport(props: withAppTypes) {
viewportId === activeViewportId
);
viewportActionCornersService.setComponents([
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
@@ -305,7 +307,7 @@ const _getArrowsComponent = (isTracked, switchMeasurement, isActiveViewport) =>
return (
<ViewportActionArrows
onArrowsClick={direction => switchMeasurement(direction)}
className={isActiveViewport ? 'visible' : 'invisible group-hover:visible'}
className={isActiveViewport ? 'visible' : 'invisible group-hover/pane:visible'}
></ViewportActionArrows>
);
};