diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 904f1f0b8..226be4bc4 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone", - "version": "2.7.3", + "version": "3.0.0", "description": "OHIF extension for Cornerstone", "author": "OHIF", "license": "MIT", diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index e7b321935..319a4c484 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -246,10 +246,12 @@ const _connectToolsToMeasurementService = measurementService => { function addMeasurement(csToolsEvent) { console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail); + try { const evtDetail = csToolsEvent.detail; const { toolName, toolType, measurementData } = evtDetail; const csToolName = toolName || measurementData.toolType || toolType; + const measurementId = addOrUpdate(csToolName, evtDetail); if (measurementId) { diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js index 7d2c1c3b2..f36c64ad8 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js @@ -30,7 +30,7 @@ const ArrowAnnotate = { points.push(measurementData.handles); return { - id: measurementData._measurementServiceId, + id: measurementData.id, SOPInstanceUID: SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js index 5b6f3e893..25ca59b12 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js @@ -31,7 +31,7 @@ const Bidirectional = { const shortAxis = [handles.perpendicularStart, handles.perpendicularEnd]; return { - id: measurementData._measurementServiceId, + id: measurementData.id, SOPInstanceUID: SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js index dbd4d2403..b2a24187e 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js @@ -53,7 +53,7 @@ const EllipticalRoi = { } return { - id: measurementData._measurementServiceId, + id: measurementData.id, SOPInstanceUID: SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js index 5f9eda649..a5b7c1e9d 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js @@ -26,7 +26,7 @@ const Length = { text: label, description, handles: getHandlesFromPoints(points), - _measurementServiceId: id, + id, }, }; }, @@ -57,11 +57,8 @@ const Length = { StudyInstanceUID, } = getSOPInstanceAttributes(element); - const points = []; - points.push(measurementData.handles); - return { - id: measurementData._measurementServiceId, + id: measurementData.id, SOPInstanceUID: SOPInstanceUID, FrameOfReferenceUID, referenceSeriesUID: SeriesInstanceUID, diff --git a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js index b5b7caaf2..5f71ce062 100644 --- a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js +++ b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js @@ -1,40 +1,63 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import cornerstoneTools from 'cornerstone-tools'; import cornerstone from 'cornerstone-core'; - import CornerstoneViewport from 'react-cornerstone-viewport'; -import OHIF, { utils } from '@ohif/core'; +import OHIF, { DicomMetadataStore, utils } from '@ohif/core'; import { ViewportActionBar, useViewportGrid } from '@ohif/ui'; import TOOL_NAMES from './constants/toolNames'; +import { adapters } from 'dcmjs'; +import getToolStateToCornerstoneMeasurementSchema from './utils/getToolStateToCornerstoneMeasurementSchema'; import id from './id'; const { formatDate } = utils; const scrollToIndex = cornerstoneTools.importInternal('util/scrollToIndex'); +const globalImageIdSpecificToolStateManager = + cornerstoneTools.globalImageIdSpecificToolStateManager; -// const cine = viewportSpecificData.cine; +const { StackManager, guid } = OHIF.utils; -// isPlaying = cine.isPlaying === true; -// frameRate = cine.cineFrameRate || frameRate; - -const { StackManager } = OHIF.utils; +const MEASUREMENT_TRACKING_EXTENSION_ID = 'org.ohif.measurement-tracking'; function OHIFCornerstoneSRViewport({ children, dataSource, displaySet, viewportIndex, - DisplaySetService, + servicesManager, + extensionManager, }) { + const { DisplaySetService, MeasurementService } = servicesManager.services; const [viewportGrid, viewportGridService] = useViewportGrid(); const [measurementSelected, setMeasurementSelected] = useState(0); const [measurementCount, setMeasurementCount] = useState(1); const [viewportData, setViewportData] = useState(null); const [activeDisplaySetData, setActiveDisplaySetData] = useState({}); const [element, setElement] = useState(null); - + const [isHydrated, setIsHydrated] = useState(displaySet.isHydrated); const { viewports, activeViewportIndex } = viewportGrid; + // Optional hook into tracking extension, if present. + let trackedMeasurements; + let sendTrackedMeasurementsEvent; + + if ( + extensionManager.registeredExtensionIds.includes( + MEASUREMENT_TRACKING_EXTENSION_ID + ) + ) { + const contextModule = extensionManager.getModuleEntry( + 'org.ohif.measurement-tracking.contextModule.TrackedMeasurementsContext' + ); + + const useTrackedMeasurements = () => useContext(contextModule.context); + + [ + trackedMeasurements, + sendTrackedMeasurementsEvent, + ] = useTrackedMeasurements(); + } + const onElementEnabled = evt => { const eventData = evt.detail; const targetElement = eventData.element; @@ -196,8 +219,6 @@ function OHIFCornerstoneSRViewport({ const { Modality } = displaySet; - // TODO -> Get this from the associated stack. - const { PatientID, PatientName, @@ -210,6 +231,7 @@ function OHIFCornerstoneSRViewport({ SeriesInstanceUID, PixelSpacing, SeriesNumber, + displaySetInstanceUID, } = activeDisplaySetData; const onMeasurementChange = direction => { @@ -229,22 +251,158 @@ function OHIFCornerstoneSRViewport({ } } - if (newMeasurementSelected === measurementSelected) { - // TODO -> Jump to image in this case. - } - updateViewport(newMeasurementSelected); }; + function hydrateMeasurementService() { + // TODO -> We should define a strict versioning somewhere. + const mappings = MeasurementService.getSourceMappings( + 'CornerstoneTools', + '4' + ); + + if (!mappings || !mappings.length) { + throw new Error( + `Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.` + ); + } + + const instance = DicomMetadataStore.getInstance( + displaySet.StudyInstanceUID, + displaySet.SeriesInstanceUID, + displaySet.SOPInstanceUID + ); + + const { MeasurementReport } = adapters.Cornerstone; + + const sopInstanceUIDToImageId = {}; + + displaySet.measurements.forEach(measurement => { + const { ReferencedSOPInstanceUID, imageId } = measurement; + if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) { + sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId; + } + }); + + // Use dcmjs to generate toolState. + const storedMeasurementByToolType = MeasurementReport.generateToolState( + instance + ); + + // Filter what is found by DICOM SR to measurements we support. + const mappingDefinitions = mappings.map(m => m.definition); + const hydratableMeasurementsInSR = {}; + + Object.keys(storedMeasurementByToolType).forEach(key => { + if (mappingDefinitions.includes(key)) { + hydratableMeasurementsInSR[key] = storedMeasurementByToolType[key]; + } + }); + + if ( + extensionManager.registeredExtensionIds.includes( + MEASUREMENT_TRACKING_EXTENSION_ID + ) + ) { + // Set the series touched as tracked. + const imageIds = []; + + Object.keys(hydratableMeasurementsInSR).forEach(toolType => { + const toolDataForToolType = hydratableMeasurementsInSR[toolType]; + + toolDataForToolType.forEach(data => { + // Add the measurement to toolState + const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; + + if (!imageIds.includes(imageId)) { + imageIds.push(imageId); + } + }); + }); + + let targetStudyInstanceUID; + const SeriesInstanceUIDs = []; + + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[0]; + const { + SeriesInstanceUID, + StudyInstanceUID, + } = cornerstone.metaData.get('instance', imageId); + + if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) { + SeriesInstanceUIDs.push(SeriesInstanceUID); + } + + if (!targetStudyInstanceUID) { + targetStudyInstanceUID = StudyInstanceUID; + } else if (targetStudyInstanceUID !== StudyInstanceUID) { + console.warn( + 'NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.' + ); + } + } + + sendTrackedMeasurementsEvent('SET_TRACKED_SERIES', { + StudyInstanceUID: targetStudyInstanceUID, + SeriesInstanceUIDs, + }); + } + + Object.keys(hydratableMeasurementsInSR).forEach(toolType => { + const toolDataForToolType = hydratableMeasurementsInSR[toolType]; + + toolDataForToolType.forEach(data => { + // Add the measurement to toolState + const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; + + data.id = guid(); + + _addToolDataToCornerstoneTools(data, toolType, imageId); + + // Let the measurement service know we added to toolState + const toMeasurementSchema = getToolStateToCornerstoneMeasurementSchema( + toolType, + MeasurementService, + imageId + ); + + const source = MeasurementService.getSource('CornerstoneTools', '4'); + + MeasurementService.addRawMeasurement( + source, + toolType, + data, + toMeasurementSchema + ); + + if (!imageIds.includes(imageId)) { + imageIds.push(imageId); + } + }); + }); + + displaySet.isHydrated = true; + + setIsHydrated(true); + + viewportGridService.setDisplaysetForViewport({ + viewportIndex: activeViewportIndex, + displaySetInstanceUID: activeDisplaySetData.displaySetInstanceUID, + }); + } + return ( <> { _checkIfCanAddMeasurementsToDisplaySet( @@ -346,6 +360,12 @@ function _processTID1410Measurement(mergedContentSequence) { group => group.ValueType === 'UIDREF' ); + const TrackingIdentifierContentItem = mergedContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.TrackingIdentifier + ); + if (!graphicItem) { console.warn( `graphic ValueType ${graphicItem.ValueType} not currently supported, skipping annotation.` @@ -362,6 +382,7 @@ function _processTID1410Measurement(mergedContentSequence) { labels: [], coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)], TrackingUniqueIdentifier: UIDREFContentItem.UID, + TrackingIdentifier: TrackingIdentifierContentItem.TextValue, }; NUMContentItems.forEach(item => { @@ -389,11 +410,18 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) { group => group.ValueType === 'UIDREF' ); + const TrackingIdentifierContentItem = mergedContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.TrackingIdentifier + ); + const measurement = { loaded: false, labels: [], coords: [], TrackingUniqueIdentifier: UIDREFContentItem.UID, + TrackingIdentifier: TrackingIdentifierContentItem.TextValue, }; NUMContentItems.forEach(item => { diff --git a/extensions/dicom-sr/src/index.js b/extensions/dicom-sr/src/index.js index 8a2a6d98a..498f51517 100644 --- a/extensions/dicom-sr/src/index.js +++ b/extensions/dicom-sr/src/index.js @@ -23,6 +23,18 @@ export default { * Only required property. Should be a unique value across all extensions. */ id, + dependencies: [ + // TODO -> This isn't used anywhere yet, but we do have a hard dependency, and need to check for these in the future. + // OHIF-229 + { + id: 'org.ohif.cornerstone', + version: '3.0.0', + }, + { + id: 'org.ohif.measurement-tracking', + version: '^0.0.1', + }, + ], preRegistration({ servicesManager, configuration = {} }) { init({ servicesManager, configuration }); @@ -34,13 +46,12 @@ export default { * @param {object} [configuration={}] * @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools` */ - getViewportModule({ servicesManager }) { + getViewportModule({ servicesManager, extensionManager }) { const ExtendedOHIFCornerstoneSRViewport = props => { - const { DisplaySetService } = servicesManager.services; - return ( ); diff --git a/extensions/dicom-sr/src/utils/addMeasurement.js b/extensions/dicom-sr/src/utils/addMeasurement.js index 8ad925b2c..f3d28c317 100644 --- a/extensions/dicom-sr/src/utils/addMeasurement.js +++ b/extensions/dicom-sr/src/utils/addMeasurement.js @@ -62,6 +62,12 @@ export default function addMeasurement( measurement.loaded = true; measurement.imageId = imageId; measurement.displaySetInstanceUID = displaySetInstanceUID; + + // Remove the unneeded coord now its processed, but keep the SOPInstanceUID. + // NOTE: We assume that each SCOORD in the MeasurementGroup maps onto one frame, + // It'd be super werid if it didn't anyway as a SCOORD. + measurement.ReferencedSOPInstanceUID = + measurement.coords[0].ReferencedSOPSequence.ReferencedSOPInstanceUID; delete measurement.coords; } diff --git a/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js b/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js new file mode 100644 index 000000000..d37b8c0be --- /dev/null +++ b/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js @@ -0,0 +1,202 @@ +export default function getToolStateToCornerstoneMeasurementSchema( + toolType, + MeasurementService, + imageId +) { + const _getValueTypeFromToolType = toolType => { + const { + POLYLINE, + ELLIPSE, + POINT, + BIDIRECTIONAL, + } = MeasurementService.VALUE_TYPES; + + // TODO -> I get why this was attemped, but its not nearly flexible enough. + // A single measurement may have an ellipse + a bidirectional measurement, for instances. + // You can't define a bidirectional tool as a single type.. + // OHIF-230 + const TOOL_TYPE_TO_VALUE_TYPE = { + Length: POLYLINE, + EllipticalRoi: ELLIPSE, + Bidirectional: BIDIRECTIONAL, + ArrowAnnotate: POINT, + }; + + return TOOL_TYPE_TO_VALUE_TYPE[toolType]; + }; + + switch (toolType) { + case 'Length': + return measurementData => + Length(measurementData, imageId, _getValueTypeFromToolType); + case 'Bidirectional': + return measurementData => + Bidirectional(measurementData, imageId, _getValueTypeFromToolType); + case 'EllipticalRoi': + return measurementData => + EllipticalRoi(measurementData, imageId, _getValueTypeFromToolType); + case 'ArrowAnnotate': + return measurementData => + ArrowAnnotate(measurementData, imageId, _getValueTypeFromToolType); + } +} + +function Length(measurementData, imageId, _getValueTypeFromToolType) { + const tool = measurementData.toolType || measurementData.toolName; + const instance = cornerstone.metaData.get('instance', imageId); + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = instance; + + const { handles } = measurementData; + + const points = []; + Object.keys(handles).map(handle => { + if (['start', 'end'].includes(handle)) { + let point = {}; + if (handles[handle].x) point.x = handles[handle].x; + if (handles[handle].y) point.y = handles[handle].y; + points.push(point); + } + }); + + return { + id: measurementData.id, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + length: measurementData.length, + type: _getValueTypeFromToolType(tool), + points, + }; +} + +function Bidirectional(measurementData, imageId, _getValueTypeFromToolType) { + const tool = measurementData.toolType || measurementData.toolName; + const instance = cornerstone.metaData.get('instance', imageId); + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = instance; + + const { handles } = measurementData; + + const longAxis = [handles.start, handles.end]; + const shortAxis = [handles.perpendicularStart, handles.perpendicularEnd]; + + return { + id: measurementData.id, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + shortestDiameter: measurementData.shortestDiameter, + longestDiameter: measurementData.longestDiameter, + type: _getValueTypeFromToolType(tool), + points: { longAxis, shortAxis }, + }; +} + +function EllipticalRoi(measurementData, imageId, _getValueTypeFromToolType) { + const tool = measurementData.toolType || measurementData.toolName; + const instance = cornerstone.metaData.get('instance', imageId); + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = instance; + + const { start, end } = measurementData.handles; + + const halfXLength = Math.abs(start.x - end.x) / 2; + const halfYLength = Math.abs(start.y - end.y) / 2; + + const points = []; + const center = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + + // To store similar to SR. + if (halfXLength > halfYLength) { + // X-axis major + // Major axis + points.push({ x: center.x - halfXLength, y: center.y }); + points.push({ x: center.x + halfXLength, y: center.y }); + // Minor axis + points.push({ x: center.x, y: center.y - halfYLength }); + points.push({ x: center.x, y: center.y + halfYLength }); + } else { + // Y-axis major + // Major axis + points.push({ x: center.x, y: center.y - halfYLength }); + points.push({ x: center.x, y: center.y + halfYLength }); + // Minor axis + points.push({ x: center.x - halfXLength, y: center.y }); + points.push({ x: center.x + halfXLength, y: center.y }); + } + + return { + id: measurementData.id, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + area: + measurementData.cachedStats && + measurementData.cachedStats + .area /* TODO: Add concept names instead (descriptor) */, + type: _getValueTypeFromToolType(tool), + points, + }; +} + +function ArrowAnnotate(measurementData, imageId, _getValueTypeFromToolType) { + const tool = measurementData.toolType || measurementData.toolName; + const instance = cornerstone.metaData.get('instance', imageId); + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = instance; + + const { handles } = measurementData; + + const points = []; + Object.keys(handles).map(handle => { + if (['start', 'end'].includes(handle)) { + let point = {}; + if (handles[handle].x) point.x = handles[handle].x; + if (handles[handle].y) point.y = handles[handle].y; + points.push(point); + } + }); + + return { + id: measurementData.id, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + text: measurementData.text, + type: _getValueTypeFromToolType(tool), + points, + }; +} diff --git a/extensions/dicom-sr/src/utils/isRehydratable.js b/extensions/dicom-sr/src/utils/isRehydratable.js new file mode 100644 index 000000000..750d184e0 --- /dev/null +++ b/extensions/dicom-sr/src/utils/isRehydratable.js @@ -0,0 +1,48 @@ +import { adapters } from 'dcmjs'; + +const cornerstoneAdapters = adapters.Cornerstone; + +/** + * Checks if the given `displySet`can be rehydrated into the `MeasurementService`. + * + * @param {object} displaySet The SR `displaySet` to check. + * @param {object[]} mappings The CornerstoneTools 4 mappings to the `MeasurementService`. + * @returns {boolean} True if the SR can be rehydrated into the `MeasurementService`. + */ +export default function isRehydratable(displaySet, mappings) { + if (!mappings || !mappings.length) { + return false; + } + + const mappingDefinitions = mappings.map(m => m.definition); + const { measurements } = displaySet; + + const adapterKeys = Object.keys(cornerstoneAdapters).filter( + adapterKey => + typeof cornerstoneAdapters[adapterKey] + .isValidCornerstoneTrackingIdentifier === 'function' + ); + + const adapters = []; + + adapterKeys.forEach(key => { + if (mappingDefinitions.includes(key)) { + // Must have both a dcmjs adapter and a MeasurementService + // Definition in order to be a candidate for import. + adapters.push(cornerstoneAdapters[key]); + } + }); + + for (let i = 0; i < measurements.length; i++) { + const TrackingIdentifier = measurements[i].TrackingIdentifier; + const hydratable = adapters.some(adapter => + adapter.isValidCornerstoneTrackingIdentifier(TrackingIdentifier) + ); + + if (hydratable) { + return true; + } + } + + return false; +} diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index 40ae9cac6..71c59eaf3 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -23,6 +23,12 @@ const machineConfiguration = { entry: 'clearContext', on: { TRACK_SERIES: 'promptBeginTracking', + SET_TRACKED_SERIES: [ + { + target: 'tracking', + actions: ['setTrackedStudyAndMultipleSeries'], + }, + ], }, }, promptBeginTracking: { @@ -69,6 +75,12 @@ const machineConfiguration = { target: 'idle', }, ], + SET_TRACKED_SERIES: [ + { + target: 'tracking', + actions: ['setTrackedStudyAndMultipleSeries'], + }, + ], }, }, promptTrackNewSeries: { @@ -139,6 +151,10 @@ const defaultOptions = { trackedStudy: evt.data.StudyInstanceUID, trackedSeries: [evt.data.SeriesInstanceUID], })), + setTrackedStudyAndMultipleSeries: assign((ctx, evt) => ({ + trackedStudy: evt.StudyInstanceUID, + trackedSeries: [...ctx.trackedSeries, ...evt.SeriesInstanceUIDs], + })), addTrackedSeries: assign((ctx, evt) => ({ trackedSeries: [...ctx.trackedSeries, evt.data.SeriesInstanceUID], })), diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js index 45dcf23ac..1b639282f 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js @@ -17,6 +17,10 @@ import ViewportOverlay from './ViewportOverlay'; const { formatDate } = utils; // TODO -> Get this list from the list of tracked measurements. +// TODO -> We can now get a list of tool names from the measurement service. +// Use the toolnames to check which tools we have instead, using the +// Classes isn't really extensible unless we add the classes to the measurement +// Service definition, which feels wrong. const { ArrowAnnotateTool, BidirectionalTool, @@ -40,7 +44,7 @@ function TrackedCornerstoneViewport({ dataSource, displaySet, viewportIndex, - ToolBarService + ToolBarService, }) { const [trackedMeasurements] = useTrackedMeasurements(); const [{ activeViewportIndex, viewports }] = useViewportGrid(); diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index ba9ddcf61..b97c09c55 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -155,6 +155,32 @@ class MeasurementService { return source; } + getSource(name, version) { + const { sources } = this; + const id = this._getSourceId(name, version); + + return sources[id]; + } + + getSourceMappings(name, version) { + const { mappings } = this; + const id = this._getSourceId(name, version); + + return mappings[id]; + } + + _getSourceId(name, version) { + const { sources } = this; + + const sourceId = Object.keys(sources).find(sourceId => { + const source = sources[sourceId]; + + return source.name === name && source.version === version; + }); + + return sourceId; + } + /** * Add a new measurement matching criteria along with mapping functions. * @@ -256,6 +282,93 @@ class MeasurementService { } } + /** + * Add a raw measurement into a source so that it may be + * Converted to/from annotation in the same way. E.g. import serialized data + * Of the same form as the measurement source. + * @param {MeasurementSource} source The measurement source instance. + * @param {string} definition The source definition you want to add the measuremnet to. + * @param {object} data The data you wish to add to the source. + * @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source definition. + */ + addRawMeasurement(source, definition, data, toMeasurementSchema) { + if (!this._isValidSource(source)) { + log.warn('Invalid source. Exiting early.'); + return; + } + + const sourceInfo = this._getSourceInfo(source); + + if (!definition) { + log.warn('No source definition provided. Exiting early.'); + return; + } + + if (!this._sourceHasMappings(source)) { + log.warn( + `No measurement mappings found for '${sourceInfo}' source. Exiting early.` + ); + return; + } + + let measurement = {}; + try { + /* Convert measurement */ + measurement = toMeasurementSchema(data); + + /* Assign measurement source instance */ + measurement.source = source; + } catch (error) { + log.warn( + `Failed to map '${sourceInfo}' measurement for definition ${definition}:`, + error.message + ); + return; + } + + if (!this._isValidMeasurement(measurement)) { + log.warn( + `Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.` + ); + return; + } + + let internalId = data.id; + if (!internalId) { + internalId = guid(); + log.warn(`Measurement ID not found. Generating UID: ${internalId}`); + } + + const newMeasurement = { + ...measurement, + modifiedTimestamp: Math.floor(Date.now() / 1000), + id: internalId, + }; + + if (this.measurements[internalId]) { + log.info( + `Measurement already defined. Updating measurement.`, + newMeasurement + ); + this.measurements[internalId] = newMeasurement; + this._broadcastChange( + this.EVENTS.MEASUREMENT_UPDATED, + source, + newMeasurement + ); + } else { + log.info(`Measurement added.`, newMeasurement); + this.measurements[internalId] = newMeasurement; + this._broadcastChange( + this.EVENTS.MEASUREMENT_ADDED, + source, + newMeasurement + ); + } + + return newMeasurement.id; + } + /** * Adds or update persisted measurements. * diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx index d901edb6e..f856d98b8 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx @@ -15,13 +15,22 @@ const ViewportActionBar = ({ showNavArrows, showPatientInfo: patientInfoVisibility, onSeriesChange, + onHydrationClick, }) => { const [showPatientInfo, setShowPatientInfo] = useState(patientInfoVisibility); + // TODO -> Remake this component with a bunch of generic slots that can be filled, + // Its not generic at all, isTracked etc shouldn't be parts of this component. + // It shouldn't care that a tracking mode or SR exists. + // Things like the right/left buttons should be made into smaller + // Components you can compose. + // OHIF-200 ticket. + const { label, isTracked, isLocked, + isHydrated, modality, studyDate, currentSeries, @@ -44,18 +53,30 @@ const ViewportActionBar = ({ const renderIconStatus = () => { if (modality === 'SR') { return ( -
- - SR - - {isLocked && ( - + <> +
+ + SR + + {isLocked && ( + + )} +
+ {!isLocked && !isHydrated && ( +
+ + Edit + +
)} -
+ ); } diff --git a/platform/viewer/src/components/ViewportGrid.jsx b/platform/viewer/src/components/ViewportGrid.jsx index 4052cbd0a..7a92ce550 100644 --- a/platform/viewer/src/components/ViewportGrid.jsx +++ b/platform/viewer/src/components/ViewportGrid.jsx @@ -28,13 +28,13 @@ function ViewerViewportGrid(props) { displaySets => { displaySets.sort((a, b) => { const isImageSet = x => x instanceof ImageSet; - return (isImageSet(a) === isImageSet(b)) ? 0 : isImageSet(a) ? -1 : 1; + return isImageSet(a) === isImageSet(b) ? 0 : isImageSet(a) ? -1 : 1; }); viewportGridService.setDisplaysetForViewport({ viewportIndex: 0, displaySetInstanceUID: displaySets[0].displaySetInstanceUID, }); - }, + } ); return () => {