feat(SR): text structured report (TEXT, CODE, NUM, PNAME, DATE, TIME and DATETIME) (#4287)

This commit is contained in:
Leonardo Campos 2024-07-23 12:28:48 -03:00 committed by GitHub
parent 58da0324cd
commit 246ebab6eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 341 additions and 48 deletions

View File

@ -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('.')}.&nbsp;
{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),
};

View File

@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React from 'react';
import CodeNameCodeSequenceValues from '../constants/CodeNameCodeSequenceValues';
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 };

View File

@ -14,7 +14,7 @@ const MEASUREMENT_TRACKING_EXTENSION_ID = '@ohif/extension-measurement-tracking'
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup'; const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
function OHIFCornerstoneSRViewport(props: withAppTypes) { function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) {
const { children, dataSource, displaySets, viewportOptions, servicesManager, extensionManager } = const { children, dataSource, displaySets, viewportOptions, servicesManager, extensionManager } =
props; props;
@ -278,14 +278,14 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) {
*/ */
useEffect(() => { useEffect(() => {
const updateSR = async () => { const updateSR = async () => {
if (!srDisplaySet.isLoaded) { if (!srDisplaySet.isLoaded) {
await srDisplaySet.load(); await srDisplaySet.load();
} }
if (!element || !srDisplaySet.isLoaded) { if (!element || !srDisplaySet.isLoaded) {
return; return;
} }
setTrackingIdentifiers(measurementSelected); setTrackingIdentifiers(measurementSelected);
} };
updateSR(); updateSR();
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]); }, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
@ -367,7 +367,7 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) {
); );
} }
OHIFCornerstoneSRViewport.propTypes = { OHIFCornerstoneSRMeasurementViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object), displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired, viewportId: PropTypes.string.isRequired,
dataSource: PropTypes.object, dataSource: PropTypes.object,
@ -508,4 +508,4 @@ function _getStatusComponent({
); );
} }
export default OHIFCornerstoneSRViewport; export default OHIFCornerstoneSRMeasurementViewport;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,18 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
ImagingMeasurements: '126010',
MeasurementGroup: '125007',
ImageLibraryGroup: '126200',
TrackingUniqueIdentifier: '112040',
TrackingIdentifier: '112039',
Finding: '121071',
FindingSite: 'G-C0E3', // SRT
CornerstoneFreeText: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
};
export { CodeNameCodeSequenceValues as default };

View File

@ -3,6 +3,7 @@ import { utils, classes, DisplaySetService, Types } from '@ohif/core';
import addDICOMSRDisplayAnnotation from './utils/addDICOMSRDisplayAnnotation'; import addDICOMSRDisplayAnnotation from './utils/addDICOMSRDisplayAnnotation';
import isRehydratable from './utils/isRehydratable'; import isRehydratable from './utils/isRehydratable';
import { adaptersSR } from '@cornerstonejs/adapters'; import { adaptersSR } from '@cornerstonejs/adapters';
import CodeNameCodeSequenceValues from './constants/CodeNameCodeSequenceValues';
type InstanceMetadata = Types.InstanceMetadata; type InstanceMetadata = Types.InstanceMetadata;
@ -16,10 +17,11 @@ const { ImageSet, MetadataProvider: metadataProvider } = classes;
// Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport. // Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport.
const sopClassUids = [ const sopClassUids = [
'1.2.840.10008.5.1.4.1.1.88.11', //BASIC_TEXT_SR: '1.2.840.10008.5.1.4.1.1.88.11', // BASIC TEXT SR
'1.2.840.10008.5.1.4.1.1.88.22', //ENHANCED_SR: '1.2.840.10008.5.1.4.1.1.88.22', // ENHANCED SR
'1.2.840.10008.5.1.4.1.1.88.33', //COMPREHENSIVE_SR: '1.2.840.10008.5.1.4.1.1.88.33', // COMPREHENSIVE SR
'1.2.840.10008.5.1.4.1.1.88.34', //COMPREHENSIVE_3D_SR: '1.2.840.10008.5.1.4.1.1.88.34', // Comprehensive 3D SR
// '1.2.840.10008.5.1.4.1.1.88.50', // Mammography CAD SR
]; ];
const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools'; const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
@ -34,19 +36,6 @@ const validateSameStudyUID = (uid: string, instances): void => {
}); });
}; };
const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
ImagingMeasurements: '126010',
MeasurementGroup: '125007',
ImageLibraryGroup: '126200',
TrackingUniqueIdentifier: '112040',
TrackingIdentifier: '112039',
Finding: '121071',
FindingSite: 'G-C0E3', // SRT
CornerstoneFreeText: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT, //
};
const CodingSchemeDesignators = { const CodingSchemeDesignators = {
SRT: 'SRT', SRT: 'SRT',
CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'], CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'],
@ -113,22 +102,10 @@ function _getDisplaySetsFromSeries(
} = instance; } = instance;
validateSameStudyUID(instance.StudyInstanceUID, instances); validateSameStudyUID(instance.StudyInstanceUID, instances);
if ( const isImagingMeasurementReport =
!ConceptNameCodeSequence || ConceptNameCodeSequence?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurementReport;
ConceptNameCodeSequence.CodeValue !== CodeNameCodeSequenceValues.ImagingMeasurementReport
) {
servicesManager.services.uiNotificationService.show({
title: 'DICOM SR',
message:
'OHIF only supports TID1500 Imaging Measurement Report Structured Reports. The SR youre trying to view is not supported.',
type: 'warning',
duration: 6000,
});
return [];
}
const displaySet = { const displaySet = {
//plugin: id,
Modality: 'SR', Modality: 'SR',
displaySetInstanceUID: utils.guid(), displaySetInstanceUID: utils.guid(),
SeriesDescription, SeriesDescription,
@ -144,6 +121,7 @@ function _getDisplaySetsFromSeries(
measurements: null, measurements: null,
isDerivedDisplaySet: true, isDerivedDisplaySet: true,
isLoaded: false, isLoaded: false,
isImagingMeasurementReport,
sopClassUids, sopClassUids,
instance, instance,
addInstances, addInstances,
@ -158,7 +136,6 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte
const { displaySetService, measurementService } = servicesManager.services; const { displaySetService, measurementService } = servicesManager.services;
const dataSources = extensionManager.getDataSources(); const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0]; const dataSource = dataSources[0];
const { ContentSequence } = displaySet.instance; const { ContentSequence } = displaySet.instance;
async function retrieveBulkData(obj, parentObj = null, key = null) { async function retrieveBulkData(obj, parentObj = null, key = null) {
@ -185,8 +162,13 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte
await retrieveBulkData(ContentSequence); await retrieveBulkData(ContentSequence);
} }
displaySet.referencedImages = _getReferencedImagesList(ContentSequence); if (displaySet.isImagingMeasurementReport) {
displaySet.measurements = _getMeasurements(ContentSequence); displaySet.referencedImages = _getReferencedImagesList(ContentSequence);
displaySet.measurements = _getMeasurements(ContentSequence);
} else {
displaySet.referencedImages = [];
displaySet.measurements = [];
}
const mappings = measurementService.getSourceMappings( const mappings = measurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_NAME,
@ -371,6 +353,10 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements
); );
if (!ImagingMeasurements) {
return [];
}
const MeasurementGroups = _getSequenceAsArray(ImagingMeasurements.ContentSequence).filter( const MeasurementGroups = _getSequenceAsArray(ImagingMeasurements.ContentSequence).filter(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup
); );
@ -625,6 +611,10 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibrary item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibrary
); );
if (!ImageLibrary) {
return [];
}
const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find( const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup
); );

