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';
function OHIFCornerstoneSRViewport(props: withAppTypes) {
function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) {
const { children, dataSource, displaySets, viewportOptions, servicesManager, extensionManager } =
props;
@ -278,14 +278,14 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) {
*/
useEffect(() => {
const updateSR = async () => {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
if (!element || !srDisplaySet.isLoaded) {
return;
}
setTrackingIdentifiers(measurementSelected);
}
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
if (!element || !srDisplaySet.isLoaded) {
return;
}
setTrackingIdentifiers(measurementSelected);
};
updateSR();
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
@ -367,7 +367,7 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) {
);
}
OHIFCornerstoneSRViewport.propTypes = {
OHIFCornerstoneSRMeasurementViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object),
viewportId: PropTypes.string.isRequired,
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 isRehydratable from './utils/isRehydratable';
import { adaptersSR } from '@cornerstonejs/adapters';
import CodeNameCodeSequenceValues from './constants/CodeNameCodeSequenceValues';
type InstanceMetadata = Types.InstanceMetadata;
@ -16,10 +17,11 @@ const { ImageSet, MetadataProvider: metadataProvider } = classes;
// Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport.
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.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.34', //COMPREHENSIVE_3D_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.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.50', // Mammography CAD SR
];
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 = {
SRT: 'SRT',
CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'],
@ -113,22 +102,10 @@ function _getDisplaySetsFromSeries(
} = instance;
validateSameStudyUID(instance.StudyInstanceUID, instances);
if (
!ConceptNameCodeSequence ||
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 isImagingMeasurementReport =
ConceptNameCodeSequence?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurementReport;
const displaySet = {
//plugin: id,
Modality: 'SR',
displaySetInstanceUID: utils.guid(),
SeriesDescription,
@ -144,6 +121,7 @@ function _getDisplaySetsFromSeries(
measurements: null,
isDerivedDisplaySet: true,
isLoaded: false,
isImagingMeasurementReport,
sopClassUids,
instance,
addInstances,
@ -158,7 +136,6 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte
const { displaySetService, measurementService } = servicesManager.services;
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const { ContentSequence } = displaySet.instance;
async function retrieveBulkData(obj, parentObj = null, key = null) {
@ -185,8 +162,13 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte
await retrieveBulkData(ContentSequence);
}
displaySet.referencedImages = _getReferencedImagesList(ContentSequence);
displaySet.measurements = _getMeasurements(ContentSequence);
if (displaySet.isImagingMeasurementReport) {
displaySet.referencedImages = _getReferencedImagesList(ContentSequence);
displaySet.measurements = _getMeasurements(ContentSequence);
} else {
displaySet.referencedImages = [];
displaySet.measurements = [];
}
const mappings = measurementService.getSourceMappings(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
@ -371,6 +353,10 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurements
);
if (!ImagingMeasurements) {
return [];
}
const MeasurementGroups = _getSequenceAsArray(ImagingMeasurements.ContentSequence).filter(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.MeasurementGroup
);
@ -625,6 +611,10 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibrary
);
if (!ImageLibrary) {
return [];
}
const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find(
item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup
);

View File

@ -10,7 +10,7 @@ import hydrateStructuredReport from './utils/hydrateStructuredReport';
import createReferencedImageDisplaySet from './utils/createReferencedImageDisplaySet';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSRViewport');
return import(/* webpackPrefetch: true */ './components/OHIFCornerstoneSRViewport');
});
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
// 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 = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
@ -169,7 +169,7 @@ function modeFactory({ modeConfiguration }) {
valid: !!modalities_list.filter(modality => NON_IMAGE_MODALITIES.indexOf(modality) === -1)
.length,
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: [

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',
'createStudyBrowserTabs',
'formatDate',
'formatTime',
'formatPN',
'generateAcceptHeader',
'isEqualWithin',

View File

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