init
This commit is contained in:
144
extensions/cornerstone-dicom-sr/src/commandsModule.ts
Normal file
144
extensions/cornerstone-dicom-sr/src/commandsModule.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { metaData, utilities } from '@cornerstonejs/core';
|
||||
|
||||
import OHIF, { DicomMetadataStore } from '@ohif/core';
|
||||
import dcmjs from 'dcmjs';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
|
||||
|
||||
const { MeasurementReport } = adaptersSR.Cornerstone3D;
|
||||
const { log } = OHIF;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param measurementData An array of measurements from the measurements service
|
||||
* that you wish to serialize.
|
||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
*
|
||||
*/
|
||||
const _generateReport = (measurementData, additionalFindingTypes, options = {}) => {
|
||||
const filteredToolState = getFilteredCornerstoneToolState(
|
||||
measurementData,
|
||||
additionalFindingTypes
|
||||
);
|
||||
|
||||
const report = MeasurementReport.generateReport(
|
||||
filteredToolState,
|
||||
metaData,
|
||||
utilities.worldToImageCoords,
|
||||
options
|
||||
);
|
||||
|
||||
const { dataset } = report;
|
||||
|
||||
// Set the default character set as UTF-8
|
||||
// https://dicom.innolitics.com/ciods/nm-image/sop-common/00080005
|
||||
if (typeof dataset.SpecificCharacterSet === 'undefined') {
|
||||
dataset.SpecificCharacterSet = 'ISO_IR 192';
|
||||
}
|
||||
return dataset;
|
||||
};
|
||||
|
||||
const commandsModule = (props: withAppTypes) => {
|
||||
const { servicesManager } = props;
|
||||
const { customizationService } = servicesManager.services;
|
||||
const actions = {
|
||||
/**
|
||||
*
|
||||
* @param measurementData An array of measurements from the measurements service
|
||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* as opposed to Finding Sites.
|
||||
* that you wish to serialize.
|
||||
*/
|
||||
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
|
||||
const srDataset = actions.generateReport(measurementData, additionalFindingTypes, options);
|
||||
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
|
||||
|
||||
//Create a URL for the binary.
|
||||
const objectUrl = URL.createObjectURL(reportBlob);
|
||||
window.location.assign(objectUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param measurementData An array of measurements from the measurements service
|
||||
* that you wish to serialize.
|
||||
* @param dataSource The dataSource that you wish to use to persist the data.
|
||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* @return The naturalized report
|
||||
*/
|
||||
storeMeasurements: async ({
|
||||
measurementData,
|
||||
dataSource,
|
||||
additionalFindingTypes,
|
||||
options = {},
|
||||
}) => {
|
||||
// Use the @cornerstonejs adapter for converting to/from DICOM
|
||||
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
||||
log.info('[DICOMSR] storeMeasurements');
|
||||
|
||||
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
|
||||
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
|
||||
return Promise.reject({});
|
||||
}
|
||||
|
||||
try {
|
||||
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
|
||||
|
||||
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
|
||||
// The content sequence has 5 or more elements, of which
|
||||
// the `[4]` element contains the annotation data, so this is
|
||||
// checking that there is some annotation data present.
|
||||
if (!ContentSequence?.[4].ContentSequence?.length) {
|
||||
console.log('naturalizedReport missing imaging content', naturalizedReport);
|
||||
throw new Error('Invalid report, no content');
|
||||
}
|
||||
|
||||
const onBeforeDicomStore =
|
||||
customizationService.getModeCustomization('onBeforeDicomStore')?.value;
|
||||
|
||||
let dicomDict;
|
||||
if (typeof onBeforeDicomStore === 'function') {
|
||||
dicomDict = onBeforeDicomStore({ measurementData, naturalizedReport });
|
||||
}
|
||||
|
||||
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
|
||||
|
||||
if (StudyInstanceUID) {
|
||||
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
||||
}
|
||||
|
||||
// The "Mode" route listens for DicomMetadataStore changes
|
||||
// When a new instance is added, it listens and
|
||||
// automatically calls makeDisplaySets
|
||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
||||
|
||||
return naturalizedReport;
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`);
|
||||
throw new Error(error.message || 'Error while saving the measurements.');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
downloadReport: {
|
||||
commandFn: actions.downloadReport,
|
||||
},
|
||||
storeMeasurements: {
|
||||
commandFn: actions.storeMeasurements,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'CORNERSTONE_STRUCTURED_REPORT',
|
||||
};
|
||||
};
|
||||
|
||||
export default commandsModule;
|
||||
@@ -0,0 +1,78 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { OHIFCornerstoneSRContentItem } from './OHIFCornerstoneSRContentItem';
|
||||
|
||||
export function OHIFCornerstoneSRContainer(props) {
|
||||
const { container, nodeIndexesTree = [0], containerNumberedTree = [1] } = props;
|
||||
const { ContinuityOfContent, ConceptNameCodeSequence } = container;
|
||||
const { CodeMeaning } = ConceptNameCodeSequence ?? {};
|
||||
let childContainerIndex = 1;
|
||||
const contentItems = container.ContentSequence?.map((contentItem, i) => {
|
||||
const { ValueType } = contentItem;
|
||||
const childNodeLevel = [...nodeIndexesTree, i];
|
||||
const key = childNodeLevel.join('.');
|
||||
|
||||
let Component;
|
||||
let componentProps;
|
||||
|
||||
if (ValueType === 'CONTAINER') {
|
||||
const childContainerNumberedTree = [...containerNumberedTree, childContainerIndex++];
|
||||
|
||||
Component = OHIFCornerstoneSRContainer;
|
||||
componentProps = {
|
||||
container: contentItem,
|
||||
nodeIndexesTree: childNodeLevel,
|
||||
containerNumberedTree: childContainerNumberedTree,
|
||||
};
|
||||
} else {
|
||||
Component = OHIFCornerstoneSRContentItem;
|
||||
componentProps = {
|
||||
contentItem,
|
||||
nodeIndexesTree: childNodeLevel,
|
||||
continuityOfContent: ContinuityOfContent,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
key={key}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-bold">
|
||||
{containerNumberedTree.join('.')}.
|
||||
{CodeMeaning}
|
||||
</div>
|
||||
<div className="ml-4 mb-2">{contentItems}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OHIFCornerstoneSRContainer.propTypes = {
|
||||
/**
|
||||
* A tree node that may contain another container or one or more content items
|
||||
* (text, code, uidref, pname, etc.)
|
||||
*/
|
||||
container: PropTypes.object,
|
||||
/**
|
||||
* A 0-based index list
|
||||
*/
|
||||
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
|
||||
/**
|
||||
* A 1-based index list that represents a container in a multi-level numbered
|
||||
* list (tree).
|
||||
*
|
||||
* Example:
|
||||
* 1. History
|
||||
* 1.1. Chief Complaint
|
||||
* 1.2. Present Illness
|
||||
* 1.3. Past History
|
||||
* 1.4. Family History
|
||||
* 2. Findings
|
||||
* */
|
||||
containerNumberedTree: PropTypes.arrayOf(PropTypes.number),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { CodeNameCodeSequenceValues } from '../enums';
|
||||
import formatContentItemValue from '../utils/formatContentItem';
|
||||
|
||||
const EMPTY_TAG_VALUE = '[empty]';
|
||||
|
||||
function OHIFCornerstoneSRContentItem(props) {
|
||||
const { contentItem, nodeIndexesTree, continuityOfContent } = props;
|
||||
const { ConceptNameCodeSequence } = contentItem;
|
||||
const { CodeValue, CodeMeaning } = ConceptNameCodeSequence;
|
||||
const isChildFirstNode = nodeIndexesTree[nodeIndexesTree.length - 1] === 0;
|
||||
const formattedValue = formatContentItemValue(contentItem) ?? EMPTY_TAG_VALUE;
|
||||
const startWithAlphaNumCharRegEx = /^[a-zA-Z0-9]/;
|
||||
const isContinuous = continuityOfContent === 'CONTINUOUS';
|
||||
const isFinding = CodeValue === CodeNameCodeSequenceValues.Finding;
|
||||
const addExtraSpace =
|
||||
isContinuous && !isChildFirstNode && startWithAlphaNumCharRegEx.test(formattedValue?.[0]);
|
||||
|
||||
// Collapse sequences of white space preserving newline characters
|
||||
let className = 'whitespace-pre-line';
|
||||
|
||||
if (CodeValue === CodeNameCodeSequenceValues.Finding) {
|
||||
// Preserve spaces because it is common to see tabular text in a
|
||||
// "Findings" ConceptNameCodeSequence
|
||||
className = 'whitespace-pre-wrap';
|
||||
}
|
||||
|
||||
if (isContinuous) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={className}
|
||||
title={CodeMeaning}
|
||||
>
|
||||
{addExtraSpace ? ' ' : ''}
|
||||
{formattedValue}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold">{CodeMeaning}: </span>
|
||||
{isFinding ? (
|
||||
<pre>{formattedValue}</pre>
|
||||
) : (
|
||||
<span className={className}>{formattedValue}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OHIFCornerstoneSRContentItem.propTypes = {
|
||||
contentItem: PropTypes.object,
|
||||
nodeIndexesTree: PropTypes.arrayOf(PropTypes.number),
|
||||
continuityOfContent: PropTypes.string,
|
||||
};
|
||||
|
||||
export { OHIFCornerstoneSRContentItem };
|
||||
@@ -0,0 +1,532 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExtensionManager } from '@ohif/core';
|
||||
|
||||
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
|
||||
|
||||
import { Icon, Tooltip, useViewportGrid, ViewportActionArrows } from '@ohif/ui';
|
||||
import hydrateStructuredReport from '../utils/hydrateStructuredReport';
|
||||
import { useAppConfig } from '@state';
|
||||
import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet';
|
||||
|
||||
const MEASUREMENT_TRACKING_EXTENSION_ID = '@ohif/extension-measurement-tracking';
|
||||
|
||||
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
|
||||
|
||||
function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) {
|
||||
const {
|
||||
commandsManager,
|
||||
children,
|
||||
dataSource,
|
||||
displaySets,
|
||||
viewportOptions,
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
} = props;
|
||||
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
cornerstoneViewportService,
|
||||
measurementService,
|
||||
viewportActionCornersService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const viewportId = viewportOptions.viewportId;
|
||||
|
||||
// SR viewport will always have a single display set
|
||||
if (displaySets.length > 1) {
|
||||
throw new Error('SR viewport should only have a single display set');
|
||||
}
|
||||
|
||||
const srDisplaySet = displaySets[0];
|
||||
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
const [measurementSelected, setMeasurementSelected] = useState(0);
|
||||
const [measurementCount, setMeasurementCount] = useState(1);
|
||||
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(null);
|
||||
const [referencedDisplaySetMetadata, setReferencedDisplaySetMetadata] = useState(null);
|
||||
const [element, setElement] = useState(null);
|
||||
const { viewports, activeViewportId } = viewportGrid;
|
||||
|
||||
const { t } = useTranslation('Common');
|
||||
|
||||
// Optional hook into tracking extension, if present.
|
||||
let trackedMeasurements;
|
||||
let sendTrackedMeasurementsEvent;
|
||||
|
||||
const hasMeasurementTrackingExtension = extensionManager.registeredExtensionIds.includes(
|
||||
MEASUREMENT_TRACKING_EXTENSION_ID
|
||||
);
|
||||
|
||||
if (hasMeasurementTrackingExtension) {
|
||||
const contextModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-measurement-tracking.contextModule.TrackedMeasurementsContext'
|
||||
);
|
||||
|
||||
const tracked = useContext(contextModule.context);
|
||||
trackedMeasurements = tracked?.[0];
|
||||
sendTrackedMeasurementsEvent = tracked?.[1];
|
||||
}
|
||||
|
||||
if (!sendTrackedMeasurementsEvent) {
|
||||
// if no panels from measurement-tracking extension is used, this code will run
|
||||
trackedMeasurements = null;
|
||||
sendTrackedMeasurementsEvent = (eventName, { displaySetInstanceUID }) => {
|
||||
measurementService.clearMeasurements();
|
||||
const { SeriesInstanceUIDs } = hydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, appConfig },
|
||||
displaySetInstanceUID
|
||||
);
|
||||
|
||||
const displaySets = displaySetService.getDisplaySetsForSeries(SeriesInstanceUIDs[0]);
|
||||
if (displaySets.length) {
|
||||
viewportGridService.setDisplaySetsForViewports([
|
||||
{
|
||||
viewportId: activeViewportId,
|
||||
displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID],
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo: what is this, not sure what it does regarding the react aspect,
|
||||
* it is updating a local variable? which is not state.
|
||||
*/
|
||||
const [isLocked, setIsLocked] = useState(trackedMeasurements?.context?.trackedSeries?.length > 0);
|
||||
/**
|
||||
* Store the tracking identifiers per viewport in order to be able to
|
||||
* show the SR measurements on the referenced image on the correct viewport,
|
||||
* when multiple viewports are used.
|
||||
*/
|
||||
const setTrackingIdentifiers = useCallback(
|
||||
measurementSelected => {
|
||||
const { measurements } = srDisplaySet;
|
||||
|
||||
setTrackingUniqueIdentifiersForElement(
|
||||
element,
|
||||
measurements.map(measurement => measurement.TrackingUniqueIdentifier),
|
||||
measurementSelected
|
||||
);
|
||||
},
|
||||
[element, measurementSelected, srDisplaySet]
|
||||
);
|
||||
|
||||
/**
|
||||
* OnElementEnabled callback which is called after the cornerstoneExtension
|
||||
* has enabled the element. Note: we delegate all the image rendering to
|
||||
* cornerstoneExtension, so we don't need to do anything here regarding
|
||||
* the image rendering, element enabling etc.
|
||||
*/
|
||||
const onElementEnabled = evt => {
|
||||
setElement(evt.detail.element);
|
||||
};
|
||||
|
||||
const updateViewport = useCallback(
|
||||
newMeasurementSelected => {
|
||||
const { StudyInstanceUID, displaySetInstanceUID, sopClassUids } = srDisplaySet;
|
||||
|
||||
if (!StudyInstanceUID || !displaySetInstanceUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sopClassUids && sopClassUids.length > 1) {
|
||||
// Todo: what happens if there are multiple SOP Classes? Why we are
|
||||
// not throwing an error?
|
||||
console.warn('More than one SOPClassUID in the same series is not yet supported.');
|
||||
}
|
||||
|
||||
// if (!srDisplaySet.measurements || !srDisplaySet.measurements.length) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
_getViewportReferencedDisplaySetData(
|
||||
srDisplaySet,
|
||||
newMeasurementSelected,
|
||||
displaySetService
|
||||
).then(({ referencedDisplaySet, referencedDisplaySetMetadata }) => {
|
||||
if (!referencedDisplaySet || !referencedDisplaySetMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeasurementSelected(newMeasurementSelected);
|
||||
setActiveImageDisplaySetData(referencedDisplaySet);
|
||||
setReferencedDisplaySetMetadata(referencedDisplaySetMetadata);
|
||||
|
||||
if (
|
||||
referencedDisplaySet.displaySetInstanceUID ===
|
||||
activeImageDisplaySetData?.displaySetInstanceUID
|
||||
) {
|
||||
const { measurements } = srDisplaySet;
|
||||
|
||||
// it means that we have a new referenced display set, and the
|
||||
// imageIdIndex will handle it by updating the viewport, but if they
|
||||
// are the same we just need to use measurementService to jump to the
|
||||
// new measurement
|
||||
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!csViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageIds = csViewport.getImageIds();
|
||||
|
||||
const imageIdIndex = imageIds.indexOf(measurements[newMeasurementSelected].imageId);
|
||||
|
||||
if (imageIdIndex !== -1) {
|
||||
csViewport.setImageIdIndex(imageIdIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[dataSource, srDisplaySet, activeImageDisplaySetData, viewportId]
|
||||
);
|
||||
|
||||
const getCornerstoneViewport = useCallback(() => {
|
||||
if (!activeImageDisplaySetData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { component: Component } = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.viewportModule.cornerstone'
|
||||
);
|
||||
|
||||
const { measurements } = srDisplaySet;
|
||||
const measurement = measurements[measurementSelected];
|
||||
|
||||
if (!measurement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialImageIndex = activeImageDisplaySetData.images.findIndex(
|
||||
image => image.imageId === measurement.imageId
|
||||
);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
// should be passed second since we don't want SR displaySet to
|
||||
// override the activeImageDisplaySetData
|
||||
displaySets={[activeImageDisplaySetData]}
|
||||
// It is possible that there is a hanging protocol applying viewportOptions
|
||||
// for the SR, so inherit the viewport options
|
||||
// TODO: Ensure the viewport options are set correctly with respect to
|
||||
// stack etc, in the incoming viewport options.
|
||||
viewportOptions={{
|
||||
...viewportOptions,
|
||||
toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`,
|
||||
// viewportType should not be required, as the stack type should be
|
||||
// required already in order to view SR, but sometimes segmentation
|
||||
// views set the viewport type without fixing the allowed display
|
||||
viewportType: 'stack',
|
||||
// The positionIds for the viewport aren't meaningful for the child display sets
|
||||
positionIds: null,
|
||||
}}
|
||||
onElementEnabled={evt => {
|
||||
props.onElementEnabled?.(evt);
|
||||
onElementEnabled(evt);
|
||||
}}
|
||||
initialImageIndex={initialImageIndex}
|
||||
isJumpToMeasurementDisabled={true}
|
||||
></Component>
|
||||
);
|
||||
}, [activeImageDisplaySetData, viewportId, measurementSelected]);
|
||||
|
||||
const onMeasurementChange = useCallback(
|
||||
direction => {
|
||||
let newMeasurementSelected = measurementSelected;
|
||||
|
||||
newMeasurementSelected += direction;
|
||||
if (newMeasurementSelected >= measurementCount) {
|
||||
newMeasurementSelected = 0;
|
||||
} else if (newMeasurementSelected < 0) {
|
||||
newMeasurementSelected = measurementCount - 1;
|
||||
}
|
||||
|
||||
setTrackingIdentifiers(newMeasurementSelected);
|
||||
updateViewport(newMeasurementSelected);
|
||||
},
|
||||
[measurementSelected, measurementCount, updateViewport, setTrackingIdentifiers]
|
||||
);
|
||||
|
||||
/**
|
||||
Cleanup the SR viewport when the viewport is destroyed
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
|
||||
({ displaySetInstanceUIDs }) => {
|
||||
const activeViewport = viewports.get(activeViewportId);
|
||||
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportId: activeViewportId,
|
||||
displaySetInstanceUIDs: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
onDisplaySetsRemovedSubscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Loading the measurements from the SR viewport, which goes through the
|
||||
* isHydratable check, the outcome for the isHydrated state here is always FALSE
|
||||
* since we don't do the hydration here. Todo: can't we just set it as false? why
|
||||
* we are changing the state here? isHydrated is always false at this stage, and
|
||||
* if it is hydrated we don't even use the SR viewport.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadSR = async () => {
|
||||
if (!srDisplaySet.isLoaded) {
|
||||
await srDisplaySet.load();
|
||||
}
|
||||
const numMeasurements = srDisplaySet.measurements.length;
|
||||
setMeasurementCount(numMeasurements);
|
||||
updateViewport(measurementSelected);
|
||||
};
|
||||
loadSR();
|
||||
}, [srDisplaySet]);
|
||||
|
||||
/**
|
||||
* Hook to update the tracking identifiers when the selected measurement changes or
|
||||
* the element changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
const updateSR = async () => {
|
||||
if (!srDisplaySet.isLoaded) {
|
||||
await srDisplaySet.load();
|
||||
}
|
||||
if (!element || !srDisplaySet.isLoaded) {
|
||||
return;
|
||||
}
|
||||
setTrackingIdentifiers(measurementSelected);
|
||||
};
|
||||
updateSR();
|
||||
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocked(trackedMeasurements?.context?.trackedSeries?.length > 0);
|
||||
}, [trackedMeasurements]);
|
||||
|
||||
useEffect(() => {
|
||||
viewportActionCornersService.addComponents([
|
||||
{
|
||||
viewportId,
|
||||
id: 'viewportStatusComponent',
|
||||
component: _getStatusComponent({
|
||||
srDisplaySet,
|
||||
viewportId,
|
||||
isRehydratable: srDisplaySet.isRehydratable,
|
||||
isLocked,
|
||||
sendTrackedMeasurementsEvent,
|
||||
t,
|
||||
}),
|
||||
indexPriority: -100,
|
||||
location: viewportActionCornersService.LOCATIONS.topLeft,
|
||||
},
|
||||
{
|
||||
viewportId,
|
||||
id: 'viewportActionArrowsComponent',
|
||||
index: 0,
|
||||
component: (
|
||||
<ViewportActionArrows
|
||||
key="actionArrows"
|
||||
onArrowsClick={onMeasurementChange}
|
||||
></ViewportActionArrows>
|
||||
),
|
||||
indexPriority: 0,
|
||||
location: viewportActionCornersService.LOCATIONS.topRight,
|
||||
},
|
||||
]);
|
||||
}, [
|
||||
isLocked,
|
||||
onMeasurementChange,
|
||||
sendTrackedMeasurementsEvent,
|
||||
srDisplaySet,
|
||||
t,
|
||||
viewportActionCornersService,
|
||||
viewportId,
|
||||
]);
|
||||
|
||||
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
let childrenWithProps = null;
|
||||
|
||||
if (!activeImageDisplaySetData || !referencedDisplaySetMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children && children.length) {
|
||||
childrenWithProps = children.map((child, index) => {
|
||||
return (
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
viewportId,
|
||||
key: index,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-row overflow-hidden">
|
||||
{getCornerstoneViewport()}
|
||||
{childrenWithProps}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OHIFCornerstoneSRMeasurementViewport.propTypes = {
|
||||
displaySets: PropTypes.arrayOf(PropTypes.object),
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
viewportLabel: PropTypes.string,
|
||||
viewportOptions: PropTypes.object,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
|
||||
};
|
||||
|
||||
async function _getViewportReferencedDisplaySetData(
|
||||
displaySet,
|
||||
measurementSelected,
|
||||
displaySetService
|
||||
) {
|
||||
const { measurements } = displaySet;
|
||||
const measurement = measurements[measurementSelected];
|
||||
|
||||
const { displaySetInstanceUID } = measurement;
|
||||
if (!displaySet.keyImageDisplaySet) {
|
||||
// Create a new display set, and preserve a reference to it here,
|
||||
// so that it can be re-displayed and shown inside the SR viewport.
|
||||
// This is only for ease of redisplay - the display set is stored in the
|
||||
// usual manner in the display set service.
|
||||
displaySet.keyImageDisplaySet = createReferencedImageDisplaySet(displaySetService, displaySet);
|
||||
}
|
||||
|
||||
if (!displaySetInstanceUID) {
|
||||
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
|
||||
}
|
||||
|
||||
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
const image0 = referencedDisplaySet.images[0];
|
||||
const referencedDisplaySetMetadata = {
|
||||
PatientID: image0.PatientID,
|
||||
PatientName: image0.PatientName,
|
||||
PatientSex: image0.PatientSex,
|
||||
PatientAge: image0.PatientAge,
|
||||
SliceThickness: image0.SliceThickness,
|
||||
StudyDate: image0.StudyDate,
|
||||
SeriesDescription: image0.SeriesDescription,
|
||||
SeriesInstanceUID: image0.SeriesInstanceUID,
|
||||
SeriesNumber: image0.SeriesNumber,
|
||||
ManufacturerModelName: image0.ManufacturerModelName,
|
||||
SpacingBetweenSlices: image0.SpacingBetweenSlices,
|
||||
};
|
||||
|
||||
return { referencedDisplaySetMetadata, referencedDisplaySet };
|
||||
}
|
||||
|
||||
function _getStatusComponent({
|
||||
srDisplaySet,
|
||||
viewportId,
|
||||
isRehydratable,
|
||||
isLocked,
|
||||
sendTrackedMeasurementsEvent,
|
||||
t,
|
||||
}) {
|
||||
const handleMouseUp = () => {
|
||||
sendTrackedMeasurementsEvent('HYDRATE_SR', {
|
||||
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
|
||||
viewportId,
|
||||
});
|
||||
};
|
||||
|
||||
const loadStr = t('LOAD');
|
||||
|
||||
// 1 - Incompatible
|
||||
// 2 - Locked
|
||||
// 3 - Rehydratable / Open
|
||||
const state = isRehydratable && !isLocked ? 3 : isRehydratable && isLocked ? 2 : 1;
|
||||
let ToolTipMessage = null;
|
||||
let StatusIcon = null;
|
||||
|
||||
switch (state) {
|
||||
case 1:
|
||||
StatusIcon = () => <Icon name="status-alert" />;
|
||||
|
||||
ToolTipMessage = () => (
|
||||
<div>
|
||||
This structured report is not compatible
|
||||
<br />
|
||||
with this application.
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
StatusIcon = () => <Icon name="status-locked" />;
|
||||
|
||||
ToolTipMessage = () => (
|
||||
<div>
|
||||
This structured report is currently read-only
|
||||
<br />
|
||||
because you are tracking measurements in
|
||||
<br />
|
||||
another viewport.
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
StatusIcon = () => (
|
||||
<Icon
|
||||
className="text-aqua-pale"
|
||||
name="status-untracked"
|
||||
/>
|
||||
);
|
||||
|
||||
ToolTipMessage = () => <div>{`Click ${loadStr} to restore measurements.`}</div>;
|
||||
}
|
||||
|
||||
const StatusArea = () => (
|
||||
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
|
||||
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
|
||||
<StatusIcon />
|
||||
<span className="ml-1">SR</span>
|
||||
</div>
|
||||
{state === 3 && (
|
||||
<div
|
||||
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
|
||||
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{loadStr}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ToolTipMessage && (
|
||||
<Tooltip
|
||||
content={<ToolTipMessage />}
|
||||
position="bottom-left"
|
||||
>
|
||||
<StatusArea />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!ToolTipMessage && <StatusArea />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OHIFCornerstoneSRMeasurementViewport;
|
||||
@@ -0,0 +1,32 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { ExtensionManager } from '@ohif/core';
|
||||
import { OHIFCornerstoneSRContainer } from './OHIFCornerstoneSRContainer';
|
||||
|
||||
function OHIFCornerstoneSRTextViewport(props: withAppTypes) {
|
||||
const { displaySets } = props;
|
||||
const displaySet = displaySets[0];
|
||||
const instance = displaySet.instances[0];
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-auto p-4 text-white">
|
||||
<div>
|
||||
{/* The root level is always a container */}
|
||||
<OHIFCornerstoneSRContainer container={instance} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OHIFCornerstoneSRTextViewport.propTypes = {
|
||||
displaySets: PropTypes.arrayOf(PropTypes.object),
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
viewportLabel: PropTypes.string,
|
||||
viewportOptions: PropTypes.object,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
|
||||
};
|
||||
|
||||
export default OHIFCornerstoneSRTextViewport;
|
||||
@@ -0,0 +1,30 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { ExtensionManager } from '@ohif/core';
|
||||
|
||||
import OHIFCornerstoneSRMeasurementViewport from './OHIFCornerstoneSRMeasurementViewport';
|
||||
import OHIFCornerstoneSRTextViewport from './OHIFCornerstoneSRTextViewport';
|
||||
|
||||
function OHIFCornerstoneSRViewport(props: withAppTypes) {
|
||||
const { displaySets } = props;
|
||||
const { isImagingMeasurementReport } = displaySets[0];
|
||||
|
||||
if (isImagingMeasurementReport) {
|
||||
return <OHIFCornerstoneSRMeasurementViewport {...props}></OHIFCornerstoneSRMeasurementViewport>;
|
||||
}
|
||||
|
||||
return <OHIFCornerstoneSRTextViewport {...props}></OHIFCornerstoneSRTextViewport>;
|
||||
}
|
||||
|
||||
OHIFCornerstoneSRViewport.propTypes = {
|
||||
displaySets: PropTypes.arrayOf(PropTypes.object),
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
viewportLabel: PropTypes.string,
|
||||
viewportOptions: PropTypes.object,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
|
||||
};
|
||||
|
||||
export default OHIFCornerstoneSRViewport;
|
||||
44
extensions/cornerstone-dicom-sr/src/enums.ts
Normal file
44
extensions/cornerstone-dicom-sr/src/enums.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
|
||||
|
||||
export const SCOORDTypes = {
|
||||
POINT: 'POINT',
|
||||
MULTIPOINT: 'MULTIPOINT',
|
||||
POLYLINE: 'POLYLINE',
|
||||
CIRCLE: 'CIRCLE',
|
||||
ELLIPSE: 'ELLIPSE',
|
||||
};
|
||||
|
||||
export const CodeNameCodeSequenceValues = {
|
||||
ImagingMeasurementReport: '126000',
|
||||
ImageLibrary: '111028',
|
||||
ImagingMeasurements: '126010',
|
||||
MeasurementGroup: '125007',
|
||||
ImageLibraryGroup: '126200',
|
||||
TrackingUniqueIdentifier: '112040',
|
||||
TrackingIdentifier: '112039',
|
||||
Finding: '121071',
|
||||
FindingSite: 'G-C0E3', // SRT
|
||||
FindingSiteSCT: '363698007', // SCT
|
||||
};
|
||||
|
||||
export const CodingSchemeDesignators = {
|
||||
SRT: 'SRT',
|
||||
SCT: 'SCT',
|
||||
CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'],
|
||||
};
|
||||
|
||||
export const RelationshipType = {
|
||||
INFERRED_FROM: 'INFERRED FROM',
|
||||
CONTAINS: 'CONTAINS',
|
||||
};
|
||||
|
||||
const enums = {
|
||||
CodeNameCodeSequenceValues,
|
||||
CodingSchemeDesignators,
|
||||
RelationshipType,
|
||||
SCOORDTypes,
|
||||
};
|
||||
|
||||
export default enums;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Types } from '@ohif/core';
|
||||
|
||||
const srProtocol: Types.HangingProtocol.Protocol = {
|
||||
id: '@ohif/sr',
|
||||
// Don't store this hanging protocol as it applies to the currently active
|
||||
// display set by default
|
||||
// cacheId: null,
|
||||
name: 'SR Key Images',
|
||||
// Just apply this one when specifically listed
|
||||
protocolMatchingRules: [],
|
||||
toolGroupIds: ['default'],
|
||||
// -1 would be used to indicate active only, whereas other values are
|
||||
// the number of required priors referenced - so 0 means active with
|
||||
// 0 or more priors.
|
||||
numberOfPriorsReferenced: 0,
|
||||
// Default viewport is used to define the viewport when
|
||||
// additional viewports are added using the layout tool
|
||||
defaultViewport: {
|
||||
viewportOptions: {
|
||||
viewportType: 'stack',
|
||||
toolGroupId: 'default',
|
||||
allowUnmatchedView: true,
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'srDisplaySetId',
|
||||
matchedDisplaySetsIndex: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySetSelectors: {
|
||||
srDisplaySetId: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: 'SR',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
name: 'SR Key Images',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: { allowUnmatchedView: true },
|
||||
displaySets: [
|
||||
{
|
||||
id: 'srDisplaySetId',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function getHangingProtocolModule() {
|
||||
return [
|
||||
{
|
||||
name: srProtocol.id,
|
||||
protocol: srProtocol,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getHangingProtocolModule;
|
||||
export { srProtocol };
|
||||
724
extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts
Normal file
724
extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
import { utils, classes, DisplaySetService, Types } from '@ohif/core';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
import addSRAnnotation from './utils/addSRAnnotation';
|
||||
import isRehydratable from './utils/isRehydratable';
|
||||
import {
|
||||
SOPClassHandlerName,
|
||||
SOPClassHandlerId,
|
||||
SOPClassHandlerId3D,
|
||||
SOPClassHandlerName3D,
|
||||
} from './id';
|
||||
import { CodeNameCodeSequenceValues, CodingSchemeDesignators } from './enums';
|
||||
|
||||
const { sopClassDictionary } = utils;
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
const { ImageSet, MetadataProvider: metadataProvider } = classes;
|
||||
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
|
||||
|
||||
type InstanceMetadata = Types.InstanceMetadata;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* - [ ] Add SR thumbnail
|
||||
* - [ ] Make viewport
|
||||
* - [ ] Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport
|
||||
*/
|
||||
|
||||
const sopClassUids = [
|
||||
sopClassDictionary.BasicTextSR,
|
||||
sopClassDictionary.EnhancedSR,
|
||||
sopClassDictionary.ComprehensiveSR,
|
||||
];
|
||||
|
||||
const validateSameStudyUID = (uid: string, instances): void => {
|
||||
instances.forEach(it => {
|
||||
if (it.StudyInstanceUID !== uid) {
|
||||
console.warn('Not all instances have the same UID', uid, it);
|
||||
throw new Error(`Instances ${it.SOPInstanceUID} does not belong to ${uid}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds instances to the DICOM SR series, rather than creating a new
|
||||
* series, so that as SR's are saved, they append to the series, and the
|
||||
* key image display set gets updated as well, containing just the new series.
|
||||
* @param instances is a list of instances from THIS series that are not
|
||||
* in this DICOM SR Display Set already.
|
||||
*/
|
||||
function addInstances(instances: InstanceMetadata[], displaySetService: DisplaySetService) {
|
||||
this.instances.push(...instances);
|
||||
utils.sortStudyInstances(this.instances);
|
||||
// The last instance is the newest one, so is the one most interesting.
|
||||
// Eventually, the SR viewer should have the ability to choose which SR
|
||||
// gets loaded, and to navigate among them.
|
||||
this.instance = this.instances[this.instances.length - 1];
|
||||
this.isLoaded = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* DICOM SR SOP Class Handler
|
||||
* For all referenced images in the TID 1500/300 sections, add an image to the
|
||||
* display.
|
||||
* @param instances is a set of instances all from the same series
|
||||
* @param servicesManager is the services that can be used for creating
|
||||
* @returns The list of display sets created for the given instances object
|
||||
*/
|
||||
function _getDisplaySetsFromSeries(
|
||||
instances,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
extensionManager
|
||||
) {
|
||||
// If the series has no instances, stop here
|
||||
if (!instances || !instances.length) {
|
||||
throw new Error('No instances were provided');
|
||||
}
|
||||
|
||||
utils.sortStudyInstances(instances);
|
||||
// The last instance is the newest one, so is the one most interesting.
|
||||
// Eventually, the SR viewer should have the ability to choose which SR
|
||||
// gets loaded, and to navigate among them.
|
||||
const instance = instances[instances.length - 1];
|
||||
|
||||
const {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
SeriesDescription,
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
ConceptNameCodeSequence,
|
||||
SOPClassUID,
|
||||
} = instance;
|
||||
validateSameStudyUID(instance.StudyInstanceUID, instances);
|
||||
|
||||
const is3DSR = SOPClassUID === sopClassDictionary.Comprehensive3DSR;
|
||||
|
||||
const isImagingMeasurementReport =
|
||||
ConceptNameCodeSequence?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurementReport;
|
||||
|
||||
const displaySet = {
|
||||
Modality: 'SR',
|
||||
displaySetInstanceUID: utils.guid(),
|
||||
SeriesDescription,
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
SOPClassHandlerId: is3DSR ? SOPClassHandlerId3D : SOPClassHandlerId,
|
||||
SOPClassUID,
|
||||
instances,
|
||||
referencedImages: null,
|
||||
measurements: null,
|
||||
isDerivedDisplaySet: true,
|
||||
isLoaded: false,
|
||||
isImagingMeasurementReport,
|
||||
sopClassUids,
|
||||
instance,
|
||||
addInstances,
|
||||
};
|
||||
|
||||
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
|
||||
|
||||
return [displaySet];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the display set with the given services and extension manager.
|
||||
* @param srDisplaySet - The display set to load.
|
||||
* @param servicesManager - The services manager containing displaySetService and measurementService.
|
||||
* @param extensionManager - The extension manager containing data sources.
|
||||
*/
|
||||
async function _load(
|
||||
srDisplaySet: Types.DisplaySet,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
extensionManager: AppTypes.ExtensionManager
|
||||
) {
|
||||
const { displaySetService, measurementService } = servicesManager.services;
|
||||
const dataSources = extensionManager.getDataSources();
|
||||
const dataSource = dataSources[0];
|
||||
const { ContentSequence } = srDisplaySet.instance;
|
||||
|
||||
async function retrieveBulkData(obj, parentObj = null, key = null) {
|
||||
for (const prop in obj) {
|
||||
if (typeof obj[prop] === 'object' && obj[prop] !== null) {
|
||||
await retrieveBulkData(obj[prop], obj, prop);
|
||||
} else if (Array.isArray(obj[prop])) {
|
||||
await Promise.all(obj[prop].map(item => retrieveBulkData(item, obj, prop)));
|
||||
} else if (prop === 'BulkDataURI') {
|
||||
const value = await dataSource.retrieve.bulkDataURI({
|
||||
BulkDataURI: obj[prop],
|
||||
StudyInstanceUID: srDisplaySet.instance.StudyInstanceUID,
|
||||
SeriesInstanceUID: srDisplaySet.instance.SeriesInstanceUID,
|
||||
SOPInstanceUID: srDisplaySet.instance.SOPInstanceUID,
|
||||
});
|
||||
if (parentObj && key) {
|
||||
parentObj[key] = new Float32Array(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (srDisplaySet.isLoaded !== true) {
|
||||
await retrieveBulkData(ContentSequence);
|
||||
}
|
||||
|
||||
if (srDisplaySet.isImagingMeasurementReport) {
|
||||
srDisplaySet.referencedImages = _getReferencedImagesList(ContentSequence);
|
||||
srDisplaySet.measurements = _getMeasurements(ContentSequence);
|
||||
} else {
|
||||
srDisplaySet.referencedImages = [];
|
||||
srDisplaySet.measurements = [];
|
||||
}
|
||||
|
||||
const mappings = measurementService.getSourceMappings(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
srDisplaySet.isHydrated = false;
|
||||
srDisplaySet.isRehydratable = isRehydratable(srDisplaySet, mappings);
|
||||
srDisplaySet.isLoaded = true;
|
||||
|
||||
/** Check currently added displaySets and add measurements if the sources exist */
|
||||
displaySetService.activeDisplaySets.forEach(activeDisplaySet => {
|
||||
_checkIfCanAddMeasurementsToDisplaySet(
|
||||
srDisplaySet,
|
||||
activeDisplaySet,
|
||||
dataSource,
|
||||
servicesManager
|
||||
);
|
||||
});
|
||||
|
||||
/** Subscribe to new displaySets as the source may come in after */
|
||||
displaySetService.subscribe(displaySetService.EVENTS.DISPLAY_SETS_ADDED, data => {
|
||||
const { displaySetsAdded } = data;
|
||||
/**
|
||||
* If there are still some measurements that have not yet been loaded into cornerstone,
|
||||
* See if we can load them onto any of the new displaySets.
|
||||
*/
|
||||
displaySetsAdded.forEach(newDisplaySet => {
|
||||
_checkIfCanAddMeasurementsToDisplaySet(
|
||||
srDisplaySet,
|
||||
newDisplaySet,
|
||||
dataSource,
|
||||
servicesManager
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if measurements can be added to a display set.
|
||||
*
|
||||
* @param srDisplaySet - The source display set containing measurements.
|
||||
* @param newDisplaySet - The new display set to check if measurements can be added.
|
||||
* @param dataSource - The data source used to retrieve image IDs.
|
||||
* @param servicesManager - The services manager.
|
||||
*/
|
||||
function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
srDisplaySet,
|
||||
newDisplaySet,
|
||||
dataSource,
|
||||
servicesManager: AppTypes.ServicesManager
|
||||
) {
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const unloadedMeasurements = srDisplaySet.measurements.filter(
|
||||
measurement => measurement.loaded === false
|
||||
);
|
||||
|
||||
if (
|
||||
unloadedMeasurements.length === 0 ||
|
||||
!(newDisplaySet instanceof ImageSet) ||
|
||||
newDisplaySet.unsupported
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// const { sopClassUids } = newDisplaySet;
|
||||
// Create a Set for faster lookups
|
||||
// const sopClassUidSet = new Set(sopClassUids);
|
||||
|
||||
// Create a Map to efficiently look up ImageIds by SOPInstanceUID and frame number
|
||||
const imageIdMap = new Map<string, string>();
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet);
|
||||
|
||||
for (const imageId of imageIds) {
|
||||
const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(imageId);
|
||||
const key = `${SOPInstanceUID}:${frameNumber || 1}`;
|
||||
imageIdMap.set(key, imageId);
|
||||
}
|
||||
|
||||
if (!unloadedMeasurements?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const is3DSR = srDisplaySet.SOPClassUID === sopClassDictionary.Comprehensive3DSR;
|
||||
|
||||
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
|
||||
let measurement = unloadedMeasurements[j];
|
||||
|
||||
const onBeforeSRAddMeasurement = customizationService.getModeCustomization(
|
||||
'onBeforeSRAddMeasurement'
|
||||
)?.value;
|
||||
|
||||
if (typeof onBeforeSRAddMeasurement === 'function') {
|
||||
measurement = onBeforeSRAddMeasurement({
|
||||
measurement,
|
||||
StudyInstanceUID: srDisplaySet.StudyInstanceUID,
|
||||
SeriesInstanceUID: srDisplaySet.SeriesInstanceUID,
|
||||
});
|
||||
}
|
||||
|
||||
// if it is 3d SR we can just add the SR annotation
|
||||
if (is3DSR) {
|
||||
addSRAnnotation(measurement, null, null);
|
||||
measurement.loaded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const referencedSOPSequence = measurement.coords[0].ReferencedSOPSequence;
|
||||
if (!referencedSOPSequence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { ReferencedSOPInstanceUID } = referencedSOPSequence;
|
||||
const frame = referencedSOPSequence.ReferencedFrameNumber || 1;
|
||||
const key = `${ReferencedSOPInstanceUID}:${frame}`;
|
||||
const imageId = imageIdMap.get(key);
|
||||
|
||||
if (
|
||||
imageId &&
|
||||
_measurementReferencesSOPInstanceUID(measurement, ReferencedSOPInstanceUID, frame)
|
||||
) {
|
||||
addSRAnnotation(measurement, imageId, frame);
|
||||
|
||||
// Update measurement properties
|
||||
measurement.loaded = true;
|
||||
measurement.imageId = imageId;
|
||||
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
|
||||
measurement.ReferencedSOPInstanceUID = ReferencedSOPInstanceUID;
|
||||
measurement.frameNumber = frame;
|
||||
|
||||
unloadedMeasurements.splice(j, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a measurement references a specific SOP Instance UID.
|
||||
* @param measurement - The measurement object.
|
||||
* @param SOPInstanceUID - The SOP Instance UID to check against.
|
||||
* @param frameNumber - The frame number to check against (optional).
|
||||
* @returns True if the measurement references the specified SOP Instance UID, false otherwise.
|
||||
*/
|
||||
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frameNumber) {
|
||||
const { coords } = measurement;
|
||||
|
||||
/**
|
||||
* NOTE: The ReferencedFrameNumber can be multiple values according to the DICOM
|
||||
* Standard. But for now, we will support only one ReferenceFrameNumber.
|
||||
*/
|
||||
const ReferencedFrameNumber =
|
||||
(measurement.coords[0].ReferencedSOPSequence &&
|
||||
measurement.coords[0].ReferencedSOPSequence?.ReferencedFrameNumber) ||
|
||||
1;
|
||||
|
||||
if (frameNumber && Number(frameNumber) !== Number(ReferencedFrameNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let j = 0; j < coords.length; j++) {
|
||||
const coord = coords[j];
|
||||
const { ReferencedSOPInstanceUID } = coord.ReferencedSOPSequence;
|
||||
if (ReferencedSOPInstanceUID === SOPInstanceUID) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the SOP class handler module.
|
||||
*
|
||||
* @param {Object} options - The options for retrieving the SOP class handler module.
|
||||
* @param {Object} options.servicesManager - The services manager.
|
||||
* @param {Object} options.extensionManager - The extension manager.
|
||||
* @returns {Array} An array containing the SOP class handler module.
|
||||
*/
|
||||
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
|
||||
const getDisplaySetsFromSeries = instances => {
|
||||
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
|
||||
};
|
||||
return [
|
||||
{
|
||||
name: SOPClassHandlerName,
|
||||
sopClassUids,
|
||||
getDisplaySetsFromSeries,
|
||||
},
|
||||
{
|
||||
name: SOPClassHandlerName3D,
|
||||
sopClassUids: [sopClassDictionary.Comprehensive3DSR],
|
||||
getDisplaySetsFromSeries,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the measurements from the ImagingMeasurementReportContentSequence.
|
||||
*
|
||||
* @param {Array} ImagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
|
||||
* @returns {Array} - The array of measurements.
|
||||
*/
|
||||
function _getMeasurements(ImagingMeasurementReportContentSequence) {
|
||||
const ImagingMeasurements = ImagingMeasurementReportContentSequence.find(
|
||||
item =>
|
||||
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements
|
||||
);
|
||||
|
||||
if (!ImagingMeasurements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const MeasurementGroups = _getSequenceAsArray(ImagingMeasurements.ContentSequence).filter(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup
|
||||
);
|
||||
|
||||
const mergedContentSequencesByTrackingUniqueIdentifiers =
|
||||
_getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups);
|
||||
const measurements = [];
|
||||
|
||||
Object.keys(mergedContentSequencesByTrackingUniqueIdentifiers).forEach(
|
||||
trackingUniqueIdentifier => {
|
||||
const mergedContentSequence =
|
||||
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier];
|
||||
|
||||
const measurement = _processMeasurement(mergedContentSequence);
|
||||
if (measurement) {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return measurements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves merged content sequences by tracking unique identifiers.
|
||||
*
|
||||
* @param {Array} MeasurementGroups - The measurement groups.
|
||||
* @returns {Object} - The merged content sequences by tracking unique identifiers.
|
||||
*/
|
||||
function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups) {
|
||||
const mergedContentSequencesByTrackingUniqueIdentifiers = {};
|
||||
|
||||
MeasurementGroups.forEach(MeasurementGroup => {
|
||||
const ContentSequence = _getSequenceAsArray(MeasurementGroup.ContentSequence);
|
||||
|
||||
const TrackingUniqueIdentifierItem = ContentSequence.find(
|
||||
item =>
|
||||
item.ConceptNameCodeSequence.CodeValue ===
|
||||
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
|
||||
);
|
||||
if (!TrackingUniqueIdentifierItem) {
|
||||
console.warn('No Tracking Unique Identifier, skipping ambiguous measurement.');
|
||||
}
|
||||
|
||||
const trackingUniqueIdentifier = TrackingUniqueIdentifierItem.UID;
|
||||
|
||||
if (mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier] === undefined) {
|
||||
// Add the full ContentSequence
|
||||
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier] = [
|
||||
...ContentSequence,
|
||||
];
|
||||
} else {
|
||||
// Add the ContentSequence minus the tracking identifier, as we have this
|
||||
// Information in the merged ContentSequence anyway.
|
||||
ContentSequence.forEach(item => {
|
||||
if (
|
||||
item.ConceptNameCodeSequence.CodeValue !==
|
||||
CodeNameCodeSequenceValues.TrackingUniqueIdentifier
|
||||
) {
|
||||
mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier].push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return mergedContentSequencesByTrackingUniqueIdentifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the measurement based on the merged content sequence.
|
||||
* If the merged content sequence contains SCOORD or SCOORD3D value types,
|
||||
* it calls the _processTID1410Measurement function.
|
||||
* Otherwise, it calls the _processNonGeometricallyDefinedMeasurement function.
|
||||
*
|
||||
* @param {Array<Object>} mergedContentSequence - The merged content sequence to process.
|
||||
* @returns {any} - The processed measurement result.
|
||||
*/
|
||||
function _processMeasurement(mergedContentSequence) {
|
||||
if (
|
||||
mergedContentSequence.some(
|
||||
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
|
||||
)
|
||||
) {
|
||||
return _processTID1410Measurement(mergedContentSequence);
|
||||
}
|
||||
|
||||
return _processNonGeometricallyDefinedMeasurement(mergedContentSequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes TID 1410 style measurements from the mergedContentSequence.
|
||||
* TID 1410 style measurements have a SCOORD or SCOORD3D at the top level,
|
||||
* and non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D.
|
||||
*
|
||||
* @param mergedContentSequence - The merged content sequence containing the measurements.
|
||||
* @returns The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
|
||||
*/
|
||||
function _processTID1410Measurement(mergedContentSequence) {
|
||||
// Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level,
|
||||
// And non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D
|
||||
|
||||
const graphicItem = mergedContentSequence.find(
|
||||
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
|
||||
);
|
||||
|
||||
const UIDREFContentItem = mergedContentSequence.find(group => group.ValueType === 'UIDREF');
|
||||
|
||||
const TrackingIdentifierContentItem = mergedContentSequence.find(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.TrackingIdentifier
|
||||
);
|
||||
|
||||
if (!graphicItem) {
|
||||
console.warn(
|
||||
`graphic ValueType ${graphicItem.ValueType} not currently supported, skipping annotation.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
|
||||
|
||||
const measurement = {
|
||||
loaded: false,
|
||||
labels: [],
|
||||
coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)],
|
||||
TrackingUniqueIdentifier: UIDREFContentItem.UID,
|
||||
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
|
||||
};
|
||||
|
||||
NUMContentItems.forEach(item => {
|
||||
const { ConceptNameCodeSequence, MeasuredValueSequence } = item;
|
||||
if (MeasuredValueSequence) {
|
||||
measurement.labels.push(
|
||||
_getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const findingSites = mergedContentSequence.filter(
|
||||
item =>
|
||||
item.ConceptNameCodeSequence.CodingSchemeDesignator === CodingSchemeDesignators.SCT &&
|
||||
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSiteSCT
|
||||
);
|
||||
if (findingSites.length) {
|
||||
measurement.labels.push({
|
||||
label: CodeNameCodeSequenceValues.FindingSiteSCT,
|
||||
value: findingSites[0].ConceptCodeSequence.CodeMeaning,
|
||||
});
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the non-geometrically defined measurement from the merged content sequence.
|
||||
*
|
||||
* @param mergedContentSequence The merged content sequence containing the measurement data.
|
||||
* @returns The processed measurement object.
|
||||
*/
|
||||
function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
|
||||
const UIDREFContentItem = mergedContentSequence.find(group => group.ValueType === 'UIDREF');
|
||||
|
||||
const TrackingIdentifierContentItem = mergedContentSequence.find(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.TrackingIdentifier
|
||||
);
|
||||
|
||||
const finding = mergedContentSequence.find(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.Finding
|
||||
);
|
||||
|
||||
const findingSites = mergedContentSequence.filter(
|
||||
item =>
|
||||
item.ConceptNameCodeSequence.CodingSchemeDesignator === CodingSchemeDesignators.SRT &&
|
||||
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSite
|
||||
);
|
||||
|
||||
const measurement = {
|
||||
loaded: false,
|
||||
labels: [],
|
||||
coords: [],
|
||||
TrackingUniqueIdentifier: UIDREFContentItem.UID,
|
||||
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
|
||||
};
|
||||
|
||||
if (
|
||||
finding &&
|
||||
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
|
||||
finding.ConceptCodeSequence.CodingSchemeDesignator
|
||||
) &&
|
||||
finding.ConceptCodeSequence.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
|
||||
) {
|
||||
measurement.labels.push({
|
||||
label: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
|
||||
value: finding.ConceptCodeSequence.CodeMeaning,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now.
|
||||
if (findingSites.length) {
|
||||
const cornerstoneFreeTextFindingSite = findingSites.find(
|
||||
FindingSite =>
|
||||
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
|
||||
FindingSite.ConceptCodeSequence.CodingSchemeDesignator
|
||||
) &&
|
||||
FindingSite.ConceptCodeSequence.CodeValue ===
|
||||
Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
|
||||
);
|
||||
|
||||
if (cornerstoneFreeTextFindingSite) {
|
||||
measurement.labels.push({
|
||||
label: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
|
||||
value: cornerstoneFreeTextFindingSite.ConceptCodeSequence.CodeMeaning,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
NUMContentItems.forEach(item => {
|
||||
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
|
||||
|
||||
const { ValueType } = ContentSequence;
|
||||
if (!ValueType === 'SCOORD') {
|
||||
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
|
||||
if (coords) {
|
||||
measurement.coords.push(coords);
|
||||
}
|
||||
|
||||
if (MeasuredValueSequence) {
|
||||
measurement.labels.push(
|
||||
_getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts coordinates from a graphic item of type SCOORD or SCOORD3D.
|
||||
* @param {object} graphicItem - The graphic item containing the coordinates.
|
||||
* @returns {object} - The extracted coordinates.
|
||||
*/
|
||||
const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
|
||||
const { ValueType, GraphicType, GraphicData } = graphicItem;
|
||||
const coords = { ValueType, GraphicType, GraphicData };
|
||||
coords.ReferencedSOPSequence = graphicItem.ContentSequence?.ReferencedSOPSequence;
|
||||
coords.ReferencedFrameOfReferenceSequence =
|
||||
graphicItem.ReferencedFrameOfReferenceUID ||
|
||||
graphicItem.ContentSequence?.ReferencedFrameOfReferenceSequence;
|
||||
return coords;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the label and value from the provided ConceptNameCodeSequence and MeasuredValueSequence.
|
||||
* @param {Object} ConceptNameCodeSequence - The ConceptNameCodeSequence object.
|
||||
* @param {Object} MeasuredValueSequence - The MeasuredValueSequence object.
|
||||
* @returns {Object} - An object containing the label and value.
|
||||
* The label represents the CodeMeaning from the ConceptNameCodeSequence.
|
||||
* The value represents the formatted NumericValue and CodeValue from the MeasuredValueSequence.
|
||||
* Example: { label: 'Long Axis', value: '31.00 mm' }
|
||||
*/
|
||||
function _getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence) {
|
||||
const { CodeMeaning } = ConceptNameCodeSequence;
|
||||
const { NumericValue, MeasurementUnitsCodeSequence } = MeasuredValueSequence;
|
||||
const { CodeValue } = MeasurementUnitsCodeSequence;
|
||||
const formatedNumericValue = NumericValue ? Number(NumericValue).toFixed(2) : '';
|
||||
return {
|
||||
label: CodeMeaning,
|
||||
value: `${formatedNumericValue} ${CodeValue}`,
|
||||
}; // E.g. Long Axis: 31.0 mm
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of referenced images from the Imaging Measurement Report Content Sequence.
|
||||
*
|
||||
* @param {Array} ImagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
|
||||
* @returns {Array} - The list of referenced images.
|
||||
*/
|
||||
function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
|
||||
const ImageLibrary = ImagingMeasurementReportContentSequence.find(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibrary
|
||||
);
|
||||
|
||||
if (!ImageLibrary) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find(
|
||||
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup
|
||||
);
|
||||
if (!ImageLibraryGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const referencedImages = [];
|
||||
|
||||
_getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => {
|
||||
const { ReferencedSOPSequence } = item;
|
||||
if (!ReferencedSOPSequence) {
|
||||
return;
|
||||
}
|
||||
for (const ref of _getSequenceAsArray(ReferencedSOPSequence)) {
|
||||
if (ref.ReferencedSOPClassUID) {
|
||||
const { ReferencedSOPClassUID, ReferencedSOPInstanceUID } = ref;
|
||||
|
||||
referencedImages.push({
|
||||
ReferencedSOPClassUID,
|
||||
ReferencedSOPInstanceUID,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return referencedImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a DICOM sequence to an array.
|
||||
* If the sequence is null or undefined, an empty array is returned.
|
||||
* If the sequence is already an array, it is returned as is.
|
||||
* Otherwise, the sequence is wrapped in an array and returned.
|
||||
*
|
||||
* @param {any} sequence - The DICOM sequence to convert.
|
||||
* @returns {any[]} - The converted array.
|
||||
*/
|
||||
function _getSequenceAsArray(sequence) {
|
||||
if (!sequence) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(sequence) ? sequence : [sequence];
|
||||
}
|
||||
|
||||
export default getSopClassHandlerModule;
|
||||
11
extensions/cornerstone-dicom-sr/src/id.js
Normal file
11
extensions/cornerstone-dicom-sr/src/id.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
const SOPClassHandlerName = 'dicom-sr';
|
||||
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
|
||||
|
||||
const SOPClassHandlerName3D = 'dicom-sr-3d';
|
||||
const SOPClassHandlerId3D = `${id}.sopClassHandlerModule.${SOPClassHandlerName3D}`;
|
||||
|
||||
export { SOPClassHandlerName, SOPClassHandlerId, SOPClassHandlerName3D, SOPClassHandlerId3D, id };
|
||||
74
extensions/cornerstone-dicom-sr/src/index.tsx
Normal file
74
extensions/cornerstone-dicom-sr/src/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import getSopClassHandlerModule from './getSopClassHandlerModule';
|
||||
import { srProtocol } from './getHangingProtocolModule';
|
||||
import onModeEnter from './onModeEnter';
|
||||
import getCommandsModule from './commandsModule';
|
||||
import preRegistration from './init';
|
||||
import { id } from './id.js';
|
||||
import toolNames from './tools/toolNames';
|
||||
import hydrateStructuredReport from './utils/hydrateStructuredReport';
|
||||
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
|
||||
import Enums from './enums';
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
return import(/* webpackPrefetch: true */ './components/OHIFCornerstoneSRViewport');
|
||||
});
|
||||
|
||||
const OHIFCornerstoneSRViewport = props => {
|
||||
return (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Component {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const dicomSRExtension = {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id,
|
||||
onModeEnter,
|
||||
|
||||
preRegistration,
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {object} [configuration={}]
|
||||
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
|
||||
*/
|
||||
getViewportModule({ servicesManager, extensionManager }) {
|
||||
const ExtendedOHIFCornerstoneSRViewport = props => {
|
||||
return (
|
||||
<OHIFCornerstoneSRViewport
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return [{ name: 'dicom-sr', component: ExtendedOHIFCornerstoneSRViewport }];
|
||||
},
|
||||
getCommandsModule,
|
||||
getSopClassHandlerModule,
|
||||
// Include dynamically computed values such as toolNames not known till instantiation
|
||||
getUtilityModule({ servicesManager }) {
|
||||
return [
|
||||
{
|
||||
name: 'tools',
|
||||
exports: {
|
||||
toolNames,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default dicomSRExtension;
|
||||
|
||||
// Put static exports here so they can be type checked
|
||||
export { hydrateStructuredReport, createReferencedImageDisplaySet, srProtocol, Enums, toolNames };
|
||||
104
extensions/cornerstone-dicom-sr/src/init.ts
Normal file
104
extensions/cornerstone-dicom-sr/src/init.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AngleTool,
|
||||
annotation,
|
||||
ArrowAnnotateTool,
|
||||
BidirectionalTool,
|
||||
CobbAngleTool,
|
||||
EllipticalROITool,
|
||||
CircleROITool,
|
||||
LengthTool,
|
||||
PlanarFreehandROITool,
|
||||
RectangleROITool,
|
||||
utilities as csToolsUtils,
|
||||
} from '@cornerstonejs/tools';
|
||||
import { Types, MeasurementService } from '@ohif/core';
|
||||
import { StackViewport, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool';
|
||||
import SCOORD3DPointTool from './tools/SCOORD3DPointTool';
|
||||
import SRSCOOR3DProbeMapper from './utils/SRSCOOR3DProbeMapper';
|
||||
import addToolInstance from './utils/addToolInstance';
|
||||
import toolNames from './tools/toolNames';
|
||||
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
|
||||
/**
|
||||
* @param {object} configuration
|
||||
*/
|
||||
export default function init({
|
||||
configuration = {},
|
||||
servicesManager,
|
||||
}: Types.Extensions.ExtensionParams): void {
|
||||
const { measurementService, cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
addToolInstance(toolNames.DICOMSRDisplay, DICOMSRDisplayTool);
|
||||
addToolInstance(toolNames.SRLength, LengthTool);
|
||||
addToolInstance(toolNames.SRBidirectional, BidirectionalTool);
|
||||
addToolInstance(toolNames.SREllipticalROI, EllipticalROITool);
|
||||
addToolInstance(toolNames.SRCircleROI, CircleROITool);
|
||||
addToolInstance(toolNames.SRArrowAnnotate, ArrowAnnotateTool);
|
||||
addToolInstance(toolNames.SRAngle, AngleTool);
|
||||
addToolInstance(toolNames.SRPlanarFreehandROI, PlanarFreehandROITool);
|
||||
addToolInstance(toolNames.SRRectangleROI, RectangleROITool);
|
||||
addToolInstance(toolNames.SRSCOORD3DPoint, SCOORD3DPointTool);
|
||||
|
||||
// TODO - fix the SR display of Cobb Angle, as it joins the two lines
|
||||
addToolInstance(toolNames.SRCobbAngle, CobbAngleTool);
|
||||
|
||||
const csTools3DVer1MeasurementSource = measurementService.getSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
const { POINT } = measurementService.VALUE_TYPES;
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'SRSCOORD3DPoint',
|
||||
POINT,
|
||||
SRSCOOR3DProbeMapper.toAnnotation,
|
||||
SRSCOOR3DProbeMapper.toMeasurement
|
||||
);
|
||||
|
||||
// Modify annotation tools to use dashed lines on SR
|
||||
const dashedLine = {
|
||||
lineDash: '4,4',
|
||||
};
|
||||
annotation.config.style.setToolGroupToolStyles('SRToolGroup', {
|
||||
[toolNames.DICOMSRDisplay]: dashedLine,
|
||||
SRLength: dashedLine,
|
||||
SRBidirectional: dashedLine,
|
||||
SREllipticalROI: dashedLine,
|
||||
SRCircleROI: dashedLine,
|
||||
SRArrowAnnotate: dashedLine,
|
||||
SRCobbAngle: dashedLine,
|
||||
SRAngle: dashedLine,
|
||||
SRPlanarFreehandROI: dashedLine,
|
||||
SRRectangleROI: dashedLine,
|
||||
global: {},
|
||||
});
|
||||
|
||||
measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
|
||||
({ viewportId, measurement, isConsumed }) => {
|
||||
if (isConsumed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const currentViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { viewPlaneNormal } = currentViewport.getCamera();
|
||||
const referencedImageId = csToolsUtils.getClosestImageIdForStackViewport(
|
||||
currentViewport as StackViewport,
|
||||
measurement.points[0],
|
||||
viewPlaneNormal
|
||||
);
|
||||
const imageIndex = (currentViewport as StackViewport)
|
||||
.getImageIds()
|
||||
.indexOf(referencedImageId);
|
||||
csUtils.jumpToSlice(currentViewport.element, { imageIndex });
|
||||
} catch (error) {
|
||||
console.warn('Unable to jump to image based on measurement coordinate', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
15
extensions/cornerstone-dicom-sr/src/onModeEnter.js
Normal file
15
extensions/cornerstone-dicom-sr/src/onModeEnter.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SOPClassHandlerId, SOPClassHandlerId3D } from './id';
|
||||
|
||||
export default function onModeEnter({ servicesManager }) {
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const displaySetCache = displaySetService.getDisplaySetCache();
|
||||
|
||||
const srDisplaySets = [...displaySetCache.values()].filter(
|
||||
ds => ds.SOPClassHandlerId === SOPClassHandlerId || ds.SOPClassHandlerId === SOPClassHandlerId3D
|
||||
);
|
||||
|
||||
srDisplaySets.forEach(ds => {
|
||||
// New mode route, allow SRs to be hydrated again
|
||||
ds.isHydrated = false;
|
||||
});
|
||||
}
|
||||
407
extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts
Normal file
407
extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import {
|
||||
AnnotationTool,
|
||||
annotation,
|
||||
drawing,
|
||||
utilities,
|
||||
Types as cs3DToolsTypes,
|
||||
} from '@cornerstonejs/tools';
|
||||
import { getTrackingUniqueIdentifiersForElement } from './modules/dicomSRModule';
|
||||
import { SCOORDTypes } from '../enums';
|
||||
import toolNames from './toolNames';
|
||||
|
||||
export default class DICOMSRDisplayTool extends AnnotationTool {
|
||||
static toolName = toolNames.DICOMSRDisplay;
|
||||
|
||||
constructor(
|
||||
toolProps = {},
|
||||
defaultToolProps = {
|
||||
configuration: {},
|
||||
}
|
||||
) {
|
||||
super(toolProps, defaultToolProps);
|
||||
}
|
||||
|
||||
_getTextBoxLinesFromLabels(labels) {
|
||||
// TODO -> max 5 for now (label + shortAxis + longAxis), need a generic solution for this!
|
||||
|
||||
const labelLength = Math.min(labels.length, 5);
|
||||
const lines = [];
|
||||
|
||||
for (let i = 0; i < labelLength; i++) {
|
||||
const labelEntry = labels[i];
|
||||
lines.push(`${_labelToShorthand(labelEntry.label)}: ${labelEntry.value}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// This tool should not inherit from AnnotationTool and we should not need
|
||||
// to add the following lines.
|
||||
isPointNearTool = () => null;
|
||||
getHandleNearImagePoint = () => null;
|
||||
|
||||
renderAnnotation = (enabledElement: Types.IEnabledElement, svgDrawingHelper: any): void => {
|
||||
const { viewport } = enabledElement;
|
||||
const { element } = viewport;
|
||||
|
||||
let annotations = annotation.state.getAnnotations(this.getToolName(), element);
|
||||
|
||||
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
|
||||
if (!annotations?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
annotations = this.filterInteractableAnnotationsForElement(element, annotations);
|
||||
|
||||
if (!annotations?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackingUniqueIdentifiersForElement = getTrackingUniqueIdentifiersForElement(element);
|
||||
|
||||
const { activeIndex, trackingUniqueIdentifiers } = trackingUniqueIdentifiersForElement;
|
||||
|
||||
const activeTrackingUniqueIdentifier = trackingUniqueIdentifiers[activeIndex];
|
||||
|
||||
// Filter toolData to only render the data for the active SR.
|
||||
const filteredAnnotations = annotations.filter(annotation =>
|
||||
trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier)
|
||||
);
|
||||
|
||||
if (!viewport._actors?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
|
||||
toolGroupId: this.toolGroupId,
|
||||
toolName: this.getToolName(),
|
||||
viewportId: enabledElement.viewport.id,
|
||||
};
|
||||
const { style: annotationStyle } = annotation.config;
|
||||
|
||||
for (let i = 0; i < filteredAnnotations.length; i++) {
|
||||
const annotation = filteredAnnotations[i];
|
||||
const annotationUID = annotation.annotationUID;
|
||||
const { renderableData, TrackingUniqueIdentifier } = annotation.data;
|
||||
const { referencedImageId } = annotation.metadata;
|
||||
|
||||
styleSpecifier.annotationUID = annotationUID;
|
||||
|
||||
const groupStyle = annotationStyle.getToolGroupToolStyles(this.toolGroupId)[
|
||||
this.getToolName()
|
||||
];
|
||||
|
||||
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
|
||||
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
|
||||
const color =
|
||||
TrackingUniqueIdentifier === activeTrackingUniqueIdentifier
|
||||
? 'rgb(0, 255, 0)'
|
||||
: this.getStyle('color', styleSpecifier, annotation);
|
||||
|
||||
const options = {
|
||||
color,
|
||||
lineDash,
|
||||
lineWidth,
|
||||
...groupStyle,
|
||||
};
|
||||
|
||||
Object.keys(renderableData).forEach(GraphicType => {
|
||||
const renderableDataForGraphicType = renderableData[GraphicType];
|
||||
|
||||
let renderMethod;
|
||||
let canvasCoordinatesAdapter;
|
||||
|
||||
switch (GraphicType) {
|
||||
case SCOORDTypes.POINT:
|
||||
renderMethod = this.renderPoint;
|
||||
break;
|
||||
case SCOORDTypes.MULTIPOINT:
|
||||
renderMethod = this.renderMultipoint;
|
||||
break;
|
||||
case SCOORDTypes.POLYLINE:
|
||||
renderMethod = this.renderPolyLine;
|
||||
break;
|
||||
case SCOORDTypes.CIRCLE:
|
||||
renderMethod = this.renderEllipse;
|
||||
break;
|
||||
case SCOORDTypes.ELLIPSE:
|
||||
renderMethod = this.renderEllipse;
|
||||
canvasCoordinatesAdapter = utilities.math.ellipse.getCanvasEllipseCorners;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported GraphicType: ${GraphicType}`);
|
||||
}
|
||||
|
||||
const canvasCoordinates = renderMethod(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
renderableDataForGraphicType,
|
||||
annotationUID,
|
||||
referencedImageId,
|
||||
options
|
||||
);
|
||||
|
||||
this.renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
canvasCoordinatesAdapter,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderPolyLine(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
renderableData,
|
||||
annotationUID,
|
||||
referencedImageId,
|
||||
options
|
||||
) {
|
||||
const drawingOptions = {
|
||||
color: options.color,
|
||||
width: options.lineWidth,
|
||||
lineDash: options.lineDash,
|
||||
};
|
||||
let allCanvasCoordinates = [];
|
||||
renderableData.map((data, index) => {
|
||||
const canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
|
||||
const lineUID = `${index}`;
|
||||
|
||||
if (canvasCoordinates.length === 2) {
|
||||
drawing.drawLine(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
lineUID,
|
||||
canvasCoordinates[0],
|
||||
canvasCoordinates[1],
|
||||
drawingOptions
|
||||
);
|
||||
} else {
|
||||
drawing.drawPolyline(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
lineUID,
|
||||
canvasCoordinates,
|
||||
drawingOptions
|
||||
);
|
||||
}
|
||||
|
||||
allCanvasCoordinates = allCanvasCoordinates.concat(canvasCoordinates);
|
||||
});
|
||||
|
||||
return allCanvasCoordinates; // used for drawing textBox
|
||||
}
|
||||
|
||||
renderMultipoint(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
renderableData,
|
||||
annotationUID,
|
||||
referencedImageId,
|
||||
options
|
||||
) {
|
||||
let canvasCoordinates;
|
||||
renderableData.map((data, index) => {
|
||||
canvasCoordinates = data.map(p => viewport.worldToCanvas(p));
|
||||
const handleGroupUID = '0';
|
||||
drawing.drawHandles(svgDrawingHelper, annotationUID, handleGroupUID, canvasCoordinates, {
|
||||
color: options.color,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderPoint(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
renderableData,
|
||||
annotationUID,
|
||||
referencedImageId,
|
||||
options
|
||||
) {
|
||||
const canvasCoordinates = [];
|
||||
renderableData.map((data, index) => {
|
||||
const point = data[0];
|
||||
// This gives us one point for arrow
|
||||
canvasCoordinates.push(viewport.worldToCanvas(point));
|
||||
|
||||
if (data[1] !== undefined) {
|
||||
canvasCoordinates.push(viewport.worldToCanvas(data[1]));
|
||||
}
|
||||
else{
|
||||
// We get the other point for the arrow by using the image size
|
||||
const imagePixelModule = metaData.get('imagePixelModule', referencedImageId);
|
||||
|
||||
let xOffset = 10;
|
||||
let yOffset = 10;
|
||||
|
||||
if (imagePixelModule) {
|
||||
const { columns, rows } = imagePixelModule;
|
||||
xOffset = columns / 10;
|
||||
yOffset = rows / 10;
|
||||
}
|
||||
|
||||
const imagePoint = csUtils.worldToImageCoords(referencedImageId, point);
|
||||
const arrowEnd = csUtils.imageToWorldCoords(referencedImageId, [
|
||||
imagePoint[0] + xOffset,
|
||||
imagePoint[1] + yOffset,
|
||||
]);
|
||||
|
||||
canvasCoordinates.push(viewport.worldToCanvas(arrowEnd));
|
||||
|
||||
}
|
||||
|
||||
|
||||
const arrowUID = `${index}`;
|
||||
|
||||
// Todo: handle drawing probe as probe, currently we are drawing it as an arrow
|
||||
drawing.drawArrow(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
arrowUID,
|
||||
canvasCoordinates[1],
|
||||
canvasCoordinates[0],
|
||||
{
|
||||
color: options.color,
|
||||
width: options.lineWidth,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return canvasCoordinates; // used for drawing textBox
|
||||
}
|
||||
|
||||
renderEllipse(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
renderableData,
|
||||
annotationUID,
|
||||
referencedImageId,
|
||||
options
|
||||
) {
|
||||
let canvasCoordinates;
|
||||
renderableData.map((data, index) => {
|
||||
if (data.length === 0) {
|
||||
// since oblique ellipse is not supported for hydration right now
|
||||
// we just return
|
||||
return;
|
||||
}
|
||||
|
||||
const ellipsePointsWorld = data;
|
||||
|
||||
const rotation = viewport.getRotation();
|
||||
|
||||
canvasCoordinates = ellipsePointsWorld.map(p => viewport.worldToCanvas(p));
|
||||
let canvasCorners;
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
canvasCorners = utilities.math.ellipse.getCanvasEllipseCorners([
|
||||
canvasCoordinates[2],
|
||||
canvasCoordinates[3],
|
||||
canvasCoordinates[0],
|
||||
canvasCoordinates[1],
|
||||
]) as Array<Types.Point2>;
|
||||
} else {
|
||||
canvasCorners = utilities.math.ellipse.getCanvasEllipseCorners(
|
||||
canvasCoordinates
|
||||
) as Array<Types.Point2>;
|
||||
}
|
||||
|
||||
const lineUID = `${index}`;
|
||||
drawing.drawEllipse(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
lineUID,
|
||||
canvasCorners[0],
|
||||
canvasCorners[1],
|
||||
{
|
||||
color: options.color,
|
||||
width: options.lineWidth,
|
||||
lineDash: options.lineDash,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return canvasCoordinates;
|
||||
}
|
||||
|
||||
renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
canvasCoordinatesAdapter,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options = {}
|
||||
) {
|
||||
if (!canvasCoordinates || !annotation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { annotationUID, data = {} } = annotation;
|
||||
const { labels } = data;
|
||||
const { color } = options;
|
||||
|
||||
let adaptedCanvasCoordinates = canvasCoordinates;
|
||||
// adapt coordinates if there is an adapter
|
||||
if (typeof canvasCoordinatesAdapter === 'function') {
|
||||
adaptedCanvasCoordinates = canvasCoordinatesAdapter(canvasCoordinates);
|
||||
}
|
||||
const textLines = this._getTextBoxLinesFromLabels(labels);
|
||||
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(adaptedCanvasCoordinates);
|
||||
|
||||
if (!annotation.data?.handles?.textBox?.worldPosition) {
|
||||
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(canvasTextBoxCoords);
|
||||
}
|
||||
|
||||
const textBoxPosition = viewport.worldToCanvas(annotation.data.handles.textBox.worldPosition);
|
||||
|
||||
const textBoxUID = '1';
|
||||
const textBoxOptions = this.getLinkedTextBoxStyle(styleSpecifier, annotation);
|
||||
|
||||
const boundingBox = drawing.drawLinkedTextBox(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
textBoxUID,
|
||||
textLines,
|
||||
textBoxPosition,
|
||||
canvasCoordinates,
|
||||
{},
|
||||
{
|
||||
...textBoxOptions,
|
||||
color,
|
||||
}
|
||||
);
|
||||
|
||||
const { x: left, y: top, width, height } = boundingBox;
|
||||
|
||||
annotation.data.handles.textBox.worldBoundingBox = {
|
||||
topLeft: viewport.canvasToWorld([left, top]),
|
||||
topRight: viewport.canvasToWorld([left + width, top]),
|
||||
bottomLeft: viewport.canvasToWorld([left, top + height]),
|
||||
bottomRight: viewport.canvasToWorld([left + width, top + height]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const SHORT_HAND_MAP = {
|
||||
'Short Axis': 'W: ',
|
||||
'Long Axis': 'L: ',
|
||||
AREA: 'Area: ',
|
||||
Length: '',
|
||||
CORNERSTONEFREETEXT: '',
|
||||
};
|
||||
|
||||
function _labelToShorthand(label) {
|
||||
const shortHand = SHORT_HAND_MAP[label];
|
||||
|
||||
if (shortHand !== undefined) {
|
||||
return shortHand;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
203
extensions/cornerstone-dicom-sr/src/tools/SCOORD3DPointTool.ts
Normal file
203
extensions/cornerstone-dicom-sr/src/tools/SCOORD3DPointTool.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import {
|
||||
annotation,
|
||||
drawing,
|
||||
utilities,
|
||||
Types as cs3DToolsTypes,
|
||||
AnnotationDisplayTool,
|
||||
} from '@cornerstonejs/tools';
|
||||
import toolNames from './toolNames';
|
||||
import { Annotation } from '@cornerstonejs/tools/dist/types/types';
|
||||
|
||||
export default class SCOORD3DPointTool extends AnnotationDisplayTool {
|
||||
static toolName = toolNames.SRSCOORD3DPoint;
|
||||
|
||||
constructor(
|
||||
toolProps = {},
|
||||
defaultToolProps = {
|
||||
configuration: {},
|
||||
}
|
||||
) {
|
||||
super(toolProps, defaultToolProps);
|
||||
}
|
||||
|
||||
_getTextBoxLinesFromLabels(labels) {
|
||||
// TODO -> max 5 for now (label + shortAxis + longAxis), need a generic solution for this!
|
||||
|
||||
const labelLength = Math.min(labels.length, 5);
|
||||
const lines = [];
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// This tool should not inherit from AnnotationTool and we should not need
|
||||
// to add the following lines.
|
||||
isPointNearTool = () => null;
|
||||
getHandleNearImagePoint = () => null;
|
||||
|
||||
renderAnnotation = (enabledElement: Types.IEnabledElement, svgDrawingHelper: any): void => {
|
||||
const { viewport } = enabledElement;
|
||||
const { element } = viewport;
|
||||
|
||||
const annotations = annotation.state.getAnnotations(this.getToolName(), element);
|
||||
|
||||
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
|
||||
if (!annotations?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter toolData to only render the data for the active SR.
|
||||
const filteredAnnotations = annotations;
|
||||
if (!viewport._actors?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
|
||||
toolGroupId: this.toolGroupId,
|
||||
toolName: this.getToolName(),
|
||||
viewportId: enabledElement.viewport.id,
|
||||
};
|
||||
|
||||
for (let i = 0; i < filteredAnnotations.length; i++) {
|
||||
const annotation = filteredAnnotations[i];
|
||||
|
||||
const annotationUID = annotation.annotationUID;
|
||||
const { renderableData } = annotation.data;
|
||||
const { POINT: points } = renderableData;
|
||||
|
||||
styleSpecifier.annotationUID = annotationUID;
|
||||
|
||||
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
|
||||
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
|
||||
const color = this.getStyle('color', styleSpecifier, annotation);
|
||||
|
||||
const options = {
|
||||
color,
|
||||
lineDash,
|
||||
lineWidth,
|
||||
};
|
||||
|
||||
const point = points[0][0];
|
||||
|
||||
// check if viewport can render it
|
||||
const viewable = viewport.isReferenceViewable(
|
||||
{ FrameOfReferenceUID: annotation.metadata.FrameOfReferenceUID, cameraFocalPoint: point },
|
||||
{ asNearbyProjection: true }
|
||||
);
|
||||
|
||||
if (!viewable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// render the point
|
||||
const arrowPointCanvas = viewport.worldToCanvas(point);
|
||||
// Todo: configure this
|
||||
const arrowEndCanvas = [arrowPointCanvas[0] + 20, arrowPointCanvas[1] + 20];
|
||||
const canvasCoordinates = [arrowPointCanvas, arrowEndCanvas];
|
||||
|
||||
drawing.drawArrow(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
'1',
|
||||
canvasCoordinates[1],
|
||||
canvasCoordinates[0],
|
||||
{
|
||||
color: options.color,
|
||||
width: options.lineWidth,
|
||||
}
|
||||
);
|
||||
|
||||
this.renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options = {}
|
||||
) {
|
||||
if (!canvasCoordinates || !annotation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { annotationUID, data = {} } = annotation;
|
||||
const { labels } = data;
|
||||
|
||||
const textLines = [];
|
||||
|
||||
for (const label of labels) {
|
||||
// make this generic
|
||||
// fix this
|
||||
if (label.label === '363698007') {
|
||||
textLines.push(`Finding Site: ${label.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { color } = options;
|
||||
|
||||
const adaptedCanvasCoordinates = canvasCoordinates;
|
||||
// adapt coordinates if there is an adapter
|
||||
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(adaptedCanvasCoordinates);
|
||||
|
||||
if (!annotation.data?.handles?.textBox?.worldPosition) {
|
||||
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(canvasTextBoxCoords);
|
||||
}
|
||||
|
||||
const textBoxPosition = viewport.worldToCanvas(annotation.data.handles.textBox.worldPosition);
|
||||
|
||||
const textBoxUID = '1';
|
||||
const textBoxOptions = this.getLinkedTextBoxStyle(styleSpecifier, annotation);
|
||||
|
||||
const boundingBox = drawing.drawLinkedTextBox(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
textBoxUID,
|
||||
textLines,
|
||||
textBoxPosition,
|
||||
canvasCoordinates,
|
||||
{},
|
||||
{
|
||||
...textBoxOptions,
|
||||
color,
|
||||
}
|
||||
);
|
||||
|
||||
const { x: left, y: top, width, height } = boundingBox;
|
||||
|
||||
annotation.data.handles.textBox.worldBoundingBox = {
|
||||
topLeft: viewport.canvasToWorld([left, top]),
|
||||
topRight: viewport.canvasToWorld([left + width, top]),
|
||||
bottomLeft: viewport.canvasToWorld([left, top + height]),
|
||||
bottomRight: viewport.canvasToWorld([left + width, top + height]),
|
||||
};
|
||||
}
|
||||
|
||||
public getLinkedTextBoxStyle(
|
||||
specifications: cs3DToolsTypes.AnnotationStyle.StyleSpecifier,
|
||||
annotation?: Annotation
|
||||
): Record<string, unknown> {
|
||||
// Todo: this function can be used to set different styles for different toolMode
|
||||
// for the textBox.
|
||||
|
||||
return {
|
||||
visibility: this.getStyle('textBoxVisibility', specifications, annotation),
|
||||
fontFamily: this.getStyle('textBoxFontFamily', specifications, annotation),
|
||||
fontSize: this.getStyle('textBoxFontSize', specifications, annotation),
|
||||
color: this.getStyle('textBoxColor', specifications, annotation),
|
||||
shadow: this.getStyle('textBoxShadow', specifications, annotation),
|
||||
background: this.getStyle('textBoxBackground', specifications, annotation),
|
||||
lineWidth: this.getStyle('textBoxLinkLineWidth', specifications, annotation),
|
||||
lineDash: this.getStyle('textBoxLinkLineDash', specifications, annotation),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getEnabledElement } from '@cornerstonejs/core';
|
||||
|
||||
const state = {
|
||||
TrackingUniqueIdentifier: null,
|
||||
trackingIdentifiersByViewportId: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* This file is being used to store the per-viewport state of the SR tools,
|
||||
* Since, all the toolStates are added to the cornerstoneTools, when displaying the SRTools,
|
||||
* if there are two viewports rendering the same imageId, we don't want to show
|
||||
* the same SR annotation twice on irrelevant viewport, hence, we are storing the state
|
||||
* of the SR tools in state here, so that we can filter them later.
|
||||
*/
|
||||
|
||||
function setTrackingUniqueIdentifiersForElement(
|
||||
element,
|
||||
trackingUniqueIdentifiers,
|
||||
activeIndex = 0
|
||||
) {
|
||||
const enabledElement = getEnabledElement(element);
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
state.trackingIdentifiersByViewportId[viewport.id] = {
|
||||
trackingUniqueIdentifiers,
|
||||
activeIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function setActiveTrackingUniqueIdentifierForElement(element, TrackingUniqueIdentifier) {
|
||||
const enabledElement = getEnabledElement(element);
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
const trackingIdentifiersForElement = state.trackingIdentifiersByViewportId[viewport.id];
|
||||
|
||||
if (trackingIdentifiersForElement) {
|
||||
const activeIndex = trackingIdentifiersForElement.trackingUniqueIdentifiers.findIndex(
|
||||
tuid => tuid === TrackingUniqueIdentifier
|
||||
);
|
||||
|
||||
trackingIdentifiersForElement.activeIndex = activeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackingUniqueIdentifiersForElement(element) {
|
||||
const enabledElement = getEnabledElement(element);
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
if (state.trackingIdentifiersByViewportId[viewport.id]) {
|
||||
return state.trackingIdentifiersByViewportId[viewport.id];
|
||||
}
|
||||
|
||||
return { trackingUniqueIdentifiers: [] };
|
||||
}
|
||||
|
||||
export {
|
||||
setTrackingUniqueIdentifiersForElement,
|
||||
setActiveTrackingUniqueIdentifierForElement,
|
||||
getTrackingUniqueIdentifiersForElement,
|
||||
};
|
||||
15
extensions/cornerstone-dicom-sr/src/tools/toolNames.ts
Normal file
15
extensions/cornerstone-dicom-sr/src/tools/toolNames.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const toolNames = {
|
||||
DICOMSRDisplay: 'DICOMSRDisplay',
|
||||
SRLength: 'SRLength',
|
||||
SRBidirectional: 'SRBidirectional',
|
||||
SREllipticalROI: 'SREllipticalROI',
|
||||
SRCircleROI: 'SRCircleROI',
|
||||
SRArrowAnnotate: 'SRArrowAnnotate',
|
||||
SRAngle: 'SRAngle',
|
||||
SRCobbAngle: 'SRCobbAngle',
|
||||
SRRectangleROI: 'SRRectangleROI',
|
||||
SRPlanarFreehandROI: 'SRPlanarFreehandROI',
|
||||
SRSCOORD3DPoint: 'SRSCOORD3DPoint',
|
||||
};
|
||||
|
||||
export default toolNames;
|
||||
@@ -0,0 +1,62 @@
|
||||
const SRSCOOR3DProbe = {
|
||||
toAnnotation: measurement => {},
|
||||
|
||||
/**
|
||||
* Maps cornerstone annotation event data to measurement service format.
|
||||
*
|
||||
* @param {Object} cornerstone Cornerstone event data
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
toMeasurement: (
|
||||
csToolsEventDetail,
|
||||
displaySetService,
|
||||
CornerstoneViewportService,
|
||||
getValueTypeFromToolType,
|
||||
customizationService
|
||||
) => {
|
||||
const { annotation } = csToolsEventDetail;
|
||||
const { metadata, data, annotationUID } = annotation;
|
||||
|
||||
if (!metadata || !data) {
|
||||
console.warn('Probe tool: Missing metadata or data');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { toolName } = metadata;
|
||||
const { points } = data.handles;
|
||||
|
||||
const displayText = getDisplayText(annotation);
|
||||
return {
|
||||
uid: annotationUID,
|
||||
points,
|
||||
metadata,
|
||||
toolName: metadata.toolName,
|
||||
label: data.label,
|
||||
displayText: displayText,
|
||||
data: data.cachedStats,
|
||||
type: getValueTypeFromToolType?.(toolName) ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function getDisplayText(annotation) {
|
||||
const { data } = annotation;
|
||||
|
||||
if (!data) {
|
||||
return [''];
|
||||
}
|
||||
const { labels } = data;
|
||||
|
||||
const displayText = [];
|
||||
|
||||
for (const label of labels) {
|
||||
// make this generic
|
||||
if (label.label === '33636980076') {
|
||||
displayText.push(`Finding Site: ${label.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return displayText;
|
||||
}
|
||||
|
||||
export default SRSCOOR3DProbe;
|
||||
67
extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts
Normal file
67
extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Types, annotation } from '@cornerstonejs/tools';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
import getRenderableData from './getRenderableData';
|
||||
import toolNames from '../tools/toolNames';
|
||||
|
||||
export default function addSRAnnotation(measurement, imageId, frameNumber) {
|
||||
let toolName = toolNames.DICOMSRDisplay;
|
||||
const renderableData = measurement.coords.reduce((acc, coordProps) => {
|
||||
acc[coordProps.GraphicType] = acc[coordProps.GraphicType] || [];
|
||||
acc[coordProps.GraphicType].push(getRenderableData({ ...coordProps, imageId }));
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const { TrackingUniqueIdentifier } = measurement;
|
||||
const { ValueType: valueType, GraphicType: graphicType } = measurement.coords[0];
|
||||
const graphicTypePoints = renderableData[graphicType];
|
||||
|
||||
/** TODO: Read the tool name from the DICOM SR identification type in the future. */
|
||||
let frameOfReferenceUID = null;
|
||||
|
||||
if (imageId) {
|
||||
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
|
||||
frameOfReferenceUID = imagePlaneModule?.frameOfReferenceUID;
|
||||
}
|
||||
|
||||
if (valueType === 'SCOORD3D') {
|
||||
toolName = toolNames.SRSCOORD3DPoint;
|
||||
|
||||
// get the ReferencedFrameOfReferenceUID from the measurement
|
||||
frameOfReferenceUID = measurement.coords[0].ReferencedFrameOfReferenceSequence;
|
||||
}
|
||||
|
||||
const SRAnnotation: Types.Annotation = {
|
||||
annotationUID: TrackingUniqueIdentifier,
|
||||
highlighted: false,
|
||||
isLocked: false,
|
||||
invalidated: false,
|
||||
metadata: {
|
||||
toolName,
|
||||
valueType,
|
||||
graphicType,
|
||||
FrameOfReferenceUID: frameOfReferenceUID,
|
||||
referencedImageId: imageId,
|
||||
},
|
||||
data: {
|
||||
label: measurement.labels?.[0]?.value || undefined,
|
||||
displayText: measurement.displayText || undefined,
|
||||
handles: {
|
||||
textBox: measurement.textBox ?? {},
|
||||
points: graphicTypePoints[0],
|
||||
},
|
||||
cachedStats: {},
|
||||
frameNumber,
|
||||
renderableData,
|
||||
TrackingUniqueIdentifier,
|
||||
labels: measurement.labels,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* const annotationManager = annotation.annotationState.getAnnotationManager();
|
||||
* was not triggering annotation_added events.
|
||||
*/
|
||||
annotation.state.addAnnotation(SRAnnotation);
|
||||
console.debug('Adding SR annotation:', SRAnnotation);
|
||||
}
|
||||
14
extensions/cornerstone-dicom-sr/src/utils/addToolInstance.ts
Normal file
14
extensions/cornerstone-dicom-sr/src/utils/addToolInstance.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { addTool } from '@cornerstonejs/tools';
|
||||
|
||||
export default function addToolInstance(name: string, toolClass, configuration = {}): void {
|
||||
class InstanceClass extends toolClass {
|
||||
static toolName = name;
|
||||
constructor(toolProps, defaultToolProps) {
|
||||
toolProps.configuration = toolProps.configuration
|
||||
? { ...toolProps.configuration, ...configuration }
|
||||
: configuration;
|
||||
super(toolProps, defaultToolProps);
|
||||
}
|
||||
}
|
||||
addTool(InstanceClass);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { DisplaySetService, classes } from '@ohif/core';
|
||||
|
||||
const ImageSet = classes.ImageSet;
|
||||
|
||||
const findInstance = (measurement, displaySetService: DisplaySetService) => {
|
||||
const { displaySetInstanceUID, ReferencedSOPInstanceUID: sopUid } = measurement;
|
||||
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
if (!referencedDisplaySet.images) {
|
||||
return;
|
||||
}
|
||||
return referencedDisplaySet.images.find(it => it.SOPInstanceUID === sopUid);
|
||||
};
|
||||
|
||||
/** Finds references to display sets inside the measurements
|
||||
* contained within the provided display set.
|
||||
* @return an array of instances referenced.
|
||||
*/
|
||||
const findReferencedInstances = (displaySetService: DisplaySetService, displaySet) => {
|
||||
const instances = [];
|
||||
const instanceById = {};
|
||||
for (const measurement of displaySet.measurements) {
|
||||
const { imageId } = measurement;
|
||||
if (!imageId) {
|
||||
continue;
|
||||
}
|
||||
if (instanceById[imageId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instance = findInstance(measurement, displaySetService);
|
||||
if (!instance) {
|
||||
console.log('Measurement', measurement, 'had no instances found');
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceById[imageId] = instance;
|
||||
instances.push(instance);
|
||||
}
|
||||
return instances;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new display set containing a single image instance for each
|
||||
* referenced image.
|
||||
*
|
||||
* @param displaySetService
|
||||
* @param displaySet - containing measurements referencing images.
|
||||
* @returns A new (registered/active) display set containing the referenced images
|
||||
*/
|
||||
const createReferencedImageDisplaySet = (displaySetService, displaySet) => {
|
||||
const instances = findReferencedInstances(displaySetService, displaySet);
|
||||
// This will be a member function of the created image set
|
||||
const updateInstances = function () {
|
||||
this.images.splice(
|
||||
0,
|
||||
this.images.length,
|
||||
...findReferencedInstances(displaySetService, displaySet)
|
||||
);
|
||||
this.numImageFrames = this.images.length;
|
||||
};
|
||||
|
||||
const imageSet = new ImageSet(instances);
|
||||
const instance = instances[0];
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageSet.setAttributes({
|
||||
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
|
||||
SeriesDate: instance.SeriesDate,
|
||||
SeriesTime: instance.SeriesTime,
|
||||
SeriesInstanceUID: imageSet.uid,
|
||||
StudyInstanceUID: instance.StudyInstanceUID,
|
||||
SeriesNumber: instance.SeriesNumber || 0,
|
||||
SOPClassUID: instance.SOPClassUID,
|
||||
SeriesDescription: `${displaySet.SeriesDescription} KO ${displaySet.instance.SeriesNumber}`,
|
||||
Modality: 'KO',
|
||||
isMultiFrame: false,
|
||||
numImageFrames: instances.length,
|
||||
SOPClassHandlerId: `@ohif/extension-default.sopClassHandlerModule.stack`,
|
||||
isReconstructable: false,
|
||||
// This object is made of multiple instances from other series
|
||||
isCompositeStack: true,
|
||||
madeInClient: true,
|
||||
excludeFromThumbnailBrowser: true,
|
||||
updateInstances,
|
||||
});
|
||||
|
||||
displaySetService.addDisplaySets(imageSet);
|
||||
|
||||
return imageSet;
|
||||
};
|
||||
|
||||
export default createReferencedImageDisplaySet;
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Should Find the requested instance metadata into the displaySets and return
|
||||
*
|
||||
* @param {Array} displaySets - List of displaySets
|
||||
* @param {string} SOPInstanceUID - sopInstanceUID to look for
|
||||
* @returns {Object} - instance metadata found
|
||||
*/
|
||||
const findInstanceMetadataBySopInstanceUID = (displaySets, SOPInstanceUID) => {
|
||||
let instanceFound;
|
||||
|
||||
displaySets.find(displaySet => {
|
||||
if (!displaySet.images) {
|
||||
return false;
|
||||
}
|
||||
|
||||
instanceFound = displaySet.images.find(
|
||||
instanceMetadata => instanceMetadata.getSOPInstanceUID() === SOPInstanceUID
|
||||
);
|
||||
|
||||
return !!instanceFound;
|
||||
});
|
||||
|
||||
return instanceFound;
|
||||
};
|
||||
|
||||
export default findInstanceMetadataBySopInstanceUID;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Should find the most recent Structured Report metadata
|
||||
*
|
||||
* @param {Array} studies
|
||||
* @returns {Object} Series
|
||||
*/
|
||||
const findMostRecentStructuredReport = studies => {
|
||||
let mostRecentStructuredReport;
|
||||
|
||||
studies.forEach(study => {
|
||||
const allSeries = study.getSeries ? study.getSeries() : [];
|
||||
allSeries.forEach(series => {
|
||||
// Skip series that may not have instances yet
|
||||
// This can happen if we have retrieved just the initial
|
||||
// details about the series via QIDO-RS, but not the full metadata
|
||||
if (!series.instances.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStructuredReportSeries(series)) {
|
||||
if (!mostRecentStructuredReport || compareSeriesDate(series, mostRecentStructuredReport)) {
|
||||
mostRecentStructuredReport = series;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return mostRecentStructuredReport;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if series sopClassUID matches with the supported Structured Reports sopClassUID
|
||||
*
|
||||
* @param {Object} series - Series metadata
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isStructuredReportSeries = series => {
|
||||
const supportedSopClassUIDs = ['1.2.840.10008.5.1.4.1.1.88.22', '1.2.840.10008.5.1.4.1.1.11.1'];
|
||||
|
||||
const firstInstance = series.getFirstInstance();
|
||||
const SOPClassUID = firstInstance.getData().metadata.SOPClassUID;
|
||||
|
||||
return supportedSopClassUIDs.includes(SOPClassUID);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if series1 is newer than series2
|
||||
*
|
||||
* @param {Object} series1 - Series Metadata 1
|
||||
* @param {Object} series2 - Series Metadata 2
|
||||
* @returns {boolean} true/false if series1 is newer than series2
|
||||
*/
|
||||
const compareSeriesDate = (series1, series2) => {
|
||||
return (
|
||||
series1._data.SeriesDate > series2._data.SeriesDate ||
|
||||
(series1._data.SeriesDate === series2._data.SeriesDate &&
|
||||
series1._data.SeriesTime > series2._data.SeriesTime)
|
||||
);
|
||||
};
|
||||
|
||||
export default findMostRecentStructuredReport;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
/**
|
||||
* Formatters used to format each of the content items (SR "nodes") which can be
|
||||
* text, code, UID ref, number, person name, date, time and date time. Each
|
||||
* formatter must be a function with the following signature:
|
||||
*
|
||||
* [VALUE_TYPE]: (contentItem) => string
|
||||
*
|
||||
*/
|
||||
const contentItemFormatters = {
|
||||
TEXT: contentItem => contentItem.TextValue,
|
||||
CODE: contentItem => contentItem.ConceptCodeSequence?.[0]?.CodeMeaning,
|
||||
UIDREF: contentItem => contentItem.UID,
|
||||
NUM: contentItem => {
|
||||
const measuredValue = contentItem.MeasuredValueSequence?.[0];
|
||||
|
||||
if (!measuredValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { NumericValue, MeasurementUnitsCodeSequence } = measuredValue;
|
||||
const { CodeValue } = MeasurementUnitsCodeSequence;
|
||||
|
||||
return `${NumericValue} ${CodeValue}`;
|
||||
},
|
||||
PNAME: contentItem => {
|
||||
const personName = contentItem.PersonName?.[0]?.Alphabetic;
|
||||
return personName ? utils.formatPN(personName) : undefined;
|
||||
},
|
||||
DATE: contentItem => {
|
||||
const { Date } = contentItem;
|
||||
return Date ? utils.formatDate(Date) : undefined;
|
||||
},
|
||||
TIME: contentItem => {
|
||||
const { Time } = contentItem;
|
||||
return Time ? utils.formatTime(Time) : undefined;
|
||||
},
|
||||
DATETIME: contentItem => {
|
||||
const { DateTime } = contentItem;
|
||||
|
||||
if (typeof DateTime !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 14 characters because it should be something like 20180614113714
|
||||
if (DateTime.length < 14) {
|
||||
return DateTime;
|
||||
}
|
||||
|
||||
const dicomDate = DateTime.substring(0, 8);
|
||||
const dicomTime = DateTime.substring(8, 14);
|
||||
const formattedDate = utils.formatDate(dicomDate);
|
||||
const formattedTime = utils.formatTime(dicomTime);
|
||||
|
||||
return `${formattedDate} ${formattedTime}`;
|
||||
},
|
||||
};
|
||||
|
||||
function formatContentItemValue(contentItem) {
|
||||
const { ValueType } = contentItem;
|
||||
const fnFormat = contentItemFormatters[ValueType];
|
||||
|
||||
return fnFormat ? fnFormat(contentItem) : `[${ValueType} is not supported]`;
|
||||
}
|
||||
|
||||
export { formatContentItemValue as default, formatContentItemValue };
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Retrieve a list of all displaySets of all studies
|
||||
*
|
||||
* @param {Object} studies - List of studies loaded into the viewer
|
||||
* @returns {Object} List of DisplaySets
|
||||
*/
|
||||
const getAllDisplaySets = studies => {
|
||||
let allDisplaySets = [];
|
||||
|
||||
studies.forEach(study => {
|
||||
if (study.getDisplaySets) {
|
||||
allDisplaySets = allDisplaySets.concat(study.getDisplaySets());
|
||||
}
|
||||
});
|
||||
|
||||
return allDisplaySets;
|
||||
};
|
||||
|
||||
export default getAllDisplaySets;
|
||||
@@ -0,0 +1,103 @@
|
||||
import OHIF from '@ohif/core';
|
||||
import { annotation } from '@cornerstonejs/tools';
|
||||
const { log } = OHIF;
|
||||
|
||||
function getFilteredCornerstoneToolState(measurementData, additionalFindingTypes) {
|
||||
const filteredToolState = {};
|
||||
|
||||
function addToFilteredToolState(annotation, toolType) {
|
||||
if (!annotation.metadata?.referencedImageId) {
|
||||
log.warn(`[DICOMSR] No referencedImageId found for ${toolType} ${annotation.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = annotation.metadata.referencedImageId;
|
||||
|
||||
if (!filteredToolState[imageId]) {
|
||||
filteredToolState[imageId] = {};
|
||||
}
|
||||
|
||||
const imageIdSpecificToolState = filteredToolState[imageId];
|
||||
|
||||
if (!imageIdSpecificToolState[toolType]) {
|
||||
imageIdSpecificToolState[toolType] = {
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const measurementDataI = measurementData.find(md => md.uid === annotation.annotationUID);
|
||||
const toolData = imageIdSpecificToolState[toolType].data;
|
||||
|
||||
let { finding } = measurementDataI;
|
||||
const findingSites = [];
|
||||
|
||||
// NOTE -> We use the CORNERSTONEJS coding schemeDesignator which we have
|
||||
// defined in the @cornerstonejs/adapters
|
||||
if (measurementDataI.label) {
|
||||
if (additionalFindingTypes.includes(toolType)) {
|
||||
finding = {
|
||||
CodeValue: 'CORNERSTONEFREETEXT',
|
||||
CodingSchemeDesignator: 'CORNERSTONEJS',
|
||||
CodeMeaning: measurementDataI.label,
|
||||
};
|
||||
} else {
|
||||
findingSites.push({
|
||||
CodeValue: 'CORNERSTONEFREETEXT',
|
||||
CodingSchemeDesignator: 'CORNERSTONEJS',
|
||||
CodeMeaning: measurementDataI.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (measurementDataI.findingSites) {
|
||||
findingSites.push(...measurementDataI.findingSites);
|
||||
}
|
||||
|
||||
const measurement = Object.assign({}, annotation, {
|
||||
finding,
|
||||
findingSites,
|
||||
});
|
||||
|
||||
toolData.push(measurement);
|
||||
}
|
||||
|
||||
const uidFilter = measurementData.map(md => md.uid);
|
||||
const uids = uidFilter.slice();
|
||||
|
||||
const annotationManager = annotation.state.getAnnotationManager();
|
||||
const framesOfReference = annotationManager.getFramesOfReference();
|
||||
|
||||
for (let i = 0; i < framesOfReference.length; i++) {
|
||||
const frameOfReference = framesOfReference[i];
|
||||
|
||||
const frameOfReferenceAnnotations = annotationManager.getAnnotations(frameOfReference);
|
||||
|
||||
const toolTypes = Object.keys(frameOfReferenceAnnotations);
|
||||
|
||||
for (let j = 0; j < toolTypes.length; j++) {
|
||||
const toolType = toolTypes[j];
|
||||
|
||||
const annotations = frameOfReferenceAnnotations[toolType];
|
||||
|
||||
if (annotations) {
|
||||
for (let k = 0; k < annotations.length; k++) {
|
||||
const annotation = annotations[k];
|
||||
const uidIndex = uids.findIndex(uid => uid === annotation.annotationUID);
|
||||
|
||||
if (uidIndex !== -1) {
|
||||
addToFilteredToolState(annotation, toolType);
|
||||
uids.splice(uidIndex, 1);
|
||||
|
||||
if (!uids.length) {
|
||||
return filteredToolState;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredToolState;
|
||||
}
|
||||
|
||||
export default getFilteredCornerstoneToolState;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
|
||||
|
||||
/**
|
||||
* Extracts the label from the toolData imported from dcmjs. We need to do this
|
||||
* as dcmjs does not depeend on OHIF/the measurementService, it just produces data for cornestoneTools.
|
||||
* This optional data is available for the consumer to process if they wish to.
|
||||
* @param {object} toolData The tooldata relating to the
|
||||
*
|
||||
* @returns {string} The extracted label.
|
||||
*/
|
||||
export default function getLabelFromDCMJSImportedToolData(toolData) {
|
||||
const { findingSites = [], finding } = toolData;
|
||||
|
||||
let freeTextLabel = findingSites.find(
|
||||
fs => fs.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
|
||||
);
|
||||
|
||||
if (freeTextLabel) {
|
||||
return freeTextLabel.CodeMeaning;
|
||||
}
|
||||
|
||||
if (finding && finding.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT) {
|
||||
return finding.CodeMeaning;
|
||||
}
|
||||
}
|
||||
142
extensions/cornerstone-dicom-sr/src/utils/getRenderableData.ts
Normal file
142
extensions/cornerstone-dicom-sr/src/utils/getRenderableData.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import { metaData, utilities, Types as csTypes } from '@cornerstonejs/core';
|
||||
|
||||
import { SCOORDTypes } from '../enums';
|
||||
|
||||
const EPSILON = 1e-4;
|
||||
|
||||
const getRenderableCoords = ({ GraphicData, ValueType, imageId }) => {
|
||||
const renderableData = [];
|
||||
if (ValueType === 'SCOORD3D') {
|
||||
for (let i = 0; i < GraphicData.length; i += 3) {
|
||||
renderableData.push([GraphicData[i], GraphicData[i + 1], GraphicData[i + 2]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < GraphicData.length; i += 2) {
|
||||
const worldPos = utilities.imageToWorldCoords(imageId, [GraphicData[i], GraphicData[i + 1]]);
|
||||
renderableData.push(worldPos);
|
||||
}
|
||||
}
|
||||
return renderableData;
|
||||
};
|
||||
|
||||
function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
|
||||
let renderableData = [];
|
||||
|
||||
switch (GraphicType) {
|
||||
case SCOORDTypes.POINT:
|
||||
case SCOORDTypes.MULTIPOINT:
|
||||
case SCOORDTypes.POLYLINE: {
|
||||
renderableData = getRenderableCoords({ GraphicData, ValueType, imageId });
|
||||
break;
|
||||
}
|
||||
case SCOORDTypes.CIRCLE: {
|
||||
const pointsWorld: csTypes.Point3[] = getRenderableCoords({
|
||||
GraphicData,
|
||||
ValueType,
|
||||
imageId,
|
||||
});
|
||||
// We do not have an explicit draw circle svg helper in Cornerstone3D at
|
||||
// this time, but we can use the ellipse svg helper to draw a circle, so
|
||||
// here we reshape the data for that purpose.
|
||||
const center = pointsWorld[0];
|
||||
const onPerimeter = pointsWorld[1];
|
||||
const radius = vec3.distance(center, onPerimeter);
|
||||
|
||||
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
|
||||
if (!imagePlaneModule) {
|
||||
throw new Error('No imagePlaneModule found');
|
||||
}
|
||||
|
||||
const {
|
||||
columnCosines,
|
||||
rowCosines,
|
||||
}: {
|
||||
columnCosines: csTypes.Point3;
|
||||
rowCosines: csTypes.Point3;
|
||||
} = imagePlaneModule;
|
||||
|
||||
// we need to get major/minor axis (which are both the same size major = minor)
|
||||
|
||||
const firstAxisStart = vec3.create();
|
||||
vec3.scaleAndAdd(firstAxisStart, center, columnCosines, radius);
|
||||
|
||||
const firstAxisEnd = vec3.create();
|
||||
vec3.scaleAndAdd(firstAxisEnd, center, columnCosines, -radius);
|
||||
|
||||
const secondAxisStart = vec3.create();
|
||||
vec3.scaleAndAdd(secondAxisStart, center, rowCosines, radius);
|
||||
|
||||
const secondAxisEnd = vec3.create();
|
||||
vec3.scaleAndAdd(secondAxisEnd, center, rowCosines, -radius);
|
||||
|
||||
renderableData = [
|
||||
firstAxisStart as csTypes.Point3,
|
||||
firstAxisEnd as csTypes.Point3,
|
||||
secondAxisStart as csTypes.Point3,
|
||||
secondAxisEnd as csTypes.Point3,
|
||||
];
|
||||
|
||||
break;
|
||||
}
|
||||
case SCOORDTypes.ELLIPSE: {
|
||||
// GraphicData is ordered as [majorAxisStartX, majorAxisStartY, majorAxisEndX, majorAxisEndY, minorAxisStartX, minorAxisStartY, minorAxisEndX, minorAxisEndY]
|
||||
// But Cornerstone3D points are ordered as top, bottom, left, right for the
|
||||
// ellipse so we need to identify if the majorAxis is horizontal or vertical
|
||||
// and then choose the correct points to use for the ellipse.
|
||||
const pointsWorld: csTypes.Point3[] = getRenderableCoords({
|
||||
GraphicData,
|
||||
ValueType,
|
||||
imageId,
|
||||
});
|
||||
|
||||
const majorAxisStart = vec3.fromValues(...pointsWorld[0]);
|
||||
const majorAxisEnd = vec3.fromValues(...pointsWorld[1]);
|
||||
const minorAxisStart = vec3.fromValues(...pointsWorld[2]);
|
||||
const minorAxisEnd = vec3.fromValues(...pointsWorld[3]);
|
||||
|
||||
const majorAxisVec = vec3.create();
|
||||
vec3.sub(majorAxisVec, majorAxisEnd, majorAxisStart);
|
||||
|
||||
// normalize majorAxisVec to avoid scaling issues
|
||||
vec3.normalize(majorAxisVec, majorAxisVec);
|
||||
|
||||
const minorAxisVec = vec3.create();
|
||||
vec3.sub(minorAxisVec, minorAxisEnd, minorAxisStart);
|
||||
vec3.normalize(minorAxisVec, minorAxisVec);
|
||||
|
||||
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
|
||||
|
||||
if (!imagePlaneModule) {
|
||||
throw new Error('imageId does not have imagePlaneModule metadata');
|
||||
}
|
||||
|
||||
const { columnCosines }: { columnCosines: csTypes.Point3 } = imagePlaneModule;
|
||||
|
||||
// find which axis is parallel to the columnCosines
|
||||
const columnCosinesVec = vec3.fromValues(...columnCosines);
|
||||
|
||||
const projectedMajorAxisOnColVec = Math.abs(vec3.dot(columnCosinesVec, majorAxisVec));
|
||||
const projectedMinorAxisOnColVec = Math.abs(vec3.dot(columnCosinesVec, minorAxisVec));
|
||||
|
||||
const absoluteOfMajorDotProduct = Math.abs(projectedMajorAxisOnColVec);
|
||||
const absoluteOfMinorDotProduct = Math.abs(projectedMinorAxisOnColVec);
|
||||
|
||||
renderableData = [];
|
||||
if (Math.abs(absoluteOfMajorDotProduct - 1) < EPSILON) {
|
||||
renderableData = [pointsWorld[0], pointsWorld[1], pointsWorld[2], pointsWorld[3]];
|
||||
} else if (Math.abs(absoluteOfMinorDotProduct - 1) < EPSILON) {
|
||||
renderableData = [pointsWorld[2], pointsWorld[3], pointsWorld[0], pointsWorld[1]];
|
||||
} else {
|
||||
console.warn('OBLIQUE ELLIPSE NOT YET SUPPORTED');
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn('Unsupported GraphicType:', GraphicType);
|
||||
}
|
||||
|
||||
return renderableData;
|
||||
}
|
||||
|
||||
export default getRenderableData;
|
||||
@@ -0,0 +1,299 @@
|
||||
import { utilities, metaData } from '@cornerstonejs/core';
|
||||
import OHIF, { DicomMetadataStore } from '@ohif/core';
|
||||
import getLabelFromDCMJSImportedToolData from './getLabelFromDCMJSImportedToolData';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
import { annotation as CsAnnotation } from '@cornerstonejs/tools';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
|
||||
const { locking } = CsAnnotation;
|
||||
const { guid } = OHIF.utils;
|
||||
const { MeasurementReport, CORNERSTONE_3D_TAG } = adaptersSR.Cornerstone3D;
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
|
||||
|
||||
const convertCode = (codingValues, code) => {
|
||||
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') {
|
||||
return;
|
||||
}
|
||||
const ref = `${code.CodingSchemeDesignator}:${code.CodeValue}`;
|
||||
const ret = { ...codingValues[ref], ref, ...code, text: code.CodeMeaning };
|
||||
return ret;
|
||||
};
|
||||
|
||||
const convertSites = (codingValues, sites) => {
|
||||
if (!sites || !sites.length) {
|
||||
return;
|
||||
}
|
||||
const ret = [];
|
||||
// Do as a loop to convert away from Proxy instances
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
// Deal with irregular conversion from dcmjs
|
||||
const site = convertCode(codingValues, sites[i][0] || sites[i]);
|
||||
if (site) {
|
||||
ret.push(site);
|
||||
}
|
||||
}
|
||||
return (ret.length && ret) || undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hydrates a structured report, for default viewports.
|
||||
*
|
||||
*/
|
||||
export default function hydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, appConfig }: withAppTypes,
|
||||
displaySetInstanceUID
|
||||
) {
|
||||
const annotationManager = CsAnnotation.state.getAnnotationManager();
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
const { measurementService, displaySetService, customizationService } = servicesManager.services;
|
||||
|
||||
const codingValues = customizationService.getCustomization('codingValues', {});
|
||||
|
||||
const { disableEditing } = customizationService.getCustomization(
|
||||
'PanelMeasurement.disableEditing',
|
||||
{
|
||||
id: 'default.disableEditing',
|
||||
disableEditing: false,
|
||||
}
|
||||
);
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
// TODO -> We should define a strict versioning somewhere.
|
||||
const mappings = measurementService.getSourceMappings(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
if (!mappings || !mappings.length) {
|
||||
throw new Error(
|
||||
`Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.`
|
||||
);
|
||||
}
|
||||
|
||||
const instance = DicomMetadataStore.getInstance(
|
||||
displaySet.StudyInstanceUID,
|
||||
displaySet.SeriesInstanceUID,
|
||||
displaySet.SOPInstanceUID
|
||||
);
|
||||
|
||||
const sopInstanceUIDToImageId = {};
|
||||
const imageIdsForToolState = {};
|
||||
|
||||
displaySet.measurements.forEach(measurement => {
|
||||
const { ReferencedSOPInstanceUID, imageId, frameNumber } = measurement;
|
||||
|
||||
if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) {
|
||||
sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId;
|
||||
imageIdsForToolState[ReferencedSOPInstanceUID] = [];
|
||||
}
|
||||
if (!imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber]) {
|
||||
imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber] = imageId;
|
||||
}
|
||||
});
|
||||
|
||||
const datasetToUse = _mapLegacyDataSet(instance);
|
||||
|
||||
// Use dcmjs to generate toolState.
|
||||
let storedMeasurementByAnnotationType = MeasurementReport.generateToolState(
|
||||
datasetToUse,
|
||||
// NOTE: we need to pass in the imageIds to dcmjs since the we use them
|
||||
// for the imageToWorld transformation. The following assumes that the order
|
||||
// that measurements were added to the display set are the same order as
|
||||
// the measurementGroups in the instance.
|
||||
sopInstanceUIDToImageId,
|
||||
utilities.imageToWorldCoords,
|
||||
metaData
|
||||
);
|
||||
|
||||
const onBeforeSRHydration =
|
||||
customizationService.getModeCustomization('onBeforeSRHydration')?.value;
|
||||
|
||||
if (typeof onBeforeSRHydration === 'function') {
|
||||
storedMeasurementByAnnotationType = onBeforeSRHydration({
|
||||
storedMeasurementByAnnotationType,
|
||||
displaySet,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter what is found by DICOM SR to measurements we support.
|
||||
const mappingDefinitions = mappings.map(m => m.annotationType);
|
||||
const hydratableMeasurementsInSR = {};
|
||||
|
||||
Object.keys(storedMeasurementByAnnotationType).forEach(key => {
|
||||
if (mappingDefinitions.includes(key)) {
|
||||
hydratableMeasurementsInSR[key] = storedMeasurementByAnnotationType[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Set the series touched as tracked.
|
||||
const imageIds = [];
|
||||
|
||||
// TODO: notification if no hydratable?
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
|
||||
const imageId =
|
||||
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
|
||||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
|
||||
|
||||
if (!imageIds.includes(imageId)) {
|
||||
imageIds.push(imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let targetStudyInstanceUID;
|
||||
const SeriesInstanceUIDs = [];
|
||||
|
||||
for (let i = 0; i < imageIds.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
|
||||
|
||||
if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) {
|
||||
SeriesInstanceUIDs.push(SeriesInstanceUID);
|
||||
}
|
||||
|
||||
if (!targetStudyInstanceUID) {
|
||||
targetStudyInstanceUID = StudyInstanceUID;
|
||||
} else if (targetStudyInstanceUID !== StudyInstanceUID) {
|
||||
console.warn('NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.');
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
|
||||
const imageId =
|
||||
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
|
||||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
|
||||
|
||||
toolData.uid = guid();
|
||||
|
||||
const instance = metaData.get('instance', imageId);
|
||||
const {
|
||||
FrameOfReferenceUID,
|
||||
// SOPInstanceUID,
|
||||
// SeriesInstanceUID,
|
||||
// StudyInstanceUID,
|
||||
} = instance;
|
||||
|
||||
const annotation = {
|
||||
annotationUID: toolData.annotation.annotationUID,
|
||||
data: toolData.annotation.data,
|
||||
metadata: {
|
||||
toolName: annotationType,
|
||||
referencedImageId: imageId,
|
||||
FrameOfReferenceUID,
|
||||
},
|
||||
};
|
||||
|
||||
const source = measurementService.getSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
annotation.data.label = getLabelFromDCMJSImportedToolData(toolData);
|
||||
annotation.data.finding = convertCode(codingValues, toolData.finding?.[0]);
|
||||
annotation.data.findingSites = convertSites(codingValues, toolData.findingSites);
|
||||
annotation.data.site = annotation.data.findingSites?.[0];
|
||||
|
||||
const matchingMapping = mappings.find(m => m.annotationType === annotationType);
|
||||
|
||||
const newAnnotationUID = measurementService.addRawMeasurement(
|
||||
source,
|
||||
annotationType,
|
||||
{ annotation },
|
||||
matchingMapping.toMeasurementSchema,
|
||||
dataSource
|
||||
);
|
||||
|
||||
if (disableEditing) {
|
||||
const addedAnnotation = annotationManager.getAnnotation(newAnnotationUID);
|
||||
locking.setAnnotationLocked(addedAnnotation, true);
|
||||
}
|
||||
|
||||
if (!imageIds.includes(imageId)) {
|
||||
imageIds.push(imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
displaySet.isHydrated = true;
|
||||
|
||||
return {
|
||||
StudyInstanceUID: targetStudyInstanceUID,
|
||||
SeriesInstanceUIDs,
|
||||
};
|
||||
}
|
||||
|
||||
function _mapLegacyDataSet(dataset) {
|
||||
const REPORT = 'Imaging Measurements';
|
||||
const GROUP = 'Measurement Group';
|
||||
const TRACKING_IDENTIFIER = 'Tracking Identifier';
|
||||
|
||||
// Identify the Imaging Measurements
|
||||
const imagingMeasurementContent = toArray(dataset.ContentSequence).find(
|
||||
codeMeaningEquals(REPORT)
|
||||
);
|
||||
|
||||
// Retrieve the Measurements themselves
|
||||
const measurementGroups = toArray(imagingMeasurementContent.ContentSequence).filter(
|
||||
codeMeaningEquals(GROUP)
|
||||
);
|
||||
|
||||
// For each of the supported measurement types, compute the measurement data
|
||||
const measurementData = {};
|
||||
|
||||
const cornerstoneToolClasses = MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
|
||||
|
||||
const registeredToolClasses = [];
|
||||
|
||||
Object.keys(cornerstoneToolClasses).forEach(key => {
|
||||
registeredToolClasses.push(cornerstoneToolClasses[key]);
|
||||
measurementData[key] = [];
|
||||
});
|
||||
|
||||
measurementGroups.forEach((measurementGroup, index) => {
|
||||
const measurementGroupContentSequence = toArray(measurementGroup.ContentSequence);
|
||||
|
||||
const TrackingIdentifierGroup = measurementGroupContentSequence.find(
|
||||
contentItem => contentItem.ConceptNameCodeSequence.CodeMeaning === TRACKING_IDENTIFIER
|
||||
);
|
||||
|
||||
const TrackingIdentifier = TrackingIdentifierGroup.TextValue;
|
||||
|
||||
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
|
||||
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
|
||||
cornerstoneTag = CORNERSTONE_3D_TAG;
|
||||
}
|
||||
|
||||
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
|
||||
|
||||
TrackingIdentifierGroup.TextValue = mappedTrackingIdentifier;
|
||||
});
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
const toArray = function (x) {
|
||||
return Array.isArray(x) ? x : [x];
|
||||
};
|
||||
|
||||
const codeMeaningEquals = codeMeaningName => {
|
||||
return contentItem => {
|
||||
return contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName;
|
||||
};
|
||||
};
|
||||
60
extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js
Normal file
60
extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
const cornerstoneAdapters =
|
||||
adaptersSR.Cornerstone3D.MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
|
||||
|
||||
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
|
||||
const CORNERSTONE_3D_TAG = adaptersSR.Cornerstone3D.CORNERSTONE_3D_TAG;
|
||||
|
||||
/**
|
||||
* Checks if the given `displaySet`can be rehydrated into the `measurementService`.
|
||||
*
|
||||
* @param {object} displaySet The SR `displaySet` to check.
|
||||
* @param {object[]} mappings The CornerstoneTools 4 mappings to the `measurementService`.
|
||||
* @returns {boolean} True if the SR can be rehydrated into the `measurementService`.
|
||||
*/
|
||||
export default function isRehydratable(displaySet, mappings) {
|
||||
if (!mappings || !mappings.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mappingDefinitions = mappings.map(m => m.annotationType);
|
||||
const { measurements } = displaySet;
|
||||
|
||||
const adapterKeys = Object.keys(cornerstoneAdapters).filter(
|
||||
adapterKey =>
|
||||
typeof cornerstoneAdapters[adapterKey].isValidCornerstoneTrackingIdentifier === 'function'
|
||||
);
|
||||
|
||||
const adapters = [];
|
||||
|
||||
adapterKeys.forEach(key => {
|
||||
if (mappingDefinitions.includes(key)) {
|
||||
// Must have both a dcmjs adapter and a measurementService
|
||||
// Definition in order to be a candidate for import.
|
||||
adapters.push(cornerstoneAdapters[key]);
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < measurements.length; i++) {
|
||||
const { TrackingIdentifier } = measurements[i] || {};
|
||||
const hydratable = adapters.some(adapter => {
|
||||
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
|
||||
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
|
||||
cornerstoneTag = CORNERSTONE_3D_TAG;
|
||||
}
|
||||
|
||||
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
|
||||
|
||||
return adapter.isValidCornerstoneTrackingIdentifier(mappedTrackingIdentifier);
|
||||
});
|
||||
|
||||
if (hydratable) {
|
||||
return true;
|
||||
}
|
||||
console.log('Measurement is not rehydratable', TrackingIdentifier, measurements[i]);
|
||||
}
|
||||
|
||||
console.log('No measurements found which were rehydratable');
|
||||
return false;
|
||||
}
|
||||
14
extensions/cornerstone-dicom-sr/src/utils/isToolSupported.js
Normal file
14
extensions/cornerstone-dicom-sr/src/utils/isToolSupported.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
/**
|
||||
* Checks if dcmjs has support to determined tool
|
||||
*
|
||||
* @param {string} toolName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isToolSupported = toolName => {
|
||||
const adapter = adaptersSR.Cornerstone3D;
|
||||
return !!adapter[toolName];
|
||||
};
|
||||
|
||||
export default isToolSupported;
|
||||
Reference in New Issue
Block a user