View File

@ -10,7 +10,7 @@ import hydrateStructuredReport from './utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet'; import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
const Component = React.lazy(() => { const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSRViewport'); return import(/* webpackPrefetch: true */ './components/OHIFCornerstoneSRViewport');
}); });
const OHIFCornerstoneSRViewport = props => { const OHIFCornerstoneSRViewport = props => {

View File

@ -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 };

View File

@ -7,7 +7,7 @@ import moreTools from './moreTools';
// Allow this mode by excluding non-imaging modalities such as SR, SEG // Allow this mode by excluding non-imaging modalities such as SR, SEG
// Also, SM is not a simple imaging modalities, so exclude it. // Also, SM is not a simple imaging modalities, so exclude it.
const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SR', 'SEG', 'RTSTRUCT']; const NON_IMAGE_MODALITIES = ['SM', 'ECG', 'SEG', 'RTSTRUCT'];
const ohif = { const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
@ -169,7 +169,7 @@ function modeFactory({ modeConfiguration }) {
valid: !!modalities_list.filter(modality => NON_IMAGE_MODALITIES.indexOf(modality) === -1) valid: !!modalities_list.filter(modality => NON_IMAGE_MODALITIES.indexOf(modality) === -1)
.length, .length,
description: description:
'The mode does not support studies that ONLY include the following modalities: SM, ECG, SR, SEG, RTSTRUCT', 'The mode does not support studies that ONLY include the following modalities: SM, ECG, SEG, RTSTRUCT',
}; };
}, },
routes: [ routes: [

View File

@ -0,0 +1,12 @@
import moment from 'moment';
/**
* Format time in HHmmss.SSS format (24h time) into HH:mm:ss
*
* @param time - Time to be formatted
* @param format - Desired time format
* @returns Formatted time
*/
export default function formatTime(time: string, format = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(format);
}

View File

@ -24,6 +24,7 @@ describe('Top level exports', () => {
'b64toBlob', 'b64toBlob',
'createStudyBrowserTabs', 'createStudyBrowserTabs',
'formatDate', 'formatDate',
'formatTime',
'formatPN', 'formatPN',
'generateAcceptHeader', 'generateAcceptHeader',
'isEqualWithin', 'isEqualWithin',

View File

@ -13,6 +13,7 @@ import hotkeys from './hotkeys';
import Queue from './Queue'; import Queue from './Queue';
import isDicomUid from './isDicomUid'; import isDicomUid from './isDicomUid';
import formatDate from './formatDate'; import formatDate from './formatDate';
import formatTime from './formatTime';
import formatPN from './formatPN'; import formatPN from './formatPN';
import generateAcceptHeader from './generateAcceptHeader'; import generateAcceptHeader from './generateAcceptHeader';
import resolveObjectPath from './resolveObjectPath'; import resolveObjectPath from './resolveObjectPath';
@ -56,6 +57,7 @@ const utils = {
seriesSortCriteria, seriesSortCriteria,
writeScript, writeScript,
formatDate, formatDate,
formatTime,
formatPN, formatPN,
b64toBlob, b64toBlob,
urlUtil, urlUtil,