diff --git a/extensions/cornerstone-dicom-sr/src/DICOMSRDisplayPoint.js b/extensions/cornerstone-dicom-sr/src/DICOMSRDisplayPoint.js new file mode 100644 index 00000000000..676534fbc47 --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/DICOMSRDisplayPoint.js @@ -0,0 +1,123 @@ +import { MeasurementService } from '@ohif/core'; +import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone'; + +const getValueTypeByGraphicType = graphicType => { + const { POINT } = MeasurementService.VALUE_TYPES; + const GRAPHIC_TYPE_TO_VALUE_TYPE = { POINT: POINT }; + return GRAPHIC_TYPE_TO_VALUE_TYPE[graphicType]; +}; + +const DICOMSRDisplayPoint = { + toAnnotation: () => {}, + toMeasurement: (csToolsEventDetail, displaySetService) => { + const { annotation } = csToolsEventDetail; + const { metadata, data, annotationUID } = annotation; + + if (!metadata || !data) { + console.warn('DICOM SR Diaply tool: Missing metadata or data'); + return null; + } + + const { graphicType, referencedImageId, FrameOfReferenceUID } = metadata; + + const { SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID } = + getSOPInstanceAttributes(referencedImageId); + + let displaySet; + + if (SOPInstanceUID) { + displaySet = displaySetService.getDisplaySetForSOPInstanceUID( + SOPInstanceUID, + SeriesInstanceUID + ); + } else { + displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID); + } + + const { points } = data.handles; + + const mappedAnnotations = getMappedAnnotations(annotation, displaySetService); + const displayText = getDisplayText(mappedAnnotations, displaySet); + + return { + uid: annotationUID, + SOPInstanceUID, + FrameOfReferenceUID, + points, + label: data.label[0].value, + metadata, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + frameNumber: mappedAnnotations[0]?.frameNumber || 1, + toolName: metadata.toolName, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + displayText: displayText, + data: data.cachedStats, + type: getValueTypeByGraphicType(graphicType), + getReport: () => { + throw new Error('Not implemented'); + }, + }; + }, + matchingCriteria: [ + { + valueType: MeasurementService.VALUE_TYPES.POINT, + points: 1, + }, + ], +}; + +function getMappedAnnotations(annotation, displaySetService) { + const { metadata, data } = annotation; + const { text } = data; + const { referencedImageId } = metadata; + + const annotations = []; + + const { SOPInstanceUID, SeriesInstanceUID, frameNumber } = + getSOPInstanceAttributes(referencedImageId); + + const displaySet = displaySetService.getDisplaySetForSOPInstanceUID( + SOPInstanceUID, + SeriesInstanceUID, + frameNumber + ); + + const { SeriesNumber } = displaySet; + + annotations.push({ + SeriesInstanceUID, + SOPInstanceUID, + SeriesNumber, + frameNumber, + text, + }); + + return annotations; +} + +function getDisplayText(mappedAnnotations, displaySet) { + if (!mappedAnnotations) { + return ''; + } + + const displayText = []; + + // Area is the same for all series + const { SeriesNumber, SOPInstanceUID, frameNumber } = mappedAnnotations[0]; + + const instance = displaySet.images.find(image => image.SOPInstanceUID === SOPInstanceUID); + let InstanceNumber; + if (instance) { + InstanceNumber = instance.InstanceNumber; + } + + const instanceText = InstanceNumber ? ` I: ${InstanceNumber}` : ''; + const frameText = displaySet.isMultiFrame ? ` F: ${frameNumber}` : ''; + + displayText.push(`(S: ${SeriesNumber}${instanceText}${frameText})`); + + return displayText; +} + +export default DICOMSRDisplayPoint; diff --git a/extensions/cornerstone-dicom-sr/src/enums.js b/extensions/cornerstone-dicom-sr/src/enums.js new file mode 100644 index 00000000000..1d35e59afb5 --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/enums.js @@ -0,0 +1,39 @@ +import { adaptersSR } from '@cornerstonejs/adapters'; + +const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D; + +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 + CornerstoneFreeText: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT, +}; + +export const CodingSchemeDesignators = { + SRT: 'SRT', + SCT: 'SCT', + CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'], +}; + +export const RELATIONSHIP_TYPE = { + INFERRED_FROM: 'INFERRED FROM', + CONTAINS: 'CONTAINS', +}; + +export const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT'; + +const enums = { + CodeNameCodeSequenceValues, + CodingSchemeDesignators, + RELATIONSHIP_TYPE, + CORNERSTONE_FREETEXT_CODE_VALUE, +}; + +export default enums; diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts index 73789049f58..422e378fb1e 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts @@ -1,25 +1,29 @@ -import { SOPClassHandlerName, SOPClassHandlerId } from './id'; import { utils, classes, DisplaySetService, Types } from '@ohif/core'; import addDICOMSRDisplayAnnotation from './utils/addDICOMSRDisplayAnnotation'; import isRehydratable from './utils/isRehydratable'; -import { adaptersSR } from '@cornerstonejs/adapters'; +import { SOPClassHandlerName, SOPClassHandlerId } from './id'; +import { + CodeNameCodeSequenceValues, + CodingSchemeDesignators, + CORNERSTONE_FREETEXT_CODE_VALUE, +} from './enums'; type InstanceMetadata = Types.InstanceMetadata; -const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D; - const { ImageSet, MetadataProvider: metadataProvider } = classes; -// TODO -> -// Add SR thumbnail -// Make viewport -// Get stacks from referenced displayInstanceUID and load into wrapped CornerStone viewport. +/** + * TODO + * - [ ] Add SR thumbnail + * - [ ] Make viewport + * - [ ] 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 */, ]; const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools'; @@ -34,31 +38,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'], -}; - -const RELATIONSHIP_TYPE = { - INFERRED_FROM: 'INFERRED FROM', - CONTAINS: 'CONTAINS', -}; - -const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT'; - /** * 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 @@ -120,7 +99,7 @@ function _getDisplaySetsFromSeries( servicesManager.services.uiNotificationService.show({ title: 'DICOM SR', message: - 'OHIF only supports TID1500 Imaging Measurement Report Structured Reports. The SR you’re trying to view is not supported.', + 'OHIF only supports TID1500 Imaging Measurement Report Structured Reports. The SR you are trying to view is not supported.', type: 'warning', duration: 6000, }); @@ -154,10 +133,15 @@ function _getDisplaySetsFromSeries( return [displaySet]; } +/** + * Loads the display set with the given services and extension manager. + * @param {Object} displaySet - The display set to load. + * @param {Object} servicesManager - The services manager containing displaySetService and measurementService. + * @param {Object} extensionManager - The extension manager containing data sources. + */ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, extensionManager) { const { displaySetService, measurementService } = servicesManager.services; - const dataSources = extensionManager.getDataSources(); - const dataSource = dataSources[0]; + const dataSource = extensionManager.getActiveDataSource()[0]; const { ContentSequence } = displaySet.instance; @@ -197,7 +181,7 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte displaySet.isRehydratable = isRehydratable(displaySet, mappings); displaySet.isLoaded = true; - // Check currently added displaySets and add measurements if the sources exist. + /** Check currently added displaySets and add measurements if the sources exist */ displaySetService.activeDisplaySets.forEach(activeDisplaySet => { _checkIfCanAddMeasurementsToDisplaySet( displaySet, @@ -207,11 +191,13 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte ); }); - // Subscribe to new displaySets as the source may come in after. + /** 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. + /** + * 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( displaySet, @@ -223,24 +209,79 @@ async function _load(displaySet, servicesManager: AppTypes.ServicesManager, exte }); } +const addReferencedSOPSequenceByFOR = (measurements, displaySet) => { + if (displaySet instanceof ImageSet) { + measurements.forEach(measurement => { + measurement.coords.forEach(coord => { + for (let i = 0; i < displaySet.images.length; ++i) { + const imageMetadata = displaySet.images[i]; + + if (imageMetadata.FrameOfReferenceUID !== coord.ReferencedFrameOfReferenceSequence) { + continue; + } + + const sliceNormal = [0, 0, 0]; + const orientation = imageMetadata.ImageOrientationPatient; + sliceNormal[0] = orientation[1] * orientation[5] - orientation[2] * orientation[4]; + sliceNormal[1] = orientation[2] * orientation[3] - orientation[0] * orientation[5]; + sliceNormal[2] = orientation[0] * orientation[4] - orientation[1] * orientation[3]; + + let distanceAlongNormal = 0; + for (let j = 0; j < 3; ++j) { + distanceAlongNormal += sliceNormal[j] * imageMetadata.ImagePositionPatient[j]; + } + + /** Assuming 2 mm tolerance */ + if (Math.abs(distanceAlongNormal - coord.GraphicData[2]) > 2) { + continue; + } + + if (coord.ReferencedSOPSequence === undefined) { + coord.ReferencedSOPSequence = { + ReferencedSOPClassUID: imageMetadata.SOPClassUID, + ReferencedSOPInstanceUID: imageMetadata.SOPInstanceUID, + }; + } + console.debug('1'); + + const { frameNumber } = metadataProvider.getUIDsFromImageID(imageMetadata.imageId); + addDICOMSRDisplayAnnotation(measurement, imageMetadata.imageId, frameNumber); + + break; + } + }); + }); + } +}; + +/** + * 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 ) { + addReferencedSOPSequenceByFOR(srDisplaySet.measurements, newDisplaySet); + const { customizationService } = servicesManager.services; + let unloadedMeasurements = srDisplaySet.measurements.filter( measurement => measurement.loaded === false ); + /** All measurements were loaded */ if (unloadedMeasurements.length === 0) { - // All already loaded! return; } - if ((!newDisplaySet) instanceof ImageSet) { - // This also filters out _this_ displaySet, as it is not an ImageSet. + if (!(newDisplaySet instanceof ImageSet)) { return; } @@ -250,42 +291,34 @@ function _checkIfCanAddMeasurementsToDisplaySet( const { sopClassUids } = newDisplaySet; - // Check if any have the newDisplaySet is the correct SOPClass. + /** Check correct SOPClassUID */ unloadedMeasurements = unloadedMeasurements.filter(measurement => - measurement.coords.some(coord => - sopClassUids.includes(coord.ReferencedSOPSequence.ReferencedSOPClassUID) - ) + measurement.coords.some(coord => { + return ( + coord.ReferencedSOPSequence && + sopClassUids.includes(coord.ReferencedSOPSequence.ReferencedSOPClassUID) + ); + }) ); if (unloadedMeasurements.length === 0) { - // New displaySet isn't the correct SOPClass, so can't contain the referenced images. + /** New displaySet isn't the correct SOPClassso can't contain the referenced images */ return; } const SOPInstanceUIDs = []; - unloadedMeasurements.forEach(measurement => { - const { coords } = measurement; - - coords.forEach(coord => { - const SOPInstanceUID = coord.ReferencedSOPSequence.ReferencedSOPInstanceUID; - + measurement.coords.forEach(({ ReferencedSOPSequence }) => { + const SOPInstanceUID = ReferencedSOPSequence.ReferencedSOPInstanceUID; if (!SOPInstanceUIDs.includes(SOPInstanceUID)) { SOPInstanceUIDs.push(SOPInstanceUID); } }); }); - const imageIdsForDisplaySet = dataSource.getImageIdsForDisplaySet(newDisplaySet); - - for (const imageId of imageIdsForDisplaySet) { - if (!unloadedMeasurements.length) { - // All measurements loaded. - return; - } - + const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet); + for (const imageId of imageIds) { const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(imageId); - if (SOPInstanceUIDs.includes(SOPInstanceUID)) { for (let j = unloadedMeasurements.length - 1; j >= 0; j--) { let measurement = unloadedMeasurements[j]; @@ -318,7 +351,6 @@ function _checkIfCanAddMeasurementsToDisplaySet( measurement.ReferencedSOPInstanceUID = measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID; measurement.frameNumber = frame; - delete measurement.coords; unloadedMeasurements.splice(j, 1); } @@ -327,11 +359,20 @@ function _checkIfCanAddMeasurementsToDisplaySet( } } +/** + * 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. + /** + * 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) || @@ -344,18 +385,26 @@ function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frame 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, @@ -365,6 +414,12 @@ function getSopClassHandlerModule({ servicesManager, extensionManager }) { ]; } +/** + * 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 => @@ -386,7 +441,6 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) { mergedContentSequencesByTrackingUniqueIdentifiers[trackingUniqueIdentifier]; const measurement = _processMeasurement(mergedContentSequence); - if (measurement) { measurements.push(measurement); } @@ -396,6 +450,12 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) { 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 = {}; @@ -407,7 +467,6 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.TrackingUniqueIdentifier ); - if (!TrackingUniqueIdentifierItem) { console.warn('No Tracking Unique Identifier, skipping ambiguous measurement.'); } @@ -436,6 +495,15 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups 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} mergedContentSequence - The merged content sequence to process. + * @returns {any} - The processed measurement result. + */ function _processMeasurement(mergedContentSequence) { if ( mergedContentSequence.some( @@ -448,11 +516,21 @@ function _processMeasurement(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'); + const graphicItem = mergedContentSequence.find( + group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D' + ); const UIDREFContentItem = mergedContentSequence.find(group => group.ValueType === 'UIDREF'); @@ -479,7 +557,6 @@ function _processTID1410Measurement(mergedContentSequence) { NUMContentItems.forEach(item => { const { ConceptNameCodeSequence, MeasuredValueSequence } = item; - if (MeasuredValueSequence) { measurement.labels.push( _getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredValueSequence) @@ -487,12 +564,29 @@ function _processTID1410Measurement(mergedContentSequence) { } }); + 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( @@ -552,15 +646,12 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) { 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); } @@ -575,51 +666,56 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) { return measurement; } -function _getCoordsFromSCOORDOrSCOORD3D(item) { - const { ValueType, RelationshipType, GraphicType, GraphicData } = item; - - if ( - !( - RelationshipType == RELATIONSHIP_TYPE.INFERRED_FROM || - RelationshipType == RELATIONSHIP_TYPE.CONTAINS - ) - ) { - console.warn( - `Relationshiptype === ${RelationshipType}. Cannot deal with NON TID-1400 SCOORD group with RelationshipType !== "INFERRED FROM" or "CONTAINS"` - ); - - return; - } - +/** + * 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 }; - // ContentSequence has length of 1 as RelationshipType === 'INFERRED FROM' if (ValueType === 'SCOORD') { - const { ReferencedSOPSequence } = item.ContentSequence; - + const { ReferencedSOPSequence } = graphicItem.ContentSequence; coords.ReferencedSOPSequence = ReferencedSOPSequence; } else if (ValueType === 'SCOORD3D') { - const { ReferencedFrameOfReferenceSequence } = item.ContentSequence; - - coords.ReferencedFrameOfReferenceSequence = ReferencedFrameOfReferenceSequence; + if (graphicItem.ReferencedFrameOfReferenceUID) { + coords.ReferencedFrameOfReferenceSequence = graphicItem.ReferencedFrameOfReferenceUID; + } else if (graphicItem.ContentSequence) { + const { ReferencedFrameOfReferenceSequence } = graphicItem.ContentSequence; + coords.ReferencedFrameOfReferenceSequence = 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 @@ -628,6 +724,9 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) { const ImageLibraryGroup = _getSequenceAsArray(ImageLibrary.ContentSequence).find( item => item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.ImageLibraryGroup ); + if (!ImageLibraryGroup) { + return []; + } const referencedImages = []; @@ -651,6 +750,15 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) { 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 []; diff --git a/extensions/cornerstone-dicom-sr/src/init.ts b/extensions/cornerstone-dicom-sr/src/init.ts index 949b4de2e63..486102647d0 100644 --- a/extensions/cornerstone-dicom-sr/src/init.ts +++ b/extensions/cornerstone-dicom-sr/src/init.ts @@ -12,13 +12,19 @@ import { } from '@cornerstonejs/tools'; import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool'; import addToolInstance from './utils/addToolInstance'; -import { Types } from '@ohif/core'; +import { MeasurementService, Types } from '@ohif/core'; import toolNames from './tools/toolNames'; +import DICOMSRDisplayPoint from './DICOMSRDisplayPoint'; /** * @param {object} configuration */ -export default function init({ configuration = {} }: Types.Extensions.ExtensionParams): void { +export default function init({ + configuration = {}, + servicesManager, +}: Types.Extensions.ExtensionParams): void { + const { measurementService, displaySetService } = servicesManager.services; + addToolInstance(toolNames.DICOMSRDisplay, DICOMSRDisplayTool); addToolInstance(toolNames.SRLength, LengthTool); addToolInstance(toolNames.SRBidirectional, BidirectionalTool); @@ -32,10 +38,32 @@ export default function init({ configuration = {} }: Types.Extensions.ExtensionP // TODO - fix the SR display of Cobb Angle, as it joins the two lines addToolInstance(toolNames.SRCobbAngle, CobbAngleTool); + const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools'; + const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1'; + const source = measurementService.getSource( + CORNERSTONE_3D_TOOLS_SOURCE_NAME, + CORNERSTONE_3D_TOOLS_SOURCE_VERSION + ); + measurementService.addMapping( + source, + toolNames.DICOMSRDisplay, + [ + { + valueType: MeasurementService.VALUE_TYPES.POINT, + points: 1, + }, + ], + DICOMSRDisplayPoint.toAnnotation, + csToolsAnnotation => DICOMSRDisplayPoint.toMeasurement(csToolsAnnotation, displaySetService) + ); + // Modify annotation tools to use dashed lines on SR const dashedLine = { lineDash: '4,4', }; + annotation.config.style.setToolGroupToolStyles('default', { + [toolNames.DICOMSRDisplay]: {}, + }); annotation.config.style.setToolGroupToolStyles('SRToolGroup', { [toolNames.DICOMSRDisplay]: dashedLine, SRLength: dashedLine, diff --git a/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts b/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts index eaf3bf6eaf1..5b24cd3c0be 100644 --- a/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts +++ b/extensions/cornerstone-dicom-sr/src/tools/DICOMSRDisplayTool.ts @@ -6,11 +6,14 @@ import { utilities, Types as cs3DToolsTypes, } from '@cornerstonejs/tools'; + +import { CodeNameCodeSequenceValues } from '../enums'; +import toolNames from './toolNames'; import { getTrackingUniqueIdentifiersForElement } from './modules/dicomSRModule'; import SCOORD_TYPES from '../constants/scoordTypes'; export default class DICOMSRDisplayTool extends AnnotationTool { - static toolName = 'DICOMSRDisplay'; + static toolName = toolNames.DICOMSRDisplay; constructor( toolProps = {}, @@ -29,7 +32,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool { for (let i = 0; i < labelLength; i++) { const labelEntry = labels[i]; - lines.push(`${_labelToShorthand(labelEntry.label)}: ${labelEntry.value}`); + lines.push(`${_labelToShorthand(labelEntry.label)} ${labelEntry.value}`); } return lines; @@ -64,8 +67,10 @@ export default class DICOMSRDisplayTool extends AnnotationTool { 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) + const filteredAnnotations = annotations.filter( + annotation => + !trackingUniqueIdentifiers.length || + trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier) ); if (!viewport._actors?.size) { @@ -150,6 +155,8 @@ export default class DICOMSRDisplayTool extends AnnotationTool { styleSpecifier, options ); + + return true; }); } }; @@ -211,6 +218,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool { const handleGroupUID = '0'; drawing.drawHandles(svgDrawingHelper, annotationUID, handleGroupUID, canvasCoordinates, { color: options.color, + lineDash: options.lineDash, }); }); } @@ -261,6 +269,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool { { color: options.color, width: options.lineWidth, + lineDash: options.lineDash, } ); }); @@ -386,6 +395,7 @@ const SHORT_HAND_MAP = { AREA: 'Area: ', Length: '', CORNERSTONEFREETEXT: '', + [CodeNameCodeSequenceValues.FindingSiteSCT]: '', }; function _labelToShorthand(label) { diff --git a/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts b/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts index 2e52ec111cf..fcf629c8c09 100644 --- a/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts +++ b/extensions/cornerstone-dicom-sr/src/tools/toolNames.ts @@ -1,7 +1,5 @@ -import DICOMSRDisplayTool from './DICOMSRDisplayTool'; - const toolNames = { - DICOMSRDisplay: DICOMSRDisplayTool.toolName, + DICOMSRDisplay: 'DICOMSRDisplay', SRLength: 'SRLength', SRBidirectional: 'SRBidirectional', SREllipticalROI: 'SREllipticalROI', diff --git a/extensions/cornerstone-dicom-sr/src/utils/addDICOMSRDisplayAnnotation.ts b/extensions/cornerstone-dicom-sr/src/utils/addDICOMSRDisplayAnnotation.ts index 585a9183a79..b172cf0f75b 100644 --- a/extensions/cornerstone-dicom-sr/src/utils/addDICOMSRDisplayAnnotation.ts +++ b/extensions/cornerstone-dicom-sr/src/utils/addDICOMSRDisplayAnnotation.ts @@ -1,11 +1,8 @@ -import { vec3 } from 'gl-matrix'; import { Types, annotation } from '@cornerstonejs/tools'; -import { metaData, utilities, Types as csTypes } from '@cornerstonejs/core'; +import { metaData } from '@cornerstonejs/core'; +import getRenderableData from './getRenderableData'; import toolNames from '../tools/toolNames'; -import SCOORD_TYPES from '../constants/scoordTypes'; - -const EPSILON = 1e-4; export default function addDICOMSRDisplayAnnotation(measurement, imageId, frameNumber) { const toolName = toolNames.DICOMSRDisplay; @@ -18,14 +15,14 @@ export default function addDICOMSRDisplayAnnotation(measurement, imageId, frameN }; measurement.coords.forEach(coord => { - const { GraphicType, GraphicData } = coord; + const { GraphicType, GraphicData, ValueType } = coord; if (measurementData.renderableData[GraphicType] === undefined) { measurementData.renderableData[GraphicType] = []; } measurementData.renderableData[GraphicType].push( - _getRenderableData(GraphicType, GraphicData, imageId) + getRenderableData(GraphicType, GraphicData, ValueType, imageId) ); }); @@ -43,8 +40,15 @@ export default function addDICOMSRDisplayAnnotation(measurement, imageId, frameN invalidated: false, metadata: { toolName: toolName, + valueType: measurement.coords[0].ValueType, + graphicType: measurement.coords[0].GraphicType, FrameOfReferenceUID: imagePlaneModule.frameOfReferenceUID, referencedImageId: imageId, + /** + * Used to jump to measurement using currently + * selected viewport if it shares frame of reference + */ + coords: measurement.coords, }, data: { label: measurement.labels, @@ -57,148 +61,10 @@ export default function addDICOMSRDisplayAnnotation(measurement, imageId, frameN frameNumber, }, }; - const annotationManager = annotation.state.getAnnotationManager(); - annotationManager.addAnnotation(SRAnnotation); -} - -function _getRenderableData(GraphicType, GraphicData, imageId) { - let renderableData: csTypes.Point3[]; - - switch (GraphicType) { - case SCOORD_TYPES.POINT: - case SCOORD_TYPES.MULTIPOINT: - case SCOORD_TYPES.POLYLINE: - renderableData = []; - - for (let i = 0; i < GraphicData.length; i += 2) { - const worldPos = utilities.imageToWorldCoords(imageId, [ - GraphicData[i], - GraphicData[i + 1], - ]); - - renderableData.push(worldPos); - } - - break; - case SCOORD_TYPES.CIRCLE: { - const pointsWorld = []; - for (let i = 0; i < GraphicData.length; i += 2) { - const worldPos = utilities.imageToWorldCoords(imageId, [ - GraphicData[i], - GraphicData[i + 1], - ]); - - pointsWorld.push(worldPos); - } - - // 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) - - // first axisStart - const firstAxisStart = vec3.create(); - vec3.scaleAndAdd(firstAxisStart, center, columnCosines, radius); - - const firstAxisEnd = vec3.create(); - vec3.scaleAndAdd(firstAxisEnd, center, columnCosines, -radius); - - // second axisStart - 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 SCOORD_TYPES.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[] = []; - for (let i = 0; i < GraphicData.length; i += 2) { - const worldPos = utilities.imageToWorldCoords(imageId, [ - GraphicData[i], - GraphicData[i + 1], - ]); - - pointsWorld.push(worldPos); - } - - 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; + /** + * const annotationManager = annotation.annotationState.getAnnotationManager(); + * was not triggering annotation_added events. + */ + annotation.state.addAnnotation(SRAnnotation); } diff --git a/extensions/cornerstone-dicom-sr/src/utils/getRenderableData.ts b/extensions/cornerstone-dicom-sr/src/utils/getRenderableData.ts new file mode 100644 index 00000000000..01f65209320 --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/utils/getRenderableData.ts @@ -0,0 +1,167 @@ +import { vec3 } from 'gl-matrix'; +import { metaData, utilities, Types as csTypes } from '@cornerstonejs/core'; +import SCOORD_TYPES from '../constants/scoordTypes'; + +const EPSILON = 1e-4; + +function getRenderableData(GraphicType, GraphicData, ValueType, imageId) { + let renderableData = []; + + switch (GraphicType) { + case SCOORD_TYPES.POINT: + case SCOORD_TYPES.MULTIPOINT: + case SCOORD_TYPES.POLYLINE: { + 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); + } + } + + break; + } + case SCOORD_TYPES.CIRCLE: { + const pointsWorld = []; + + if (ValueType === 'SCOORD3D') { + for (let i = 0; i < GraphicData.length; i += 3) { + pointsWorld.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], + ]); + pointsWorld.push(worldPos); + } + } + + // 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) + + // first axisStart + const firstAxisStart = vec3.create(); + vec3.scaleAndAdd(firstAxisStart, center, columnCosines, radius); + + const firstAxisEnd = vec3.create(); + vec3.scaleAndAdd(firstAxisEnd, center, columnCosines, -radius); + + // second axisStart + 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 SCOORD_TYPES.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[] = []; + + if (ValueType === 'SCOORD3D') { + for (let i = 0; i < GraphicData.length; i += 3) { + pointsWorld.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], + ]); + pointsWorld.push(worldPos); + } + } + + 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; diff --git a/extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js b/extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js index d6f6a748413..31705c3a216 100644 --- a/extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js +++ b/extensions/cornerstone-dicom-sr/src/utils/isRehydratable.js @@ -4,7 +4,7 @@ const cornerstoneAdapters = adaptersSR.Cornerstone3D.MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE; const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0']; -const CORNERSTONE_3D_TAG = cornerstoneAdapters.CORNERSTONE_3D_TAG; +const CORNERSTONE_3D_TAG = adaptersSR.Cornerstone3D.CORNERSTONE_3D_TAG; /** * Checks if the given `displaySet`can be rehydrated into the `measurementService`. diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index 6ceb7787ca1..fecbda639ad 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -2,16 +2,14 @@ import PropTypes from 'prop-types'; import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ExtensionManager } from '@ohif/core'; +import { Icon, Tooltip, useViewportGrid, ViewportActionArrows } from '@ohif/ui'; +import { useAppConfig } from '@state'; 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 OHIFCornerstoneSRViewport(props: withAppTypes) { @@ -99,7 +97,7 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { measurementSelected ); }, - [element, measurementSelected, srDisplaySet] + [element, srDisplaySet] ); /** @@ -126,10 +124,6 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { console.warn('More than one SOPClassUID in the same series is not yet supported.'); } - // if (!srDisplaySet.measurements || !srDisplaySet.measurements.length) { - // return; - // } - _getViewportReferencedDisplaySetData( srDisplaySet, newMeasurementSelected, @@ -161,7 +155,13 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { } }); }, - [dataSource, srDisplaySet, activeImageDisplaySetData, viewportId] + [ + srDisplaySet, + cornerstoneViewportService, + displaySetService, + activeImageDisplaySetData, + viewportId, + ] ); const getCornerstoneViewport = useCallback(() => { @@ -209,10 +209,17 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { onElementEnabled(evt); }} initialImageIndex={initialImageIndex} - isJumpToMeasurementDisabled={true} + isJumpToMeasurementDisabled={false} > ); - }, [activeImageDisplaySetData, viewportId, measurementSelected]); + }, [ + activeImageDisplaySetData, + extensionManager, + props, + srDisplaySet, + viewportOptions, + measurementSelected, + ]); const onMeasurementChange = useCallback( direction => { @@ -270,7 +277,7 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { updateViewport(measurementSelected); }; loadSR(); - }, [srDisplaySet]); + }, [dataSource, measurementSelected, updateViewport, srDisplaySet]); /** * Hook to update the tracking identifiers when the selected measurement changes or @@ -387,6 +394,7 @@ async function _getViewportReferencedDisplaySetData( const measurement = measurements[measurementSelected]; const { displaySetInstanceUID } = measurement; + // TODO: Remove this if not being used anymore 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. diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 12e2b5625e1..b29e3075eb0 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -20,6 +20,7 @@ import CornerstoneOverlays from './Overlays/CornerstoneOverlays'; import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import CinePlayer from '../components/CinePlayer'; import { Types } from '@ohif/core'; +import findImageIdIndexFromMeasurementByFOR from '../utils/findImageIdIndexFromMeasurementByFOR'; import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners'; import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu'; @@ -480,9 +481,11 @@ function _subscribeToJumpToMeasurementEvents( props => { cacheJumpToMeasurementEvent = props; const { viewportId: jumpId, measurement, isConsumed } = props; + if (!measurement || isConsumed) { return; } + if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) { // Decide on which viewport should handle this cacheJumpToMeasurementEvent.cornerstoneViewport = @@ -495,17 +498,28 @@ function _subscribeToJumpToMeasurementEvents( } ); } - if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) { + + const targetElement = elementRef.current; + const enabledElement = getEnabledElement(targetElement); + const viewport = enabledElement.viewport; + const { SOPInstanceUID, frameNumber } = measurement; + const imageIdIndex = findImageIdIndex(viewport, SOPInstanceUID, frameNumber); + + /** + * Only check for cached viewport if image id exists in that viewport. + * Otherwise the jump to measurement will try to load a different viewport + * by frame of reference. + */ + if (imageIdIndex >= 0 && cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) { return; } + _jumpToMeasurement( measurement, elementRef, viewportId, - measurementService, displaySetService, - viewportGridService, - cornerstoneViewportService + viewportGridService ); } ); @@ -543,23 +557,35 @@ function _checkForCachedJumpToMeasurementEvents( measurement, elementRef, viewportId, - measurementService, displaySetService, - viewportGridService, - cornerstoneViewportService + viewportGridService ); } } } +/** + * Finds the index of the image ID in the viewport that matches the given SOPInstanceUID and frameNumber. + * @param viewport - The viewport object. + * @param SOPInstanceUID - The SOPInstanceUID to match. + * @param frameNumber - The frame number to match (optional). + * @returns The index of the matching image ID, or -1 if not found. + */ +function findImageIdIndex(viewport, SOPInstanceUID, frameNumber) { + const imageIds = viewport.getImageIds(); + return imageIds.findIndex(imageId => { + const { SOPInstanceUID: aSOPInstanceUID, frameNumber: aFrameNumber } = + getSOPInstanceAttributes(imageId); + return aSOPInstanceUID === SOPInstanceUID && (!frameNumber || frameNumber === aFrameNumber); + }); +} + function _jumpToMeasurement( measurement, targetElementRef, viewportId, - measurementService, displaySetService, - viewportGridService, - cornerstoneViewportService + viewportGridService ) { const targetElement = targetElementRef.current; const { displaySetInstanceUID, SOPInstanceUID, frameNumber } = measurement; @@ -571,10 +597,6 @@ function _jumpToMeasurement( const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - // Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager - // to set it properly - // setCornerstoneMeasurementActive(measurement); - viewportGridService.setActiveViewportId(viewportId); const enabledElement = getEnabledElement(targetElement); @@ -587,12 +609,16 @@ function _jumpToMeasurement( let viewportCameraDirectionMatch = true; if (viewport instanceof StackViewport) { - const imageIds = viewport.getImageIds(); - imageIdIndex = imageIds.findIndex(imageId => { - const { SOPInstanceUID: aSOPInstanceUID, frameNumber: aFrameNumber } = - getSOPInstanceAttributes(imageId); - return aSOPInstanceUID === SOPInstanceUID && (!frameNumber || frameNumber === aFrameNumber); - }); + imageIdIndex = findImageIdIndex(viewport, SOPInstanceUID, frameNumber); + + /** If could not find image id index using sop instance try frame of reference */ + if (imageIdIndex < 0) { + const imageIds = viewport.getImageIds(); + const imageIdIndexFound = findImageIdIndexFromMeasurementByFOR(imageIds, measurement); + if (imageIdIndexFound > -1) { + imageIdIndex = imageIdIndexFound; + } + } } else { // for volume viewport we can't rely on the imageIdIndex since it can be // a reconstructed view that doesn't match the original slice numbers etc. diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index cc46e9afb6a..d55b9fa800f 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -40,6 +40,9 @@ import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/g import { findNearbyToolData } from './utils/findNearbyToolData'; import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer'; +import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; +export { getSOPInstanceAttributes }; + const { helpers: volumeLoaderHelpers } = csStreamingImageVolumeLoader; const { getDynamicVolumeInfo } = volumeLoaderHelpers ?? {}; const { imageRetrieveMetadataProvider } = cornerstone.utilities; diff --git a/extensions/cornerstone/src/initMeasurementService.ts b/extensions/cornerstone/src/initMeasurementService.ts index b2682953b76..cf0459ea2d4 100644 --- a/extensions/cornerstone/src/initMeasurementService.ts +++ b/extensions/cornerstone/src/initMeasurementService.ts @@ -232,7 +232,7 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana annotationToMeasurement(toolName, annotationAddedEventDetail); } } catch (error) { - console.warn('Failed to update measurement:', error); + console.error('Failed to update measurement:', error); } } @@ -256,7 +256,7 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana // Passing true to indicate this is an update and NOT a annotation (start) completion. annotationToMeasurement(toolName, annotationModifiedEventDetail, true); } catch (error) { - console.warn('Failed to update measurement:', error); + console.error('Failed to update measurement:', error); } } function selectMeasurement(csToolsEvent) { @@ -278,7 +278,7 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana ); } } catch (error) { - console.warn('Failed to select and unselect measurements:', error); + console.error('Failed to select and unselect measurements:', error); } } @@ -303,10 +303,10 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana remove(annotationUID, annotationRemovedEventDetail); } } catch (error) { - console.warn('Failed to update measurement:', error); + console.error('Failed to update measurement:', error); } } catch (error) { - console.warn('Failed to remove measurement:', error); + console.error('Failed to remove measurement:', error); } } diff --git a/extensions/cornerstone/src/utils/findImageIdIndexFromMeasurementByFOR.js b/extensions/cornerstone/src/utils/findImageIdIndexFromMeasurementByFOR.js new file mode 100644 index 00000000000..575e883bdfd --- /dev/null +++ b/extensions/cornerstone/src/utils/findImageIdIndexFromMeasurementByFOR.js @@ -0,0 +1,45 @@ +import { metaData } from '@cornerstonejs/core'; + +/** + * Finds the index of the image ID from the given array of image IDs based on the measurement data. + * @param {string[]} imageIds - The array of image IDs. + * @param {object} measurement - The measurement object. + * @returns {number} - The index of the image ID, or -1 if not found. + */ +const findImageIdIndexFromMeasurementByFOR = (imageIds, measurement) => { + let imageIdIndex = -1; + measurement.metadata.coords.forEach(coord => { + for (let i = 0; i < imageIds.length; ++i) { + const imageMetadata = metaData.get('instance', imageIds[i]); + if (imageMetadata.FrameOfReferenceUID !== coord.ReferencedFrameOfReferenceSequence) { + continue; + } + + const sliceNormal = [0, 0, 0]; + const orientation = imageMetadata.ImageOrientationPatient; + sliceNormal[0] = orientation[1] * orientation[5] - orientation[2] * orientation[4]; + sliceNormal[1] = orientation[2] * orientation[3] - orientation[0] * orientation[5]; + sliceNormal[2] = orientation[0] * orientation[4] - orientation[1] * orientation[3]; + + let distanceAlongNormal = 0; + for (let j = 0; j < 3; ++j) { + distanceAlongNormal += sliceNormal[j] * imageMetadata.ImagePositionPatient[j]; + } + + /** Assuming 2 mm tolerance */ + if (Math.abs(distanceAlongNormal - coord.GraphicData[2]) > 2) { + continue; + } else { + coord.ReferencedSOPSequence = { + ReferencedSOPClassUID: imageMetadata.SOPClassUID, + ReferencedSOPInstanceUID: imageMetadata.SOPInstanceUID, + }; + imageIdIndex = i; + i = imageIds.length; + } + } + }); + return imageIdIndex; +}; + +export default findImageIdIndexFromMeasurementByFOR; diff --git a/extensions/measurement-tracking/src/getPanelModule.tsx b/extensions/measurement-tracking/src/getPanelModule.tsx index a49a44a5bf6..d78c477de23 100644 --- a/extensions/measurement-tracking/src/getPanelModule.tsx +++ b/extensions/measurement-tracking/src/getPanelModule.tsx @@ -19,7 +19,6 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }): servicesManager, }), }, - { name: 'trackedMeasurements', iconName: 'tab-linear', diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx index 6ab9b413459..881d22c13c7 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx @@ -312,4 +312,4 @@ function _mapMeasurementToDisplay(measurement, types, displaySetService) { }; } -export default PanelMeasurementTableTracking; +export default PanelMeasurementTableTracking; \ No newline at end of file diff --git a/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js b/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js index 539f45f853b..57ecd7bc016 100644 --- a/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js +++ b/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js @@ -1,5 +1,5 @@ import SUPPORTED_TOOLS from './constants/supportedTools'; -import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; +import { getSOPInstanceAttributes } from '@ohif/extension-cornerstone'; const RectangleROIStartEndThreshold = { toAnnotation: (measurement, definition) => {}, diff --git a/extensions/tmtv/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js b/extensions/tmtv/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js deleted file mode 100644 index ccd594e7242..00000000000 --- a/extensions/tmtv/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js +++ /dev/null @@ -1,18 +0,0 @@ -import { metaData } from '@cornerstonejs/core'; - -export default function getSOPInstanceAttributes(imageId) { - if (imageId) { - return _getUIDFromImageID(imageId); - } -} - -function _getUIDFromImageID(imageId) { - const instance = metaData.get('instance', imageId); - - return { - SOPInstanceUID: instance.SOPInstanceUID, - SeriesInstanceUID: instance.SeriesInstanceUID, - StudyInstanceUID: instance.StudyInstanceUID, - frameNumber: instance.frameNumber || 1, - }; -} diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index 807b2072dac..36e756d0b2f 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -168,6 +168,7 @@ function ViewerViewportGrid(props: withAppTypes) { const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement; const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID); + // Arbitrarily assign the viewport to element 0 const viewport = updatedViewports?.[0]; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 35383f89a28..161d03cb1a4 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -169,6 +169,15 @@ class MeasurementService extends PubSubService { return [...this.measurements.values()]; } + /** + * Get all unmapped measurements. + * + * @return {Measurement[]} Array of measurements + */ + getUnmappedMeasurements() { + return [...this.unmappedMeasurements.values()]; + } + /** * Get specific measurement by its uid. * @@ -479,6 +488,14 @@ class MeasurementService extends PubSubService { ); if (!sourceMapping) { console.log('No source mapping', source); + this.unmappedMeasurements.set(sourceAnnotationDetail.uid, { + ...sourceAnnotationDetail, + source: { + name: source.name, + version: source.version, + uid: source.uid, + }, + }); return; } const { toMeasurementSchema } = sourceMapping;