From 5beb12a7fc4a3c0ff4e1bf7c83c83e80966053f8 Mon Sep 17 00:00:00 2001 From: Davide Punzo Date: Thu, 3 Feb 2022 09:33:02 +0100 Subject: [PATCH] IDC (Re #1215): Feat/v2 annotations (#2658) * Add scoord3d parse / display tool to v2 * Update measurement panel with sr measurements * Update measurement panel to display read only items * Cleanup debugger statement * Check for empty measurement * fix SCOORD3D coordinates * Update getRenderableData.js * fix scoord3d coordinate transformation * implement logic to detect the ReferencedSOPInstanceUID if missing in the measurement. Fix also world to IJ coordinates. * Fix broken unit test / import * fix comment style * Init tools only once * restore default config dcmjs server Co-authored-by: igoroctaviano --- .../src/ConnectedCornerstoneViewport.js | 2 + .../src/OHIFCornerstoneViewport.js | 3 + extensions/cornerstone/src/init.js | 4 + .../src/tools/DICOMSRDisplayTool.js | 414 ++++++++++++++++++ .../src/tools/checkForSRAnnotations.js | 57 +++ .../src/tools/constants/scoordTypes.js | 8 + .../src/tools/constants/toolNames.js | 5 + extensions/cornerstone/src/tools/id.js | 7 + .../cornerstone/src/tools/initSRTools.js | 108 +++++ .../src/tools/modules/dicomSRModule.js | 61 +++ .../src/tools/utils/getToolAlias.js | 32 ++ .../src/OHIFDicomHtmlSopClassHandler.js | 5 +- lerna-debug.log | 23 + platform/core/package.json | 3 +- .../DICOMSR/SCOORD3D/constants/scoordTypes.js | 8 + .../DICOMSR/SCOORD3D/constants/toolNames.js | 5 + platform/core/src/DICOMSR/SCOORD3D/enums.js | 29 ++ .../src/DICOMSR/SCOORD3D/parseSCOORD3D.js | 149 +++++++ .../DICOMSR/SCOORD3D/utils/addMeasurement.js | 102 +++++ .../utils/getCoordsFromSCOORDOrSCOORD3D.js | 31 ++ .../getLabelFromMeasuredValueSequence.js | 19 + .../DICOMSR/SCOORD3D/utils/getMeasurements.js | 47 ++ ...entSequencesByTrackingUniqueIdentifiers.js | 54 +++ .../SCOORD3D/utils/getReferencedImagesList.js | 38 ++ .../SCOORD3D/utils/getRenderableData.js | 192 ++++++++ .../SCOORD3D/utils/getSequenceAsArray.js | 4 + .../DICOMSR/SCOORD3D/utils/isRehydratable.js | 48 ++ .../SCOORD3D/utils/processMeasurement.js | 16 + ...ocessNonGeometricallyDefinedMeasurement.js | 111 +++++ .../utils/processTID1410Measurement.js | 88 ++++ platform/core/src/DICOMSR/dataExchange.js | 5 +- .../src/DICOMSR/handleStructuredReport.js | 11 +- .../src/DICOMSR/parseDicomStructuredReport.js | 13 +- .../utils/findMostRecentStructuredReport.js | 1 + .../measurements/classes/MeasurementApi.js | 4 + .../measurements/lib/getImageAttributes.js | 10 +- .../measurements/tools/dicomSRDisplayTool.js | 22 + platform/core/src/measurements/tools/index.js | 2 + .../MeasurementService/MeasurementService.js | 389 +++++++++++----- .../MeasurementService.test.js | 200 +++++---- platform/core/src/services/_shared/index.js | 3 + .../_shared/pubSubServiceInterface.js | 88 ++++ .../measurementTable/MeasurementTable.js | 1 - .../measurementTable/MeasurementTableItem.js | 24 +- .../components/studyBrowser/ImageThumbnail.js | 6 +- .../ConnectedMeasurementTable.js | 2 + .../viewer/src/connectedComponents/Viewer.js | 6 +- yarn.lock | 57 ++- 48 files changed, 2286 insertions(+), 231 deletions(-) create mode 100644 extensions/cornerstone/src/tools/DICOMSRDisplayTool.js create mode 100644 extensions/cornerstone/src/tools/checkForSRAnnotations.js create mode 100644 extensions/cornerstone/src/tools/constants/scoordTypes.js create mode 100644 extensions/cornerstone/src/tools/constants/toolNames.js create mode 100644 extensions/cornerstone/src/tools/id.js create mode 100644 extensions/cornerstone/src/tools/initSRTools.js create mode 100644 extensions/cornerstone/src/tools/modules/dicomSRModule.js create mode 100644 extensions/cornerstone/src/tools/utils/getToolAlias.js create mode 100644 lerna-debug.log create mode 100644 platform/core/src/DICOMSR/SCOORD3D/constants/scoordTypes.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/constants/toolNames.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/enums.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/parseSCOORD3D.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/addMeasurement.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getCoordsFromSCOORDOrSCOORD3D.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getLabelFromMeasuredValueSequence.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getMeasurements.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getMergedContentSequencesByTrackingUniqueIdentifiers.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getReferencedImagesList.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getRenderableData.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/getSequenceAsArray.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/isRehydratable.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/processMeasurement.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/processNonGeometricallyDefinedMeasurement.js create mode 100644 platform/core/src/DICOMSR/SCOORD3D/utils/processTID1410Measurement.js create mode 100644 platform/core/src/measurements/tools/dicomSRDisplayTool.js create mode 100644 platform/core/src/services/_shared/index.js create mode 100644 platform/core/src/services/_shared/pubSubServiceInterface.js diff --git a/extensions/cornerstone/src/ConnectedCornerstoneViewport.js b/extensions/cornerstone/src/ConnectedCornerstoneViewport.js index 6a6e837c2..f8fe078c5 100644 --- a/extensions/cornerstone/src/ConnectedCornerstoneViewport.js +++ b/extensions/cornerstone/src/ConnectedCornerstoneViewport.js @@ -3,6 +3,7 @@ import OHIF from '@ohif/core'; import { connect } from 'react-redux'; import throttle from 'lodash.throttle'; import { setEnabledElement } from './state'; +import initSRTools from './tools/initSRTools'; const { setViewportActive, setViewportSpecificData } = OHIF.redux.actions; const { @@ -92,6 +93,7 @@ const mapDispatchToProps = (dispatch, ownProps) => { plugin: 'cornerstone', }) ); + initSRTools(enabledElement); }, onMeasurementsChanged: (event, action) => { diff --git a/extensions/cornerstone/src/OHIFCornerstoneViewport.js b/extensions/cornerstone/src/OHIFCornerstoneViewport.js index f7875c44c..cb71216b0 100644 --- a/extensions/cornerstone/src/OHIFCornerstoneViewport.js +++ b/extensions/cornerstone/src/OHIFCornerstoneViewport.js @@ -5,6 +5,7 @@ import ConnectedCornerstoneViewport from './ConnectedCornerstoneViewport'; import OHIF from '@ohif/core'; import PropTypes from 'prop-types'; import cornerstone from 'cornerstone-core'; +import checkForSRAnnotations from './tools/checkForSRAnnotations'; const { StackManager } = OHIF.utils; @@ -187,6 +188,8 @@ class OHIFCornerstoneViewport extends Component { displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID || displaySet.frameIndex !== prevDisplaySet.frameIndex ) { + const { viewportIndex } = this.props; + checkForSRAnnotations({ displaySet, viewportIndex }); this.setStateFromProps(); } } diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index fb0f55436..c6e9af984 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -5,6 +5,8 @@ import csTools from 'cornerstone-tools'; import merge from 'lodash.merge'; import initCornerstoneTools from './initCornerstoneTools.js'; import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; +import dicomSRModule from './tools/modules/dicomSRModule'; +import srModuleId from './tools/id'; /** * @@ -15,6 +17,8 @@ import measurementServiceMappingsFactory from './utils/measurementServiceMapping export default function init({ servicesManager, configuration }) { const { UIDialogService, MeasurementService } = servicesManager.services; + csTools.register('module', srModuleId, dicomSRModule); + const callInputDialog = (data, event, callback) => { if (UIDialogService) { let dialogId = UIDialogService.create({ diff --git a/extensions/cornerstone/src/tools/DICOMSRDisplayTool.js b/extensions/cornerstone/src/tools/DICOMSRDisplayTool.js new file mode 100644 index 000000000..ccece2546 --- /dev/null +++ b/extensions/cornerstone/src/tools/DICOMSRDisplayTool.js @@ -0,0 +1,414 @@ +import csTools, { + importInternal, + getToolState, + toolColors, +} from 'cornerstone-tools'; + +import cornerstone from 'cornerstone-core'; + +/** Internal imports */ +import TOOL_NAMES from './constants/toolNames'; +import SCOORD_TYPES from './constants/scoordTypes'; +import id from './id'; + +/** Cornerstone 3rd party dev kit imports */ +const draw = importInternal('drawing/draw'); +const drawJoinedLines = importInternal('drawing/drawJoinedLines'); +const drawCircle = importInternal('drawing/drawCircle'); +const drawEllipse = importInternal('drawing/drawEllipse'); +const drawHandles = importInternal('drawing/drawHandles'); +const drawArrow = importInternal('drawing/drawArrow'); +const getNewContext = importInternal('drawing/getNewContext'); +const BaseTool = importInternal('base/BaseTool'); +const drawLinkedTextBox = importInternal('drawing/drawLinkedTextBox'); + +/** + * @class DICOMSRDisplayTool - Renders DICOMSR data in a read only manner (i.e. as an overlay). + * + * This is a generic render tool. + * + * A single tool that, given some schema, can render + * POINT, MULTIPOINT, POLYLINE, CIRCLE, and ELLIPSE + * value types for a given imageId. + * + * + * @extends cornerstoneTools.BaseTool + */ +export default class DICOMSRDisplayTool extends BaseTool { + constructor(props = {}) { + const defaultProps = { + mixins: ['enabledOrDisabledBinaryTool'], + name: TOOL_NAMES.DICOM_SR_DISPLAY_TOOL, + }; + + const initialProps = Object.assign(defaultProps, props); + + super(initialProps); + + this._module = csTools.getModule(id); + } + + renderToolData(evt) { + const eventData = evt.detail; + const { element } = eventData; + const module = this._module; + + const toolState = getToolState(element, this.name); + + if (!toolState) { + return; + } + + const trackingUniqueIdentifiersForElement = module.getters.trackingUniqueIdentifiersForElement( + element + ); + + const { + activeIndex, + trackingUniqueIdentifiers, + } = trackingUniqueIdentifiersForElement; + + const activeTrackingUniqueIdentifier = + trackingUniqueIdentifiers[activeIndex]; + + // Filter toolData to only render the data for the active SR. + const filteredToolData = toolState.data.filter(td => + trackingUniqueIdentifiers.includes(td.TrackingUniqueIdentifier) + ); + + let shouldRepositionTextBoxes = false; + + for (let i = 0; i < filteredToolData.length; i++) { + const data = filteredToolData[i]; + const { renderableData, labels } = data; + + const color = + data.TrackingUniqueIdentifier === activeTrackingUniqueIdentifier + ? toolColors.getActiveColor() + : toolColors.getToolColor(); + const lineWidth = 2; + const options = { + color, + lineWidth, + handleRadius: 6, + }; + + Object.keys(renderableData).forEach(GraphicType => { + const renderableDataForGraphicType = renderableData[GraphicType]; + + switch (GraphicType) { + case SCOORD_TYPES.POINT: + this.renderPoint(renderableDataForGraphicType, eventData, options); + break; + case SCOORD_TYPES.MULTIPOINT: + this.renderMultipoint( + renderableDataForGraphicType, + eventData, + options + ); + break; + case SCOORD_TYPES.POLYGON: + case SCOORD_TYPES.POLYLINE: + this.renderPolyLine( + renderableDataForGraphicType, + eventData, + options + ); + break; + case SCOORD_TYPES.CIRCLE: + this.renderCircle(renderableDataForGraphicType, eventData, options); + break; + case SCOORD_TYPES.ELLIPSE: + this.renderEllipse( + renderableDataForGraphicType, + eventData, + options + ); + break; + } + }); + + const { element } = eventData; + const context = getNewContext(eventData.canvasContext.canvas); + + if (!data.handles || !data.handles.textBox) { + const textBox = { + active: false, + hasMoved: true, + movesIndependently: false, + drawnIndependently: true, + allowedOutsideImage: true, + hasBoundingBox: true, + }; + + const anchorPoints = _getTextBoxAnchorPointsForRenderableData( + renderableData, + eventData + ); + textBox.anchorPoints = anchorPoints; + + const bottomRight = { + x: Math.max(...anchorPoints.map(point => point.x)), + y: Math.max(...anchorPoints.map(point => point.y)), + }; + + textBox.x = bottomRight.x; + textBox.y = bottomRight.y; + + data.handles = {}; + data.handles.textBox = textBox; + + shouldRepositionTextBoxes = true; + } + + const text = _getTextBoxLinesFromLabels(labels); + + function textBoxAnchorPoints() { + return data.handles.textBox.anchorPoints; + } + + draw(context, context => { + drawLinkedTextBox( + context, + element, + data.handles.textBox, + text, + data.handles, + textBoxAnchorPoints, + color, + lineWidth, + 0, + true + ); + }); + } + + // TOOD -> text boxes may overlap with other annotations at the moment. + // To be fixed after we get requirements. + // if (shouldRepositionTextBoxes) { + // this.repositionTextBox(filteredToolData, eventData); + // } + } + + // repositionTextBox(toolData, eventData) { + // const toolBoundingBoxes = []; + + // for (let i = 0; i < toolData.length; i++) { + // const toolDataI = toolData[i]; + + // const { textBox } = toolDataI.handles; + // const { anchorPoints } = textBox; + + // const boundingBox = _getBoundingBoxFromAnchorPoints(anchorPoints); + // // Get the textbox bounding locations. + // // Get the tool extents. + // } + // } + + renderPolyLine(renderableData, eventData, options) { + const { element } = eventData; + const context = getNewContext(eventData.canvasContext.canvas); + renderableData.forEach(points => { + draw(context, context => { + drawJoinedLines(context, element, points[0], points, options); + }); + }); + } + + renderMultipoint(renderableData, eventData, options) { + const context = getNewContext(eventData.canvasContext.canvas); + + renderableData.forEach(points => { + draw(context, context => { + drawHandles(context, eventData, points, options); + }); + }); + } + + renderPoint(renderableData, eventData, options) { + // Render single point as an arrow. + const { element, image } = eventData; + const { rows, columns } = image; + const context = getNewContext(eventData.canvasContext.canvas); + + const { color, lineWidth } = options; + + // Find a suitable length for the image size. + + const xOffset = columns / 10; + const yOffset = rows / 10; + + renderableData.forEach(points => { + const point = points[0]; // The SCOORD type is POINT so the array length is 1. + draw(context, context => { + // Draw the arrow + const handleStartCanvas = cornerstone.pixelToCanvas(element, point); + const handleEndCanvas = cornerstone.pixelToCanvas(element, { + x: point.x + xOffset, + y: point.y + yOffset, + }); + + drawArrow( + context, + handleEndCanvas, + handleStartCanvas, + color, + lineWidth, + false + ); + }); + }); + } + + renderCircle(renderableData, eventData, options) { + const { element } = eventData; + + const context = getNewContext(eventData.canvasContext.canvas); + + renderableData.forEach(circle => { + const { center, radius } = circle; + + drawCircle(context, element, center, radius, options); + }); + } + + renderEllipse(renderableData, eventData, options) { + const { element } = eventData; + + const context = getNewContext(eventData.canvasContext.canvas); + + renderableData.forEach(ellipse => { + const { corner1, corner2 } = ellipse; + + drawEllipse( + context, + element, + corner1, + corner2, + options, + 'pixel', + 0 // TODO -> Work our the initial rotation and add it here so we render appropriately rotated ellipses. + ); + }); + } +} + +function _getTextBoxLinesFromLabels(labels) { + // TODO -> max 3 for now (label + shortAxis + longAxis), need a generic solution for this! + + const labelLength = Math.min(labels.length, 3); + const lines = []; + + for (let i = 0; i < labelLength; i++) { + const labelEntry = labels[i]; + lines.push(`${_labelToShorthand(labelEntry.label)}${labelEntry.value}`); + } + + return lines; +} + +const SHORT_HAND_MAP = { + 'Short Axis': 'W ', + 'Long Axis': 'L ', + AREA: 'Area ', + Length: '', + CORNERSTONEFREETEXT: '', +}; + +function _labelToShorthand(label) { + const shortHand = SHORT_HAND_MAP[label]; + + if (shortHand !== undefined) { + return shortHand; + } + + return label; +} + +function _getTextBoxAnchorPointsForRenderableData(renderableData, eventData) { + let anchorPoints = []; + + Object.keys(renderableData).forEach(GraphicType => { + const renderableDataForGraphicType = renderableData[GraphicType]; + + switch (GraphicType) { + case SCOORD_TYPES.POINT: + renderableDataForGraphicType.forEach(points => { + anchorPoints = [...anchorPoints, ...points]; + + // Add other arrow point based on image size. + const { image } = eventData; + const { rows, columns } = image; + + const xOffset = columns / 10; + const yOffset = rows / 10; + const point = points[0]; + + anchorPoints.push({ x: point.x + xOffset, y: point.y + yOffset }); + }); + + break; + case SCOORD_TYPES.MULTIPOINT: + case SCOORD_TYPES.POLYLINE: + case SCOORD_TYPES.POLYGON: + renderableDataForGraphicType.forEach(points => { + anchorPoints = [...anchorPoints, ...points]; + }); + break; + case SCOORD_TYPES.CIRCLE: + renderableDataForGraphicType.forEach(circle => { + const { center, radius } = circle; + + anchorPoints.push({ x: center.x + radius, y: center.y }); + anchorPoints.push({ x: center.x - radius, y: center.y }); + anchorPoints.push({ x: center.x, y: center.y + radius }); + anchorPoints.push({ x: center.x, y: center.y - radius }); + }); + + break; + case SCOORD_TYPES.ELLIPSE: + renderableDataForGraphicType.forEach(ellipse => { + const { corner1, corner2 } = ellipse; + + const halfWidth = Math.abs(corner1.x - corner2.x) / 2; + const halfHeight = Math.abs(corner1.y - corner2.y) / 2; + + const center = { + x: (corner1.x + corner2.x) / 2, + y: (corner1.y + corner2.y) / 2, + }; + + anchorPoints.push({ x: center.x + halfWidth, y: center.y }); + anchorPoints.push({ x: center.x - halfWidth, y: center.y }); + anchorPoints.push({ x: center.x, y: center.y + halfHeight }); + anchorPoints.push({ x: center.x, y: center.y - halfHeight }); + }); + break; + } + }); + + return anchorPoints; +} + +function _getBoundingBoxFromAnchorPoints(anchorPoints) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + anchorPoints.forEach(point => { + const { x, y } = point; + + if (x > maxX) { + maxX = x; + } else if (x < minX) { + minX = x; + } + + if (y > maxX) { + maxY = y; + } else if (y < minY) { + minY = y; + } + }); +} diff --git a/extensions/cornerstone/src/tools/checkForSRAnnotations.js b/extensions/cornerstone/src/tools/checkForSRAnnotations.js new file mode 100644 index 000000000..665eacc83 --- /dev/null +++ b/extensions/cornerstone/src/tools/checkForSRAnnotations.js @@ -0,0 +1,57 @@ +import csTools from 'cornerstone-tools'; +import cs from 'cornerstone-core'; +import OHIF from '@ohif/core'; + +import { getEnabledElement } from '../state'; +import id from './id'; + +const { studyMetadataManager } = OHIF.utils; + +const checkForSRAnnotations = ({ viewportIndex, displaySet }) => { + const srModule = csTools.getModule(id); + + const element = getEnabledElement(viewportIndex); + if (!element) { + return; + } + + const { StudyInstanceUID } = displaySet; + const studyMetadata = studyMetadataManager.get(StudyInstanceUID); + if (!studyMetadata) { + return; + } + + const srDisplaySets = studyMetadata + .getDisplaySets() + .filter(ds => ds.Modality === 'SR'); + if (srDisplaySets.length === 0) { + return; + } + + const { measurements: _measurements } = srDisplaySets[0]; + if (!_measurements || _measurements.length < 1) { + return; + } + + const measurements = _measurements.filter(m => m.loaded === true); + const measurement = measurements[0]; + if (!measurement) { + return; + } + + srModule.setters.trackingUniqueIdentifiersForElement( + element, + measurements.map(measurement => measurement.TrackingUniqueIdentifier), + measurement + ); + + const { TrackingUniqueIdentifier } = measurement; + srModule.setters.activeTrackingUniqueIdentifierForElement( + element, + TrackingUniqueIdentifier + ); + + cs.updateImage(element); +}; + +export default checkForSRAnnotations; diff --git a/extensions/cornerstone/src/tools/constants/scoordTypes.js b/extensions/cornerstone/src/tools/constants/scoordTypes.js new file mode 100644 index 000000000..35057e409 --- /dev/null +++ b/extensions/cornerstone/src/tools/constants/scoordTypes.js @@ -0,0 +1,8 @@ +export default { + POINT: 'POINT', + MULTIPOINT: 'MULTIPOINT', + POLYLINE: 'POLYLINE', + CIRCLE: 'CIRCLE', + ELLIPSE: 'ELLIPSE', + POLYGON: 'POLYGON', +}; diff --git a/extensions/cornerstone/src/tools/constants/toolNames.js b/extensions/cornerstone/src/tools/constants/toolNames.js new file mode 100644 index 000000000..ba11ac0ef --- /dev/null +++ b/extensions/cornerstone/src/tools/constants/toolNames.js @@ -0,0 +1,5 @@ +const TOOL_NAMES = { + DICOM_SR_DISPLAY_TOOL: 'DICOMSRDisplayTool', +}; + +export default TOOL_NAMES; diff --git a/extensions/cornerstone/src/tools/id.js b/extensions/cornerstone/src/tools/id.js new file mode 100644 index 000000000..623f69a90 --- /dev/null +++ b/extensions/cornerstone/src/tools/id.js @@ -0,0 +1,7 @@ +const id = 'org.ohif.dicom-sr'; + +export default id; + +const SOPClassHandlerName = 'dicom-sr'; +const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`; +export { SOPClassHandlerName, SOPClassHandlerId }; diff --git a/extensions/cornerstone/src/tools/initSRTools.js b/extensions/cornerstone/src/tools/initSRTools.js new file mode 100644 index 000000000..856ba5055 --- /dev/null +++ b/extensions/cornerstone/src/tools/initSRTools.js @@ -0,0 +1,108 @@ +import cornerstoneTools from 'cornerstone-tools'; +import DICOMSRDisplayTool from './DICOMSRDisplayTool'; +import TOOL_NAMES from './constants/toolNames'; +import getToolAlias from './utils/getToolAlias'; + +/** + * Initialize SR cornerstone tools. + * + * @param {*} targetElement + */ +const initSRTools = targetElement => { + const primaryToolId = 'Wwwc'; + const toolAlias = getToolAlias(primaryToolId); // These are 1:1 for built-in only + + // ~~ MAGIC + cornerstoneTools.addToolForElement(targetElement, DICOMSRDisplayTool); + cornerstoneTools.setToolEnabledForElement( + targetElement, + TOOL_NAMES.DICOM_SR_DISPLAY_TOOL + ); + + // ~~ Variants + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.LengthTool, + { + name: 'SRLength', + configuration: { + renderDashed: true, + }, + } + ); + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.ArrowAnnotateTool, + { + name: 'SRArrowAnnotate', + configuration: { + renderDashed: true, + }, + } + ); + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.BidirectionalTool, + { + name: 'SRBidirectional', + configuration: { + renderDashed: true, + }, + } + ); + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.EllipticalRoiTool, + { + name: 'SREllipticalRoi', + configuration: { + renderDashed: true, + }, + } + ); + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.RectangleRoiTool, + { + name: 'SRRectangleRoi', + configuration: { + renderDashed: true, + }, + } + ); + cornerstoneTools.addToolForElement( + targetElement, + cornerstoneTools.FreehandRoiTool, + { + name: 'SRFreehandRoi', + configuration: { + renderDashed: true, + }, + } + ); + + // ~~ Business as usual + cornerstoneTools.setToolActiveForElement(targetElement, 'PanMultiTouch', { + pointers: 2, + }); + cornerstoneTools.setToolActiveForElement(targetElement, 'ZoomTouchPinch', {}); + + // TODO: Add always dashed tool alternative aliases + // TODO: or same name... alternative config? + cornerstoneTools.setToolActiveForElement(targetElement, toolAlias, { + mouseButtonMask: 1, + }); + cornerstoneTools.setToolActiveForElement(targetElement, 'Pan', { + mouseButtonMask: 4, + }); + cornerstoneTools.setToolActiveForElement(targetElement, 'Zoom', { + mouseButtonMask: 2, + }); + cornerstoneTools.setToolActiveForElement( + targetElement, + 'StackScrollMouseWheel', + {} + ); +}; + +export default initSRTools; diff --git a/extensions/cornerstone/src/tools/modules/dicomSRModule.js b/extensions/cornerstone/src/tools/modules/dicomSRModule.js new file mode 100644 index 000000000..3fbb3c360 --- /dev/null +++ b/extensions/cornerstone/src/tools/modules/dicomSRModule.js @@ -0,0 +1,61 @@ +import cornerstone from 'cornerstone-core'; + +const state = { + TrackingUniqueIdentifier: null, + trackingIdentifiersByEnabledElementUUID: {}, +}; + +function setTrackingUniqueIdentifiersForElement( + element, + trackingUniqueIdentifiers, + activeIndex = 0 +) { + const enabledElement = cornerstone.getEnabledElement(element); + const { uuid } = enabledElement; + + state.trackingIdentifiersByEnabledElementUUID[uuid] = { + trackingUniqueIdentifiers, + activeIndex, + }; +} + +function setActiveTrackingUniqueIdentifierForElement( + element, + TrackingUniqueIdentifier +) { + const enabledElement = cornerstone.getEnabledElement(element); + const { uuid } = enabledElement; + + const trackingIdentifiersForElement = + state.trackingIdentifiersByEnabledElementUUID[uuid]; + + if (trackingIdentifiersForElement) { + const activeIndex = trackingIdentifiersForElement.trackingUniqueIdentifiers.findIndex( + tuid => tuid === TrackingUniqueIdentifier + ); + + trackingIdentifiersForElement.activeIndex = activeIndex; + } +} + +function getTrackingUniqueIdentifiersForElement(element) { + const enabledElement = cornerstone.getEnabledElement(element); + const { uuid } = enabledElement; + + if (state.trackingIdentifiersByEnabledElementUUID[uuid]) { + return state.trackingIdentifiersByEnabledElementUUID[uuid]; + } + + return { trackingUniqueIdentifiers: [] }; +} + +export default { + state, + getters: { + trackingUniqueIdentifiersForElement: getTrackingUniqueIdentifiersForElement, + }, + setters: { + trackingUniqueIdentifiersForElement: setTrackingUniqueIdentifiersForElement, + activeTrackingUniqueIdentifierForElement: setActiveTrackingUniqueIdentifierForElement, + }, +}; diff --git a/extensions/cornerstone/src/tools/utils/getToolAlias.js b/extensions/cornerstone/src/tools/utils/getToolAlias.js new file mode 100644 index 000000000..5460b70ca --- /dev/null +++ b/extensions/cornerstone/src/tools/utils/getToolAlias.js @@ -0,0 +1,32 @@ +/** + * Get cornerstone tool alias. + * + * @param {string} toolName + * @returns tool alias + */ +export default function getToolAlias(toolName) { + let toolAlias = toolName; + + switch (toolName) { + case 'Length': + toolAlias = 'SRLength'; + break; + case 'Bidirectional': + toolAlias = 'SRBidirectional'; + break; + case 'ArrowAnnotate': + toolAlias = 'SRArrowAnnotate'; + break; + case 'EllipticalRoi': + toolAlias = 'SREllipticalRoi'; + break; + case 'FreehandRoi': + toolAlias = 'SRFreehandRoi'; + break; + case 'RectangleRoi': + toolAlias = 'SRRectangleRoi'; + break; + } + + return toolAlias; +} diff --git a/extensions/dicom-html/src/OHIFDicomHtmlSopClassHandler.js b/extensions/dicom-html/src/OHIFDicomHtmlSopClassHandler.js index 94e62d885..5a1844f67 100644 --- a/extensions/dicom-html/src/OHIFDicomHtmlSopClassHandler.js +++ b/extensions/dicom-html/src/OHIFDicomHtmlSopClassHandler.js @@ -5,11 +5,12 @@ const SOP_CLASS_UIDS = { BASIC_TEXT_SR: '1.2.840.10008.5.1.4.1.1.88.11', ENHANCED_SR: '1.2.840.10008.5.1.4.1.1.88.22', COMPREHENSIVE_SR: '1.2.840.10008.5.1.4.1.1.88.33', + COMPREHENSIVE_3D_SR: '1.2.840.10008.5.1.4.1.1.88.34', PROCEDURE_LOG_STORAGE: '1.2.840.10008.5.1.4.1.1.88.40', MAMMOGRAPHY_CAD_SR: '1.2.840.10008.5.1.4.1.1.88.50', CHEST_CAD_SR: '1.2.840.10008.5.1.4.1.1.88.65', X_RAY_RADIATION_DOSE_SR: '1.2.840.10008.5.1.4.1.1.88.67', - ACQUISITION_CONTEXT_SR_STORAGE: '1.2.840.10008.5.1.4.1.1.88.71', + ACQUISITION_CONTEXT_SR_STORAGE: '1.2.840.10008.5.1.4.1.1.88.71' }; const sopClassUIDs = Object.values(SOP_CLASS_UIDS); @@ -46,6 +47,8 @@ const OHIFDicomHtmlSopClassHandler = { SeriesTime, SeriesNumber, authorizationHeaders, + sopClassUids: sopClassUIDs, + images: series._instances, }; }, }; diff --git a/lerna-debug.log b/lerna-debug.log new file mode 100644 index 000000000..126e7d323 --- /dev/null +++ b/lerna-debug.log @@ -0,0 +1,23 @@ +0 silly argv { +0 silly argv _: [ 'run' ], +0 silly argv stream: true, +0 silly argv lernaVersion: '3.18.3', +0 silly argv '$0': 'node_modules/.bin/lerna', +0 silly argv script: 'dev:viewer' +0 silly argv } +1 notice cli v3.18.3 +2 verbose rootPath /home/davide/Development/OIHF/Viewers +3 info versioning independent +4 info Executing command in 1 package: "yarn run dev:viewer" +5 silly npmRunScript.stream [ 'dev:viewer', [], '@ohif/viewer' ] +6 silly getExecOpts /home/davide/Development/OIHF/Viewers/platform/viewer undefined +7 error MaxBufferError: stderr maxBuffer exceeded +7 error at PassThrough. (/home/davide/Development/OIHF/Viewers/node_modules/get-stream/index.js:41:19) +7 error at PassThrough.emit (events.js:412:35) +7 error at addChunk (internal/streams/readable.js:290:12) +7 error at readableAddChunk (internal/streams/readable.js:261:11) +7 error at PassThrough.Readable.push (internal/streams/readable.js:204:10) +7 error at PassThrough.Transform.push (internal/streams/transform.js:166:32) +7 error at PassThrough.afterTransform (internal/streams/transform.js:101:10) +7 error at PassThrough._transform (internal/streams/passthrough.js:46:3) +7 error at PassThrough.Transform._read (internal/streams/transform.js:205:10) diff --git a/platform/core/package.json b/platform/core/package.json index 3ec968fcd..896781c06 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -47,6 +47,7 @@ "lodash.merge": "^4.6.1", "mousetrap": "^1.6.3", "retry": "^0.12.0", - "validate.js": "^0.12.0" + "validate.js": "^0.12.0", + "mathjs": "^10.1.0" } } diff --git a/platform/core/src/DICOMSR/SCOORD3D/constants/scoordTypes.js b/platform/core/src/DICOMSR/SCOORD3D/constants/scoordTypes.js new file mode 100644 index 000000000..35057e409 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/constants/scoordTypes.js @@ -0,0 +1,8 @@ +export default { + POINT: 'POINT', + MULTIPOINT: 'MULTIPOINT', + POLYLINE: 'POLYLINE', + CIRCLE: 'CIRCLE', + ELLIPSE: 'ELLIPSE', + POLYGON: 'POLYGON', +}; diff --git a/platform/core/src/DICOMSR/SCOORD3D/constants/toolNames.js b/platform/core/src/DICOMSR/SCOORD3D/constants/toolNames.js new file mode 100644 index 000000000..ba11ac0ef --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/constants/toolNames.js @@ -0,0 +1,5 @@ +const TOOL_NAMES = { + DICOM_SR_DISPLAY_TOOL: 'DICOMSRDisplayTool', +}; + +export default TOOL_NAMES; diff --git a/platform/core/src/DICOMSR/SCOORD3D/enums.js b/platform/core/src/DICOMSR/SCOORD3D/enums.js new file mode 100644 index 000000000..ac9871418 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/enums.js @@ -0,0 +1,29 @@ +export const CodeNameCodeSequenceValues = { + ImagingMeasurementReport: '126000', + ImageLibrary: '111028', + ImagingMeasurements: '126010', + MeasurementGroup: '125007', + ImageLibraryGroup: '126200', + TrackingUniqueIdentifier: '112040', + TrackingIdentifier: '112039', + Finding: '121071', + FindingSite: 'G-C0E3', // SRT + CornerstoneFreeText: 'CORNERSTONEFREETEXT', // CST4 + Score: '246262008', +}; + +export const RELATIONSHIP_TYPE = { + INFERRED_FROM: 'INFERRED FROM', + SELECTED_FROM: 'SELECTED FROM', +}; + +export const CodingSchemeDesignators = { + SRT: 'SRT', + cornerstoneTools4: 'CST4', +}; + +export default { + CodeNameCodeSequenceValues, + RELATIONSHIP_TYPE, + CodingSchemeDesignators, +}; diff --git a/platform/core/src/DICOMSR/SCOORD3D/parseSCOORD3D.js b/platform/core/src/DICOMSR/SCOORD3D/parseSCOORD3D.js new file mode 100644 index 000000000..beb1ea7a1 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/parseSCOORD3D.js @@ -0,0 +1,149 @@ +import { ImageSet } from '../../classes'; +import getMeasurements from './utils/getMeasurements'; +import getReferencedImagesList from './utils/getReferencedImagesList'; +import isRehydratable from './utils/isRehydratable'; +import addMeasurement from './utils/addMeasurement'; + +const parseSCOORD3D = ({ servicesManager, displaySets }) => { + const { MeasurementService } = servicesManager.services; + + const srDisplaySets = displaySets.filter(ds => ds.Modality === 'SR'); + + srDisplaySets.forEach(srDisplaySet => { + const firstInstance = srDisplaySet.metadata; + if (!firstInstance) { + return; + } + + const { ContentSequence } = firstInstance; + + srDisplaySet.referencedImages = getReferencedImagesList(ContentSequence); + srDisplaySet.measurements = getMeasurements(ContentSequence, srDisplaySet); + const mappings = MeasurementService.getSourceMappings( + 'CornerstoneTools', + '4' + ); + + srDisplaySet.isHydrated = false; + srDisplaySet.isRehydratable = isRehydratable(srDisplaySet, mappings); + srDisplaySet.isLoaded = true; + + const imageDisplaySets = displaySets.filter(ds => ds.Modality !== 'SR'); + imageDisplaySets.forEach(imageDisplaySet => { + // Check currently added displaySets and add measurements if the sources exist. + checkIfCanAddMeasurementsToDisplaySet(srDisplaySet, imageDisplaySet); + }); + }); +}; + +const checkIfCanAddMeasurementsToDisplaySet = ( + srDisplaySet, + imageDisplaySet +) => { + let measurements = srDisplaySet.measurements; + + /** + * Look for image sets. + * This also filters out _this_ displaySet, as it is not an image set. + */ + if (!(imageDisplaySet instanceof ImageSet)) { + return; + } + + const { sopClassUIDs, images } = imageDisplaySet; + + /** + * Filter measurements that references the correct sop class. + */ + measurements = measurements.filter(measurement => { + return measurement.coords.some(coord => { + if (coord.ReferencedSOPSequence === undefined) { + /** we miss the referenced information. We can compare the annotation SCOORD3D coordinates with + * the ImagePatientPosition of the frames. However (WARNING!!!), + * if more than a source series is present, this logic can find the wrong frame + * (i.e. two source series, with the same frameOfReferenceUID, + * that have each a frame with the same ImagePositionPatient of the annotation 3D coordinates) + */ + for (let i = 0; i < images.length; ++i) { + const imageMetadata = images[i].getData().metadata; + if ( + imageMetadata.FrameOfReferenceUID !== + coord.ReferencedFrameOfReferenceSequence + ) { + continue; + } + + let 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 1 mm tolerance + if (Math.abs(distanceAlongNormal - coord.GraphicData[2]) > 1) { + continue; + } + + coord.ReferencedSOPSequence = { + ReferencedSOPClassUID: imageMetadata.SOPClassUID, + ReferencedSOPInstanceUID: imageMetadata.SOPInstanceUID, + }; + + break; + } + + if (coord.ReferencedSOPSequence === undefined) { + return false; + } + } + + return sopClassUIDs.includes( + coord.ReferencedSOPSequence.ReferencedSOPClassUID + ); + }); + }); + + /** + * New display set doesn't have measurements that references the correct sop class. + */ + if (measurements.length === 0) { + return; + } + + const imageIds = images.map(i => i.getImageId()); + const SOPInstanceUIDs = images.map(i => i.SOPInstanceUID); + measurements.forEach(measurement => { + const { coords } = measurement; + + coords.forEach(coord => { + if (coord.ReferencedSOPSequence !== undefined) { + const imageIndex = SOPInstanceUIDs.findIndex( + SOPInstanceUID => + SOPInstanceUID === + coord.ReferencedSOPSequence.ReferencedSOPInstanceUID + ); + if (imageIndex > -1) { + const imageId = imageIds[imageIndex]; + const imageMetadata = images[imageIndex].getData().metadata; + addMeasurement( + measurement, + imageId, + imageMetadata, + imageDisplaySet.displaySetInstanceUID + ); + } + } + }); + }); +}; + +export default parseSCOORD3D; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/addMeasurement.js b/platform/core/src/DICOMSR/SCOORD3D/utils/addMeasurement.js new file mode 100644 index 000000000..42ad129f8 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/addMeasurement.js @@ -0,0 +1,102 @@ +import csTools from 'cornerstone-tools'; +import OHIF from '../../../'; + +/** Internal imports */ +import TOOL_NAMES from '../constants/toolNames'; +import getRenderableData from './getRenderableData'; + +const globalImageIdSpecificToolStateManager = + csTools.globalImageIdSpecificToolStateManager; + +/** + * Add a measurement to a display set. + * + * @param {*} measurement + * @param {*} imageId + * @param {*} displaySetInstanceUID + */ +export default function addMeasurement( + measurement, + imageId, + imageMetadata, + displaySetInstanceUID +) { + // TODO -> Render rotated ellipse . + const toolName = TOOL_NAMES.DICOM_SR_DISPLAY_TOOL; + + const measurementData = { + TrackingUniqueIdentifier: measurement.TrackingUniqueIdentifier, + renderableData: {}, + labels: measurement.labels, + }; + + measurement.coords.forEach(coord => { + const { GraphicType, GraphicData, ValueType } = coord; + + if (measurementData.renderableData[GraphicType] === undefined) { + measurementData.renderableData[GraphicType] = []; + } + measurementData.renderableData[GraphicType].push( + getRenderableData(GraphicType, GraphicData, ValueType, imageMetadata) + ); + }); + + const toolState = globalImageIdSpecificToolStateManager.saveToolState(); + + if (toolState[imageId] === undefined) { + toolState[imageId] = {}; + } + + const imageIdToolState = toolState[imageId]; + + // If we don't have tool state for this type of tool, add an empty object + if (imageIdToolState[toolName] === undefined) { + imageIdToolState[toolName] = { + data: [], + }; + } + + const toolData = imageIdToolState[toolName]; + + measurementData.description = `Read-only annotation`; + measurementData.isReadOnly = true; + toolData.data.push(measurementData); + + addToMeasurementApi({ measurementData, toolName, imageId }); + + 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; + + return measurement; +} + +const addToMeasurementApi = ({ measurementData, toolName, imageId }) => { + const measurementApi = OHIF.measurements.MeasurementApi.Instance; + + const toolType = toolName; + const collection = measurementApi.tools[toolType]; + if (!collection) return; + if (!measurementData || measurementData.cancelled) return; + + const imageAttributes = OHIF.measurements.getImageAttributes(null, imageId); + const measurement = Object.assign({}, measurementData, imageAttributes, { + lesionNamingNumber: measurementData.lesionNamingNumber, + userId: OHIF.user.getUserId(), + toolType, + }); + + const addedMeasurement = measurementApi.addMeasurement(toolType, measurement); + Object.assign(measurementData, addedMeasurement); + + const measurementLabel = OHIF.measurements.getLabel(measurementData); + if (measurementLabel) { + measurementData.labels = [measurementLabel]; + } +}; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getCoordsFromSCOORDOrSCOORD3D.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getCoordsFromSCOORDOrSCOORD3D.js new file mode 100644 index 000000000..e78528920 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getCoordsFromSCOORDOrSCOORD3D.js @@ -0,0 +1,31 @@ +const getCoordsFromSCOORDOrSCOORD3D = (graphicItem, displaySet) => { + const { ValueType, RelationshipType, GraphicType, GraphicData } = graphicItem; + + // if (RelationshipType !== RELATIONSHIP_TYPE.INFERRED_FROM) { + // console.warn( + // `Relationshiptype === ${RelationshipType}. Cannot deal with NON TID-1400 SCOORD group with RelationshipType !== "INFERRED FROM."` + // ); + // return; + // } + + const coords = { ValueType, GraphicType, GraphicData }; + + // ContentSequence has length of 1 as RelationshipType === 'INFERRED FROM' + if (ValueType === 'SCOORD') { + const { ReferencedSOPSequence } = graphicItem.ContentSequence; + coords.ReferencedSOPSequence = ReferencedSOPSequence; + } else if (ValueType === 'SCOORD3D') { + if (graphicItem.ReferencedFrameOfReferenceUID) { + coords.ReferencedFrameOfReferenceSequence = graphicItem.ReferencedFrameOfReferenceUID; + } else if (graphicItem.ContentSequence) { + const { + ReferencedFrameOfReferenceSequence, + } = graphicItem.ContentSequence; + coords.ReferencedFrameOfReferenceSequence = ReferencedFrameOfReferenceSequence; + } + } + + return coords; +}; + +export default getCoordsFromSCOORDOrSCOORD3D; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getLabelFromMeasuredValueSequence.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getLabelFromMeasuredValueSequence.js new file mode 100644 index 000000000..abcbec612 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getLabelFromMeasuredValueSequence.js @@ -0,0 +1,19 @@ +const getLabelFromMeasuredValueSequence = ( + ConceptNameCodeSequence, + MeasuredValueSequence +) => { + const { CodeMeaning } = ConceptNameCodeSequence; + const { NumericValue, MeasurementUnitsCodeSequence } = MeasuredValueSequence; + const { CodeValue } = MeasurementUnitsCodeSequence; + + const formatedNumericValue = NumericValue + ? Number(NumericValue).toFixed(1) + : ''; + + return { + label: CodeMeaning, + value: `${formatedNumericValue} ${CodeValue}`, + }; // E.g. Long Axis: 31.0 mm +}; + +export default getLabelFromMeasuredValueSequence; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getMeasurements.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getMeasurements.js new file mode 100644 index 000000000..45fb4c43c --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getMeasurements.js @@ -0,0 +1,47 @@ +import { CodeNameCodeSequenceValues } from '../enums'; +import getSequenceAsArray from './getSequenceAsArray'; +import getMergedContentSequencesByTrackingUniqueIdentifiers from './getMergedContentSequencesByTrackingUniqueIdentifiers'; +import processMeasurement from './processMeasurement'; + +const getMeasurements = ( + ImagingMeasurementReportContentSequence, + displaySet +) => { + const ImagingMeasurements = ImagingMeasurementReportContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.ImagingMeasurements + ); + + const MeasurementGroups = getSequenceAsArray( + ImagingMeasurements.ContentSequence + ).filter( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.MeasurementGroup + ); + + const mergedContentSequencesByTrackingUniqueIdentifiers = getMergedContentSequencesByTrackingUniqueIdentifiers( + MeasurementGroups + ); + + let measurements = []; + + Object.keys(mergedContentSequencesByTrackingUniqueIdentifiers).forEach( + trackingUniqueIdentifier => { + const mergedContentSequence = + mergedContentSequencesByTrackingUniqueIdentifiers[ + trackingUniqueIdentifier + ]; + + const measurement = processMeasurement(mergedContentSequence, displaySet); + if (measurement) { + measurements.push(measurement); + } + } + ); + + return measurements; +}; + +export default getMeasurements; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getMergedContentSequencesByTrackingUniqueIdentifiers.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getMergedContentSequencesByTrackingUniqueIdentifiers.js new file mode 100644 index 000000000..d140a503c --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getMergedContentSequencesByTrackingUniqueIdentifiers.js @@ -0,0 +1,54 @@ +import getSequenceAsArray from './getSequenceAsArray'; +import { CodeNameCodeSequenceValues } from '../enums'; + +const getMergedContentSequencesByTrackingUniqueIdentifiers = MeasurementGroups => { + const mergedContentSequencesByTrackingUniqueIdentifiers = {}; + + MeasurementGroups.forEach(MeasurementGroup => { + const ContentSequence = getSequenceAsArray( + MeasurementGroup.ContentSequence + ); + + const TrackingUniqueIdentifierItem = ContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.TrackingUniqueIdentifier + ); + + if (!TrackingUniqueIdentifierItem) { + console.warn( + 'No Tracking Unique Identifier, skipping ambiguous measurement.' + ); + } + + const trackingUniqueIdentifier = TrackingUniqueIdentifierItem.UID; + + if ( + mergedContentSequencesByTrackingUniqueIdentifiers[ + trackingUniqueIdentifier + ] === undefined + ) { + // Add the full ContentSequence + mergedContentSequencesByTrackingUniqueIdentifiers[ + trackingUniqueIdentifier + ] = [...ContentSequence]; + } else { + // Add the ContentSequence minus the tracking identifier, as we have this + // Information in the merged ContentSequence anyway. + ContentSequence.forEach(item => { + if ( + item.ConceptNameCodeSequence.CodeValue !== + CodeNameCodeSequenceValues.TrackingUniqueIdentifier + ) { + mergedContentSequencesByTrackingUniqueIdentifiers[ + trackingUniqueIdentifier + ].push(item); + } + }); + } + }); + + return mergedContentSequencesByTrackingUniqueIdentifiers; +}; + +export default getMergedContentSequencesByTrackingUniqueIdentifiers; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getReferencedImagesList.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getReferencedImagesList.js new file mode 100644 index 000000000..42edf9698 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getReferencedImagesList.js @@ -0,0 +1,38 @@ +import getSequenceAsArray from './getSequenceAsArray'; +import { CodeNameCodeSequenceValues } from '../enums'; + +const getReferencedImagesList = ImagingMeasurementReportContentSequence => { + const referencedImages = []; + + const ImageLibrary = ImagingMeasurementReportContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.ImageLibrary + ); + + if (!ImageLibrary.ContentSequence) { + return referencedImages; + } + + const ImageLibraryGroup = getSequenceAsArray( + ImageLibrary.ContentSequence + ).find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.ImageLibraryGroup + ); + + getSequenceAsArray(ImageLibraryGroup.ContentSequence).forEach(item => { + const { ReferencedSOPSequence } = item; + const { + ReferencedSOPClassUID, + ReferencedSOPInstanceUID, + } = ReferencedSOPSequence; + + referencedImages.push({ ReferencedSOPClassUID, ReferencedSOPInstanceUID }); + }); + + return referencedImages; +}; + +export default getReferencedImagesList; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getRenderableData.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getRenderableData.js new file mode 100644 index 000000000..4c8982a66 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getRenderableData.js @@ -0,0 +1,192 @@ +import csMath from 'cornerstone-math'; +import SCOORD_TYPES from '../constants/scoordTypes'; +import { inv } from 'mathjs'; + +const getRenderableData = ( + GraphicType, + GraphicData, + ValueType, + imageMetadata +) => { + let renderableData; + + const orientation = imageMetadata.ImageOrientationPatient; + const position = imageMetadata.ImagePositionPatient; + const pixelSpacing = imageMetadata.PixelSpacing; + const sliceSpacing = imageMetadata.SliceThickness + ? imageMetadata.SliceThickness + : 1; + // https://nipy.org/nibabel/dicom/dicom_orientation.html + const M = [ + [ + orientation[0] * pixelSpacing[0], + orientation[3] * pixelSpacing[1], + sliceSpacing, + position[0], + ], + [ + orientation[1] * pixelSpacing[0], + orientation[4] * pixelSpacing[1], + sliceSpacing, + position[1], + ], + [ + orientation[2] * pixelSpacing[0], + orientation[5] * pixelSpacing[1], + sliceSpacing, + position[2], + ], + [0, 0, 0, 1], + ]; + + // we need to go from 3D to pixel (cornerstone2D works in pixel coordinates), + // we take the inverse. + const M1 = inv(M); + + const worldToIJK = (point, M1) => { + const worldPoint = { + x: + M1[0][0] * point.x + M1[0][1] * point.y + M1[0][2] * point.z + M1[0][3], + y: + M1[1][0] * point.x + M1[1][1] * point.y + M1[1][2] * point.z + M1[1][3], + z: + M1[2][0] * point.x + M1[2][1] * point.y + M1[2][2] * point.z + M1[2][3], + }; + return worldPoint; + }; + + // https://dicom.innolitics.com/ciods/procedure-log/sr-document-content/00700023 + switch (GraphicType) { + case SCOORD_TYPES.POINT: + renderableData = []; + + if (ValueType === 'SCOORD3D') { + for (let i = 0; i < GraphicData.length; i += 3) { + const point = { + x: GraphicData[i], + y: GraphicData[i + 1], + z: GraphicData[i + 2], + }; + + renderableData.push(worldToIJK(point, M1)); + } + } else { + for (let i = 0; i < GraphicData.length; i += 2) { + renderableData.push({ x: GraphicData[i], y: GraphicData[i + 1] }); + } + } + + break; + case SCOORD_TYPES.MULTIPOINT: + renderableData = []; + + if (ValueType === 'SCOORD3D') { + for (let i = 0; i < GraphicData.length; i += 3) { + const point = { + x: GraphicData[i], + y: GraphicData[i + 1], + z: GraphicData[i + 2], + }; + + renderableData.push(worldToIJK(point, M1)); + } + } else { + for (let i = 0; i < GraphicData.length; i += 2) { + renderableData.push({ x: GraphicData[i], y: GraphicData[i + 1] }); + } + } + + break; + case SCOORD_TYPES.POLYLINE: + renderableData = []; + + if (ValueType === 'SCOORD3D') { + for (let i = 0; i < GraphicData.length; i += 3) { + const point = { + x: GraphicData[i], + y: GraphicData[i + 1], + z: GraphicData[i + 2], + }; + + renderableData.push(worldToIJK(point, M1)); + } + } else { + for (let i = 0; i < GraphicData.length; i += 2) { + renderableData.push({ x: GraphicData[i], y: GraphicData[i + 1] }); + } + } + + break; + case SCOORD_TYPES.POLYGON: + // this is only scoord3d + renderableData = []; + for (let i = 0; i < GraphicData.length; i += 3) { + const point = { + x: GraphicData[i], + y: GraphicData[i + 1], + z: GraphicData[i + 2], + }; + + renderableData.push(worldToIJK(point, M1)); + } + break; + case SCOORD_TYPES.CIRCLE: { + // this is only scoord + const center = { x: GraphicData[0], y: GraphicData[1] }; + const onPerimeter = { x: GraphicData[2], y: GraphicData[3] }; + + const radius = csMath.point.distance(center, onPerimeter); + + renderableData = { + center, + radius, + }; + break; + } + case SCOORD_TYPES.ELLIPSE: { + console.warn('ROTATED ELLIPSE NOT YET SUPPORTED!'); + // To Do: scoord3d ellips, need data for testing + const majorAxis = [ + { x: GraphicData[0], y: GraphicData[1] }, + { x: GraphicData[2], y: GraphicData[3] }, + ]; + const minorAxis = [ + { x: GraphicData[4], y: GraphicData[5] }, + { x: GraphicData[6], y: GraphicData[7] }, + ]; + + // Calculate two opposite corners of box defined by two axes. + + const minorAxisLength = csMath.point.distance(minorAxis[0], minorAxis[1]); + + const minorAxisDirection = { + x: (minorAxis[1].x - minorAxis[0].x) / minorAxisLength, + y: (minorAxis[1].y - minorAxis[0].y) / minorAxisLength, + }; + + const halfMinorAxisLength = minorAxisLength / 2; + + // First end point of major axis + half minor axis vector + const corner1 = { + x: majorAxis[0].x + minorAxisDirection.x * halfMinorAxisLength, + y: majorAxis[0].y + minorAxisDirection.y * halfMinorAxisLength, + }; + + // Second end point of major axis - half of minor axis vector + const corner2 = { + x: majorAxis[1].x - minorAxisDirection.x * halfMinorAxisLength, + y: majorAxis[1].y - minorAxisDirection.y * halfMinorAxisLength, + }; + + renderableData = { + corner1, + corner2, + }; + break; + } + } + + return renderableData; +}; + +export default getRenderableData; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/getSequenceAsArray.js b/platform/core/src/DICOMSR/SCOORD3D/utils/getSequenceAsArray.js new file mode 100644 index 000000000..937fbef46 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/getSequenceAsArray.js @@ -0,0 +1,4 @@ +const getSequenceAsArray = sequence => + Array.isArray(sequence) ? sequence : [sequence]; + +export default getSequenceAsArray; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/isRehydratable.js b/platform/core/src/DICOMSR/SCOORD3D/utils/isRehydratable.js new file mode 100644 index 000000000..cb95b288a --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/isRehydratable.js @@ -0,0 +1,48 @@ +import { adapters } from 'dcmjs'; + +const cornerstoneAdapters = adapters.Cornerstone; + +/** + * Checks if the given `displaySet`can be rehydrated into the `MeasurementService`. + * + * @param {object} displaySet The SR `displaySet` to check. + * @param {object[]} mappings The CornerstoneTools 4 mappings to the `MeasurementService`. + * @returns {boolean} True if the SR can be rehydrated into the `MeasurementService`. + */ +export default function isRehydratable(displaySet, mappings) { + if (!mappings || !mappings.length) { + return false; + } + + const mappingDefinitions = mappings.map(m => m.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/platform/core/src/DICOMSR/SCOORD3D/utils/processMeasurement.js b/platform/core/src/DICOMSR/SCOORD3D/utils/processMeasurement.js new file mode 100644 index 000000000..7fd16d909 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/processMeasurement.js @@ -0,0 +1,16 @@ +import processTID1410Measurement from './processTID1410Measurement'; +import processNonGeometricallyDefinedMeasurement from './processNonGeometricallyDefinedMeasurement'; + +const processMeasurement = (mergedContentSequence, displaySet) => { + if ( + mergedContentSequence.some( + group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D' + ) + ) { + return processTID1410Measurement(mergedContentSequence, displaySet); + } + + return processNonGeometricallyDefinedMeasurement(mergedContentSequence); +}; + +export default processMeasurement; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/processNonGeometricallyDefinedMeasurement.js b/platform/core/src/DICOMSR/SCOORD3D/utils/processNonGeometricallyDefinedMeasurement.js new file mode 100644 index 000000000..42238a575 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/processNonGeometricallyDefinedMeasurement.js @@ -0,0 +1,111 @@ +import getLabelFromMeasuredValueSequence from './getLabelFromMeasuredValueSequence'; +import getCoordsFromSCOORDOrSCOORD3D from './getCoordsFromSCOORDOrSCOORD3D'; +import { CodeNameCodeSequenceValues, CodingSchemeDesignators } from '../enums'; + +const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT'; + +const processNonGeometricallyDefinedMeasurement = mergedContentSequence => { + const NUMContentItems = mergedContentSequence.filter( + group => group.ValueType === 'NUM' + ); + + const UIDREFContentItem = mergedContentSequence.find( + group => group.ValueType === 'UIDREF' + ); + + const TrackingIdentifierContentItem = mergedContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.TrackingIdentifier + ); + + const Finding = mergedContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.Finding + ); + + const FindingSites = mergedContentSequence.filter( + item => + item.ConceptNameCodeSequence.CodingSchemeDesignator === + CodingSchemeDesignators.SRT && + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.FindingSite + ); + + const measurement = { + loaded: false, + labels: [], + coords: [], + TrackingUniqueIdentifier: UIDREFContentItem.UID, + TrackingIdentifier: TrackingIdentifierContentItem.TextValue, + }; + + if ( + Finding && + Finding.ConceptCodeSequence.CodingSchemeDesignator === + CodingSchemeDesignators.cornerstoneTools4 && + Finding.ConceptCodeSequence.CodeValue === + CodeNameCodeSequenceValues.CornerstoneFreeText + ) { + measurement.labels.push({ + label: CORNERSTONE_FREETEXT_CODE_VALUE, + value: Finding.ConceptCodeSequence.CodeMeaning, + }); + } + + // TODO -> Eventually hopefully support SNOMED or some proper code library, just free text for now. + if (FindingSites.length) { + const cornerstoneFreeTextFindingSite = FindingSites.find( + FindingSite => + FindingSite.ConceptCodeSequence.CodingSchemeDesignator === + CodingSchemeDesignators.cornerstoneTools4 && + FindingSite.ConceptCodeSequence.CodeValue === + CodeNameCodeSequenceValues.CornerstoneFreeText + ); + + if (cornerstoneFreeTextFindingSite) { + measurement.labels.push({ + label: CORNERSTONE_FREETEXT_CODE_VALUE, + value: cornerstoneFreeTextFindingSite.ConceptCodeSequence.CodeMeaning, + }); + } + } + + NUMContentItems.forEach(item => { + const { + ConceptNameCodeSequence, + ContentSequence, + MeasuredValueSequence, + } = item; + + const { ValueType } = ContentSequence; + + if (!ValueType === 'SCOORD' && !ValueType === 'SCOORD3D') { + console.warn( + `Graphic ${ValueType} not currently supported, skipping annotation.` + ); + + return; + } + + const coords = getCoordsFromSCOORDOrSCOORD3D(ContentSequence); + + if (coords) { + measurement.coords.push(coords); + } + + if (MeasuredValueSequence) { + measurement.labels.push( + getLabelFromMeasuredValueSequence( + ConceptNameCodeSequence, + MeasuredValueSequence + ) + ); + } + }); + + return measurement; +}; + +export default processNonGeometricallyDefinedMeasurement; diff --git a/platform/core/src/DICOMSR/SCOORD3D/utils/processTID1410Measurement.js b/platform/core/src/DICOMSR/SCOORD3D/utils/processTID1410Measurement.js new file mode 100644 index 000000000..65d86ef47 --- /dev/null +++ b/platform/core/src/DICOMSR/SCOORD3D/utils/processTID1410Measurement.js @@ -0,0 +1,88 @@ +import getLabelFromMeasuredValueSequence from './getLabelFromMeasuredValueSequence'; +import getCoordsFromSCOORDOrSCOORD3D from './getCoordsFromSCOORDOrSCOORD3D'; +import { RELATIONSHIP_TYPE, CodeNameCodeSequenceValues } from '../enums'; + +/** + * TID 1410 Planar ROI Measurements and Qualitative Evaluations. + * + * @param {*} mergedContentSequence + * @returns + */ +const processTID1410Measurement = (mergedContentSequence, displaySet) => { + // Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level, + // And non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D + + const graphicItem = mergedContentSequence.find( + group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D' + ); + + if (!graphicItem) { + console.warn( + `graphic ValueType ${graphicItem.ValueType} not currently supported, skipping annotation.` + ); + return; + } + + const UIDREFContentItem = mergedContentSequence.find( + group => group.ValueType === 'UIDREF' + ); + + const TrackingIdentifierContentItem = mergedContentSequence.find( + item => + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.TrackingIdentifier + ); + + const NUMContentItems = mergedContentSequence.filter( + group => group.ValueType === 'NUM' + ); + + const measurement = { + loaded: false, + labels: [], + coords: [getCoordsFromSCOORDOrSCOORD3D(graphicItem, displaySet)], + TrackingUniqueIdentifier: UIDREFContentItem.UID, + TrackingIdentifier: TrackingIdentifierContentItem.TextValue, + }; + + NUMContentItems.forEach(item => { + const { + ConceptNameCodeSequence, + ContentSequence, + MeasuredValueSequence, + } = item; + + if ( + item.ConceptNameCodeSequence.CodeValue === + CodeNameCodeSequenceValues.Score + ) { + ContentSequence.forEach(item => { + if ( + [ + RELATIONSHIP_TYPE.SELECTED_FROM, + RELATIONSHIP_TYPE.INFERRED_FROM, + ].includes(item.RelationshipType) + ) { + if (item.ReferencedSOPSequence) { + measurement.coords.forEach(coord => { + coord.ReferencedSOPSequence = item.ReferencedSOPSequence; + }); + } + } + }); + } + + if (MeasuredValueSequence) { + measurement.labels.push( + getLabelFromMeasuredValueSequence( + ConceptNameCodeSequence, + MeasuredValueSequence + ) + ); + } + }); + + return measurement; +}; + +export default processTID1410Measurement; diff --git a/platform/core/src/DICOMSR/dataExchange.js b/platform/core/src/DICOMSR/dataExchange.js index aab594da3..0d2b66630 100644 --- a/platform/core/src/DICOMSR/dataExchange.js +++ b/platform/core/src/DICOMSR/dataExchange.js @@ -19,9 +19,10 @@ import findMostRecentStructuredReport from './utils/findMostRecentStructuredRepo * Function to be registered into MeasurementAPI to retrieve measurements from DICOM Structured Reports * * @param {serverType} server + * @param {object} external * @returns {Promise} Should resolve with OHIF measurementData object */ -const retrieveMeasurements = server => { +const retrieveMeasurements = (server, external = {}) => { log.info('[DICOMSR] retrieveMeasurements'); if (!server || server.type !== 'dicomWeb') { @@ -36,7 +37,7 @@ const retrieveMeasurements = server => { if (!latestSeries) return Promise.resolve({}); - return retrieveMeasurementFromSR(latestSeries, studies, serverUrl); + return retrieveMeasurementFromSR(latestSeries, studies, serverUrl, external); }; /** diff --git a/platform/core/src/DICOMSR/handleStructuredReport.js b/platform/core/src/DICOMSR/handleStructuredReport.js index 5bb818038..d3306fa5a 100644 --- a/platform/core/src/DICOMSR/handleStructuredReport.js +++ b/platform/core/src/DICOMSR/handleStructuredReport.js @@ -17,9 +17,15 @@ const TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; * @param {Array} series - List of all series metaData loaded * @param {Array} studies - List of all studies metaData loaded * @param {string} serverUrl - Server URL to be used on request + * @param {object} external * @returns {Object} MeasurementData */ -const retrieveMeasurementFromSR = async (series, studies, serverUrl) => { +const retrieveMeasurementFromSR = async ( + series, + studies, + serverUrl, + external +) => { const config = { url: serverUrl, headers: DICOMWeb.getAuthorizationHeader(), @@ -40,7 +46,8 @@ const retrieveMeasurementFromSR = async (series, studies, serverUrl) => { const displaySets = getAllDisplaySets(studies); const measurementsData = parseDicomStructuredReport( part10SRArrayBuffer, - displaySets + displaySets, + external ); return measurementsData; diff --git a/platform/core/src/DICOMSR/parseDicomStructuredReport.js b/platform/core/src/DICOMSR/parseDicomStructuredReport.js index d887d3158..b4f929858 100644 --- a/platform/core/src/DICOMSR/parseDicomStructuredReport.js +++ b/platform/core/src/DICOMSR/parseDicomStructuredReport.js @@ -1,5 +1,6 @@ import dcmjs from 'dcmjs'; import classes from '../classes'; +import parseSCOORD3D from './SCOORD3D/parseSCOORD3D'; import findInstanceMetadataBySopInstanceUID from './utils/findInstanceMetadataBySopInstanceUid'; @@ -12,11 +13,19 @@ const { LogManager } = classes; * * @param {ArrayBuffer} part10SRArrayBuffer * @param {Array} displaySets + * @param {object} external * @returns */ -const parseDicomStructuredReport = (part10SRArrayBuffer, displaySets) => { - // Get the dicom data as an Object +const parseDicomStructuredReport = ( + part10SRArrayBuffer, + displaySets, + external +) => { + if (external && external.servicesManager) { + parseSCOORD3D({ servicesManager: external.servicesManager, displaySets }); + } + // Get the dicom data as an Object const dicomData = dcmjs.data.DicomMessage.readFile(part10SRArrayBuffer); const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset( dicomData.dict diff --git a/platform/core/src/DICOMSR/utils/findMostRecentStructuredReport.js b/platform/core/src/DICOMSR/utils/findMostRecentStructuredReport.js index 75b855a99..8c9bd9cec 100644 --- a/platform/core/src/DICOMSR/utils/findMostRecentStructuredReport.js +++ b/platform/core/src/DICOMSR/utils/findMostRecentStructuredReport.js @@ -41,6 +41,7 @@ const isStructuredReportSeries = series => { const supportedSopClassUIDs = [ '1.2.840.10008.5.1.4.1.1.88.22', '1.2.840.10008.5.1.4.1.1.11.1', + '1.2.840.10008.5.1.4.1.1.88.34', // COMPREHENSIVE_3D_SR ]; const firstInstance = series.getFirstInstance(); diff --git a/platform/core/src/measurements/classes/MeasurementApi.js b/platform/core/src/measurements/classes/MeasurementApi.js index be2847370..ec7d13c9d 100644 --- a/platform/core/src/measurements/classes/MeasurementApi.js +++ b/platform/core/src/measurements/classes/MeasurementApi.js @@ -802,6 +802,10 @@ export default class MeasurementApi { collection.push(addedMeasurement); } + if (measurement.isReadOnly) { + addedMeasurement.isReadOnly = measurement.isReadOnly; + } + if (!emptyItem) { // Reflect the entry in the tool group collection groupCollection.push({ diff --git a/platform/core/src/measurements/lib/getImageAttributes.js b/platform/core/src/measurements/lib/getImageAttributes.js index e2759a675..f39654346 100644 --- a/platform/core/src/measurements/lib/getImageAttributes.js +++ b/platform/core/src/measurements/lib/getImageAttributes.js @@ -1,9 +1,11 @@ import cornerstone from 'cornerstone-core'; -export default function(element) { - // Get the Cornerstone imageId - const enabledElement = cornerstone.getEnabledElement(element); - const imageId = enabledElement.image.imageId; +export default function(element, imageId) { + if (!imageId) { + // Get the Cornerstone imageId + const enabledElement = cornerstone.getEnabledElement(element); + imageId = enabledElement.image.imageId; + } // Get StudyInstanceUID & PatientID const { diff --git a/platform/core/src/measurements/tools/dicomSRDisplayTool.js b/platform/core/src/measurements/tools/dicomSRDisplayTool.js new file mode 100644 index 000000000..ffed8efda --- /dev/null +++ b/platform/core/src/measurements/tools/dicomSRDisplayTool.js @@ -0,0 +1,22 @@ +export const dicomSRDisplayTool = { + id: 'DICOMSRDisplayTool', + name: 'DICOMSRDisplayTool', + toolGroup: 'allTools', + cornerstoneToolType: 'DICOMSRDisplayTool', + options: { + measurementTable: { + displayFunction: data => { + return `(SR) ${data.lesionNamingNumber || + data.measurementNumber || + data.text || + ''}`; + }, + }, + caseProgress: { + include: true, + evaluate: true, + }, + }, +}; + +export default dicomSRDisplayTool; diff --git a/platform/core/src/measurements/tools/index.js b/platform/core/src/measurements/tools/index.js index 16035e221..ddc9d2d64 100644 --- a/platform/core/src/measurements/tools/index.js +++ b/platform/core/src/measurements/tools/index.js @@ -10,6 +10,7 @@ import { angle } from './angle'; import { targetCR } from './targetCR'; import { targetNE } from './targetNE'; import { targetUN } from './targetUN'; +import dicomSRDisplayTool from './dicomSRDisplayTool'; export { arrowAnnotate, @@ -24,4 +25,5 @@ export { targetCR, targetNE, targetUN, + dicomSRDisplayTool, }; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index ce58cff9e..888a2501d 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -1,5 +1,7 @@ +/** Internal imports */ import log from '../../log'; import guid from '../../utils/guid'; +import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; /** * Measurement source schema @@ -15,7 +17,7 @@ import guid from '../../utils/guid'; * * @typedef {Object} Measurement * @property {number} id - - * @property {string} sopInstanceUid - + * @property {string} SOPInstanceUID - * @property {string} FrameOfReferenceUID - * @property {string} referenceSeriesUID - * @property {string} label - @@ -32,24 +34,37 @@ const MEASUREMENT_SCHEMA_KEYS = [ 'id', 'SOPInstanceUID', 'FrameOfReferenceUID', + 'referenceStudyUID', 'referenceSeriesUID', 'label', 'description', 'type', 'unit', 'area', // TODO: Add concept names instead (descriptor) + 'mean', + 'stdDev', + 'length', + 'shortestDiameter', + 'longestDiameter', + 'text', // NOTE: There is nothing like this in SR. 'points', 'source', ]; const EVENTS = { MEASUREMENT_UPDATED: 'event::measurement_updated', + INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated', MEASUREMENT_ADDED: 'event::measurement_added', + RAW_MEASUREMENT_ADDED: 'event::raw_measurement_added', + MEASUREMENT_REMOVED: 'event::measurement_removed', + MEASUREMENTS_CLEARED: 'event::measurements_cleared', + JUMP_TO_MEASUREMENT: 'event:jump_to_measurement', }; const VALUE_TYPES = { POLYLINE: 'value_type::polyline', POINT: 'value_type::point', + BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here. ELLIPSE: 'value_type::ellipse', MULTIPOINT: 'value_type::multipoint', CIRCLE: 'value_type::circle', @@ -61,6 +76,7 @@ class MeasurementService { this.mappings = {}; this.measurements = {}; this.listeners = {}; + this._jumpToMeasurementCache = {}; Object.defineProperty(this, 'EVENTS', { value: EVENTS, writable: false, @@ -73,6 +89,8 @@ class MeasurementService { enumerable: true, configurable: false, }); + + Object.assign(this, pubSubServiceInterface); } /** @@ -91,7 +109,7 @@ class MeasurementService { /** * Get specific measurement by its id. * - * @param {string} id If of the measurement + * @param {string} id Id of the measurement * @return {Measurement} Measurement instance */ getMeasurement(id) { @@ -114,13 +132,11 @@ class MeasurementService { */ createSource(name, version) { if (!name) { - log.warn('Source name not provided. Exiting early.'); - return; + throw new Error('Source name not provided.'); } if (!version) { - log.warn('Source version not provided. Exiting early.'); - return; + throw new Error('Source version not provided.'); } const id = guid(); @@ -132,6 +148,9 @@ class MeasurementService { source.addOrUpdate = (definition, measurement) => { return this.addOrUpdate(source, definition, measurement); }; + source.remove = id => { + return this.remove(id, source); + }; source.getAnnotation = (definition, measurementId) => { return this.getAnnotation(source, definition, measurementId); }; @@ -142,6 +161,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. * @@ -160,28 +205,23 @@ class MeasurementService { toMeasurementSchema ) { if (!this._isValidSource(source)) { - log.warn('Invalid source. Exiting early.'); - return; + throw new Error('Invalid source.'); } if (!matchingCriteria) { - log.warn('Matching criteria not provided. Exiting early.'); - return; + throw new Error('Matching criteria not provided.'); } if (!definition) { - log.warn('Definition not provided. Exiting early.'); - return; + throw new Error('Definition not provided.'); } if (!toSourceSchema) { - log.warn('Source mapping function not provided. Exiting early.'); - return; + throw new Error('Mapping function to source schema not provided.'); } if (!toMeasurementSchema) { - log.warn('Measurement mapping function not provided. Exiting early.'); - return; + throw new Error('Measurement mapping function not provided.'); } const mapping = { @@ -227,9 +267,9 @@ class MeasurementService { measurementId, definition ); + const measurement = this.getMeasurement(measurementId); if (mapping) return mapping.toSourceSchema(measurement, definition); - const measurement = this.getMeasurement(measurementId); const matchingMapping = this._getMatchingMapping( source, definition, @@ -243,15 +283,50 @@ class MeasurementService { } } + update(id, measurement, notYetUpdatedAtSource = false) { + if (this.measurements[id]) { + const updatedMeasurement = { + ...measurement, + modifiedTimestamp: Math.floor(Date.now() / 1000), + }; + + log.info( + `Updating internal measurement representation...`, + updatedMeasurement + ); + + this.measurements[id] = updatedMeasurement; + + this.publish( + // Add an internal flag to say the measurement has not yet been updated at source. + this.EVENTS.MEASUREMENT_UPDATED, + { + source: measurement.source, + measurement: updatedMeasurement, + notYetUpdatedAtSource, + } + ); + + return updatedMeasurement.id; + } + } + /** - * Adds or update persisted measurements. - * - * @param {MeasurementSource} source The measurement source instance - * @param {string} definition The source definition - * @param {Measurement} measurement The source measurement - * @return {string} A measurement id + * 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 measurement 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. */ - addOrUpdate(source, definition, sourceMeasurement) { + addRawMeasurement( + source, + definition, + data, + toMeasurementSchema, + dataSource = {} + ) { if (!this._isValidSource(source)) { log.warn('Invalid source. Exiting early.'); return; @@ -273,14 +348,8 @@ class MeasurementService { let measurement = {}; try { - const sourceMappings = this.mappings[source.id]; - const { toMeasurementSchema } = sourceMappings.find( - mapping => mapping.definition === definition - ); - /* Convert measurement */ - measurement = toMeasurementSchema(sourceMeasurement); - + measurement = toMeasurementSchema(data); /* Assign measurement source instance */ measurement.source = source; } catch (error) { @@ -298,7 +367,7 @@ class MeasurementService { return; } - let internalId = sourceMeasurement.id; + let internalId = data.id; if (!internalId) { internalId = guid(); log.warn(`Measurement ID not found. Generating UID: ${internalId}`); @@ -316,49 +385,167 @@ class MeasurementService { newMeasurement ); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_UPDATED, + this.publish(this.EVENTS.MEASUREMENT_UPDATED, { source, - newMeasurement - ); + measurement: newMeasurement, + }); } else { log.info(`Measurement added.`, newMeasurement); this.measurements[internalId] = newMeasurement; - this._broadcastChange( - this.EVENTS.MEASUREMENT_ADDED, + this.publish(this.EVENTS.RAW_MEASUREMENT_ADDED, { source, - newMeasurement - ); + measurement: newMeasurement, + data, + dataSource, + }); } return newMeasurement.id; } /** - * Subscribe to measurement updates. + * Adds or update persisted measurements. * - * @param {string} eventName The name of the event - * @param {Function} callback Events callback - * @return {Object} Observable object with actions + * @param {MeasurementSource} source The measurement source instance + * @param {string} definition The source definition + * @param {Measurement} measurement The source measurement + * @return {string} A measurement id */ - subscribe(eventName, callback) { - if (this._isValidEvent(eventName)) { - const listenerId = guid(); - const subscription = { id: listenerId, callback }; - - console.info(`Subscribing to '${eventName}'.`); - if (Array.isArray(this.listeners[eventName])) { - this.listeners[eventName].push(subscription); - } else { - this.listeners[eventName] = [subscription]; - } - - return { - unsubscribe: () => this._unsubscribe(eventName, listenerId), - }; - } else { - throw new Error(`Event ${eventName} not supported.`); + addOrUpdate(source, definition, sourceMeasurement) { + if (!this._isValidSource(source)) { + throw new Error('Invalid source.'); } + + if (!definition) { + throw new Error('No source definition provided.'); + } + + const sourceInfo = this._getSourceInfo(source); + + if (!this._sourceHasMappings(source)) { + throw new Error( + `No measurement mappings found for '${sourceInfo}' source. Exiting early.` + ); + } + + let measurement = {}; + try { + const sourceMappings = this.mappings[source.id]; + const { toMeasurementSchema } = sourceMappings.find( + mapping => mapping.definition === definition + ); + + /* Convert measurement */ + measurement = toMeasurementSchema(sourceMeasurement); + + /* Assign measurement source instance */ + measurement.source = source; + } catch (error) { + throw new Error( + `Failed to map '${sourceInfo}' measurement for definition ${definition}:`, + error.message + ); + } + + if (!this._isValidMeasurement(measurement)) { + throw new Error( + `Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.` + ); + } + + let internalId = sourceMeasurement.id; + if (!internalId) { + internalId = guid(); + log.info(`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.publish(this.EVENTS.MEASUREMENT_UPDATED, { + source, + measurement: newMeasurement, + notYetUpdatedAtSource: false, + }); + } else { + log.info('Measurement added.', newMeasurement); + this.measurements[internalId] = newMeasurement; + this.publish(this.EVENTS.MEASUREMENT_ADDED, { + source, + measurement: newMeasurement, + }); + } + + return newMeasurement.id; + } + + /** + * Removes a measurement and broadcasts the removed event. + * + * @param {string} id The measurement id + * @param {MeasurementSource} source The measurement source instance + * @return {string} The removed measurement id + */ + remove(id, source) { + if (!id || !this.measurements[id]) { + log.warn(`No id provided, or unable to find measurement by id.`); + return; + } + + delete this.measurements[id]; + this.publish(this.EVENTS.MEASUREMENT_REMOVED, { + source, + measurement: id, // This is weird :shrug: + }); + } + + clearMeasurements() { + this.measurements = {}; + this._jumpToMeasurementCache = {}; + this.publish(this.EVENTS.MEASUREMENTS_CLEARED); + } + + jumpToMeasurement(viewportIndex, id) { + const measurement = this.measurements[id]; + + if (!measurement) { + log.warn(`No id provided, or unable to find measurement by id.`); + return; + } + + this._addJumpToMeasurement(viewportIndex, id); + + const eventName = this.EVENTS.JUMP_TO_MEASUREMENT; + + const hasListeners = Object.keys(this.listeners).length > 0; + const hasCallbacks = Array.isArray(this.listeners[eventName]); + + if (hasListeners && hasCallbacks) { + this.listeners[eventName].forEach(listener => { + listener.callback({ viewportIndex, measurement }); + }); + } + } + + _addJumpToMeasurement(viewportIndex, id) { + this._jumpToMeasurementCache[viewportIndex] = id; + } + + getJumpToMeasurement(viewportIndex) { + return this._jumpToMeasurementCache[viewportIndex]; + } + + removeJumpToMeasurement(viewportIndex) { + delete this._jumpToMeasurementCache[viewportIndex]; } _getMappingByMeasurementSource(measurementId, definition) { @@ -370,12 +557,20 @@ class MeasurementService { } } + /** + * Clear all measurements and broadcasts cleared event. + */ + clear() { + this.measurements = {}; + this.publish(this.EVENTS.MEASUREMENTS_CLEARED); + } + /** * Get measurement mapping function if matching criteria. * * @param {MeasurementSource} source Measurement source instance * @param {string} definition The source definition - * @param {string} measurement The measurement serice measurement + * @param {Measurement} measurement The measurement service measurement * @return {Object} The mapping based on matched criteria */ _getMatchingMapping(source, definition, measurement) { @@ -387,10 +582,27 @@ class MeasurementService { /* Criteria Matching */ return sourceMappingsByDefinition.find(({ matchingCriteria }) => { - return ( + if (matchingCriteria.type !== measurement.type) { + return false; + } + + if ( + matchingCriteria.properties && + matchingCriteria.properties.every(name => + measurement.hasOwnProperty(name) + ) + ) { + return true; + } + + if ( measurement.points && measurement.points.length === matchingCriteria.points - ); + ) { + return true; + } + + return false; }); } @@ -426,47 +638,6 @@ class MeasurementService { ); } - /** - * Broadcasts measurement changes. - * - * @param {string} measurementId The measurement id - * @param {MeasurementSource} source The measurement source - * @param {string} eventName The event name - * @return void - */ - _broadcastChange(eventName, source, measurement) { - const hasListeners = Object.keys(this.listeners).length > 0; - const hasCallbacks = Array.isArray(this.listeners[eventName]); - - if (hasListeners && hasCallbacks) { - this.listeners[eventName].forEach(listener => { - listener.callback({ source, measurement }); - }); - } - } - - /** - * Unsubscribe to measurement updates. - * - * @param {string} eventName The name of the event - * @param {string} listenerId The listeners id - * @return void - */ - _unsubscribe(eventName, listenerId) { - if (!this.listeners[eventName]) { - return; - } - - const listeners = this.listeners[eventName]; - if (Array.isArray(listeners)) { - this.listeners[eventName] = listeners.filter( - ({ id }) => id !== listenerId - ); - } else { - this.listeners[eventName] = undefined; - } - } - /** * Check if a given measurement data is valid. * @@ -489,10 +660,10 @@ class MeasurementService { * * @param {string} eventName The name of the event * @return {boolean} Event name validation - */ - _isValidEvent(eventName) { - return Object.values(this.EVENTS).includes(eventName); - } + // */ + // _isValidEvent(eventName) { + // return Object.values(this.EVENTS).includes(eventName); + // } /** * Converts object of objects to array. diff --git a/platform/core/src/services/MeasurementService/MeasurementService.test.js b/platform/core/src/services/MeasurementService/MeasurementService.test.js index 5b24b54a0..14752ba1d 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.test.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.test.js @@ -13,7 +13,7 @@ describe('MeasurementService.js', () => { let source; let definition; let matchingCriteria; - let toAnnotation; + let toSourceSchema; let toMeasurement; let annotation; @@ -34,11 +34,20 @@ describe('MeasurementService.js', () => { unit: 'mm', area: 123, type: measurementService.VALUE_TYPES.POLYLINE, - points: [{ x: 1, y: 2 }, { x: 1, y: 2 }], + points: [ + { x: 1, y: 2 }, + { x: 1, y: 2 }, + ], source: source, }; - toAnnotation = () => annotation; - toMeasurement = () => measurement; + toSourceSchema = () => annotation; + toMeasurement = () => { + if (Object.keys(measurement).includes('invalidProperty')) { + throw new Error('Measurement does not match schema'); + } + + return measurement; + } matchingCriteria = { valueType: measurementService.VALUE_TYPES.POLYLINE, points: 2, @@ -52,16 +61,16 @@ describe('MeasurementService.js', () => { measurementService.createSource('Testing', '1'); }); - it('logs warning and return early if no name provided', () => { - measurementService.createSource(null, '1'); - - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no name provided', () => { + expect(() => { + measurementService.createSource(null, '1') + }).toThrow(new Error('Source name not provided.')); }); - it('logs warning and return early if no version provided', () => { - measurementService.createSource('Testing', null); - - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no version provided', () => { + expect(() => { + measurementService.createSource('Testing', null) + }).toThrow(new Error('Source version not provided.')); }); }); @@ -71,83 +80,84 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); }); - it('logs warning and return early if no matching criteria provided', () => { - measurementService.addMapping( - source, - definition, - null, - toAnnotation, - toMeasurement - ); + it('throws Error if invalid source provided', () => { + expect(() => { + const invalidSource = {}; - expect(log.warn.mock.calls.length).toBe(1); + measurementService.addMapping( + invalidSource, + definition, + matchingCriteria, + toSourceSchema, + toMeasurement + ); + }).toThrow(new Error('Invalid source.')); }); - it('logs warning and return early if invalid source provided', () => { - const invalidSoure = {}; - - measurementService.addMapping( - invalidSoure, - definition, - matchingCriteria, - toAnnotation, - toMeasurement - ); - - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no matching criteria provided', () => { + expect(() => { + measurementService.addMapping( + source, + definition, + null, + toSourceSchema, + toMeasurement + ); + }).toThrow(new Error('Matching criteria not provided.')); }); - it('logs warning and return early if no source provided', () => { - measurementService.addMapping( - null /* source */, - definition, - matchingCriteria, - toAnnotation, - toMeasurement - ); - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no source provided', () => { + expect(() => { + measurementService.addMapping( + null /* source */, + definition, + matchingCriteria, + toSourceSchema, + toMeasurement + ); + }).toThrow(new Error('Invalid source.')); }); it('logs warning and return early if no definition provided', () => { - measurementService.addMapping( - source, - null /* definition */, - matchingCriteria, - toAnnotation, - toMeasurement - ); - - expect(log.warn.mock.calls.length).toBe(1); + expect(() => { + measurementService.addMapping( + source, + null /* definition */, + matchingCriteria, + toSourceSchema, + toMeasurement + ); + }).toThrow(new Error('Definition not provided.')); }); - it('logs warning and return early if no measurement mapping function provided', () => { - measurementService.addMapping( - source, - definition, - matchingCriteria, - null /* toAnnotation */, - toMeasurement - ); - - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no measurement mapping function provided', () => { + expect(() => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + null /* toSourceSchema */, + toMeasurement + ); + }).toThrow(new Error('Mapping function to source schema not provided.')); }); - it('logs warning and return early if no annotation mapping function provided', () => { - measurementService.addMapping( - source, - definition, - matchingCriteria, - toAnnotation, - null /* toMeasurement */ - ); - - expect(log.warn.mock.calls.length).toBe(1); + it('throws Error if no annotation mapping function provided', () => { + expect(() => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toSourceSchema, + null /* toMeasurement */ + ); + }).toThrow(new Error('Measurement mapping function not provided.')); }); }); @@ -157,7 +167,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); const measurementId = source.addOrUpdate(definition, annotation); @@ -171,7 +181,7 @@ describe('MeasurementService.js', () => { source, definition, {}, - toAnnotation, + toSourceSchema, toMeasurement ); const measurementId = source.addOrUpdate(definition, annotation); @@ -193,7 +203,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -212,7 +222,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -232,7 +242,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -245,9 +255,9 @@ describe('MeasurementService.js', () => { }); it('fails to add new measurements when no mapping', () => { - source.addOrUpdate(definition, measurement); - - expect(log.warn.mock.calls.length).toBe(1); + expect(() => { + source.addOrUpdate(definition, measurement); + }).toThrow() }); it('fails to add new measurements when invalid mapping function', () => { @@ -255,13 +265,13 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, 1 /* Invalid */ ); - source.addOrUpdate(definition, measurement); - - expect(log.warn.mock.calls.length).toBe(1); + expect(() => { + source.addOrUpdate(definition, measurement); + }).toThrow() }); it('adds new measurement with custom id', () => { @@ -271,7 +281,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -288,28 +298,28 @@ describe('MeasurementService.js', () => { expect(newMeasurement).toEqual(savedMeasurement); }); - it('logs warning and return if adding invalid measurement', () => { + it('throws Error if adding invalid measurement', () => { measurement.invalidProperty = {}; measurementService.addMapping( source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); - source.addOrUpdate(definition, measurement); - - expect(log.warn.mock.calls.length).toBe(2); + expect(() => { + source.addOrUpdate(definition, measurement); + }).toThrow() }); - it('updates existent measurement', () => { + it('updates existing measurement', () => { measurementService.addMapping( source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -330,7 +340,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -354,7 +364,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); @@ -381,7 +391,7 @@ describe('MeasurementService.js', () => { source, definition, matchingCriteria, - toAnnotation, + toSourceSchema, toMeasurement ); diff --git a/platform/core/src/services/_shared/index.js b/platform/core/src/services/_shared/index.js new file mode 100644 index 000000000..3ae9a1496 --- /dev/null +++ b/platform/core/src/services/_shared/index.js @@ -0,0 +1,3 @@ +import pubSubServiceInterface from './pubSubServiceInterface'; + +export { pubSubServiceInterface }; diff --git a/platform/core/src/services/_shared/pubSubServiceInterface.js b/platform/core/src/services/_shared/pubSubServiceInterface.js new file mode 100644 index 000000000..9fdba83c5 --- /dev/null +++ b/platform/core/src/services/_shared/pubSubServiceInterface.js @@ -0,0 +1,88 @@ +import guid from '../../utils/guid'; + +/** + * Consumer must implement: + * this.listeners = {} + * this.EVENTS = { "EVENT_KEY": "EVENT_VALUE" } + */ +export default { + subscribe, + publish, + _unsubscribe, + _isValidEvent, +}; + +/** + * Subscribe to updates. + * + * @param {string} eventName The name of the event + * @param {Function} callback Events callback + * @return {Object} Observable object with actions + */ +function subscribe(eventName, callback) { + if (this._isValidEvent(eventName)) { + const listenerId = guid(); + const subscription = { id: listenerId, callback }; + + console.info(`Subscribing to '${eventName}'.`); + if (Array.isArray(this.listeners[eventName])) { + this.listeners[eventName].push(subscription); + } else { + this.listeners[eventName] = [subscription]; + } + + return { + unsubscribe: () => this._unsubscribe(eventName, listenerId), + }; + } else { + throw new Error(`Event ${eventName} not supported.`); + } +} + +/** + * Unsubscribe to measurement updates. + * + * @param {string} eventName The name of the event + * @param {string} listenerId The listeners id + * @return void + */ +function _unsubscribe(eventName, listenerId) { + if (!this.listeners[eventName]) { + return; + } + + const listeners = this.listeners[eventName]; + if (Array.isArray(listeners)) { + this.listeners[eventName] = listeners.filter(({ id }) => id !== listenerId); + } else { + this.listeners[eventName] = undefined; + } +} + +/** + * Check if a given event is valid. + * + * @param {string} eventName The name of the event + * @return {boolean} Event name validation + */ +function _isValidEvent(eventName) { + return Object.values(this.EVENTS).includes(eventName); +} + +/** + * Broadcasts changes. + * + * @param {string} eventName - The event name + * @param {func} callbackProps - Properties to pass callback + * @return void + */ +function publish(eventName, callbackProps) { + const hasListeners = Object.keys(this.listeners).length > 0; + const hasCallbacks = Array.isArray(this.listeners[eventName]); + + if (hasListeners && hasCallbacks) { + this.listeners[eventName].forEach(listener => { + listener.callback(callbackProps); + }); + } +} diff --git a/platform/ui/src/components/measurementTable/MeasurementTable.js b/platform/ui/src/components/measurementTable/MeasurementTable.js index 47bbe1adf..f9d33aa3f 100644 --- a/platform/ui/src/components/measurementTable/MeasurementTable.js +++ b/platform/ui/src/components/measurementTable/MeasurementTable.js @@ -139,7 +139,6 @@ class MeasurementTable extends Component { const itemIndex = measurement.itemNumber || index + 1; const itemClass = selectedKey === key && !this.props.readOnly ? 'selected' : ''; - return ( - {hasWarnings ? ( + {hasWarnings && !isReadOnly ? ( {this.getTableListItem()} ) : ( - {this.getTableListItem()} - )} + {this.getTableListItem()} + )} ); } @@ -62,9 +66,11 @@ class MeasurementTableItem extends Component { }; getTableListItem = () => { - const hasWarningClass = this.props.measurementData.hasWarnings - ? 'hasWarnings' - : ''; + const hasWarningClass = + this.props.measurementData.hasWarnings && + !this.props.measurementData.isReadOnly + ? 'hasWarnings' + : ''; const actionButtons = []; @@ -102,7 +108,9 @@ class MeasurementTableItem extends Component { })}
{this.getDataDisplayText()}
-
{actionButtons}
+ {!this.props.measurementData.isReadOnly && ( +
{actionButtons}
+ )} ); diff --git a/platform/ui/src/components/studyBrowser/ImageThumbnail.js b/platform/ui/src/components/studyBrowser/ImageThumbnail.js index 2fb260ced..7380a6702 100644 --- a/platform/ui/src/components/studyBrowser/ImageThumbnail.js +++ b/platform/ui/src/components/studyBrowser/ImageThumbnail.js @@ -59,9 +59,9 @@ function ImageThumbnail(props) { }) .catch(error => { if (error.isCanceled) return; - setLoading(false); - setError(true); - throw new Error(error); + // setLoading(false); + // setError(true); + // throw new Error(error); }); }; diff --git a/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js b/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js index 5c9cb3781..13c0068a1 100644 --- a/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js +++ b/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js @@ -136,6 +136,7 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { measurementNumber, lesionNamingNumber, toolType, + isReadOnly } = measurementData; const measurementId = measurementData._id; @@ -154,6 +155,7 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { lesionNamingNumber, toolType, hasWarnings, + isReadOnly, warningTitle, warningList, isSplitLesion: false, //TODO diff --git a/platform/viewer/src/connectedComponents/Viewer.js b/platform/viewer/src/connectedComponents/Viewer.js index 2ed2e1ee0..8289c367f 100644 --- a/platform/viewer/src/connectedComponents/Viewer.js +++ b/platform/viewer/src/connectedComponents/Viewer.js @@ -11,7 +11,7 @@ import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; import ConnectedViewerMain from './ConnectedViewerMain.js'; import SidePanel from './../components/SidePanel.js'; import ErrorBoundaryDialog from './../components/ErrorBoundaryDialog'; -import { extensionManager } from './../App.js'; +import { extensionManager, servicesManager } from './../App.js'; import { ReconstructionIssues } from './../../../core/src/enums.js'; // Contexts @@ -70,9 +70,11 @@ class Viewer extends Component { const { activeServer } = this.props; const server = Object.assign({}, activeServer); + const external = { servicesManager }; + OHIF.measurements.MeasurementApi.setConfiguration({ dataExchange: { - retrieve: DICOMSR.retrieveMeasurements, + retrieve: server => DICOMSR.retrieveMeasurements(server, external), store: DICOMSR.storeMeasurements, }, server, diff --git a/yarn.lock b/yarn.lock index d191d545d..28153c244 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1247,7 +1247,7 @@ pirates "^4.0.0" source-map-support "^0.5.9" -"@babel/runtime@7.1.2", "@babel/runtime@7.5.5", "@babel/runtime@7.6.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6": +"@babel/runtime@7.1.2", "@babel/runtime@7.5.5", "@babel/runtime@7.6.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.16.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== @@ -5969,6 +5969,11 @@ compare-func@^1.3.1: array-ify "^1.0.0" dot-prop "^3.0.0" +complex.js@^2.0.15: + version "2.0.15" + resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.15.tgz#7add6848b4c1d12aa9262f7df925ebe7a51a7406" + integrity sha512-gDBvQU8IG139ZBQTSo2qvDFP+lANMGluM779csXOr6ny1NUtA3wkUnCFjlDNH/moAVfXtvClYt6G0zarFbtz5w== + component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -7009,6 +7014,11 @@ decamelize@^2.0.0: dependencies: xregexp "4.0.0" +decimal.js@^10.3.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" + integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -8088,6 +8098,11 @@ escape-html@^1.0.3, escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escape-latex@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1" + integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw== + escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -9212,6 +9227,11 @@ frac@~1.1.2: resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b" integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA== +fraction.js@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.1.2.tgz#13e420a92422b6cf244dff8690ed89401029fbe8" + integrity sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -11544,6 +11564,11 @@ istanbul-reports@^2.2.6: dependencies: handlebars "^4.1.2" +javascript-natural-sort@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" + integrity sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k= + javascript-stringify@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-1.6.0.tgz#142d111f3a6e3dae8f4a9afd77d45855b5a9cce3" @@ -13110,6 +13135,21 @@ match-sorter@^3.0.0: dependencies: remove-accents "0.4.2" +mathjs@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-10.1.0.tgz#87e3e5ec73d9d36bd5d72731ab6d7a10d14f5b32" + integrity sha512-TrpZAR3H9jR0Cv6cnzT+TZhE40Xs2SCaLf/qm2WcWm2tui69Gas/bC/ct5ZLZNWnWvNvJ7H2uHvuRDDl151PHA== + dependencies: + "@babel/runtime" "^7.16.5" + complex.js "^2.0.15" + decimal.js "^10.3.1" + escape-latex "^1.2.0" + fraction.js "^4.1.2" + javascript-natural-sort "^0.7.1" + seedrandom "^3.0.5" + tiny-emitter "^2.1.0" + typed-function "^2.0.0" + mathml-tag-names@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.1.tgz#6dff66c99d55ecf739ca53c492e626f1d12a33cc" @@ -18211,6 +18251,11 @@ seedrandom@2.4.3: resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-2.4.3.tgz#2438504dad33917314bff18ac4d794f16d6aaecc" integrity sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw= +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -19769,6 +19814,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tiny-invariant@^1.0.2: version "1.0.6" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" @@ -20049,6 +20099,11 @@ type@^1.0.1: resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== +typed-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-2.0.0.tgz#15ab3825845138a8b1113bd89e60cd6a435739e8" + integrity sha512-Hhy1Iwo/e4AtLZNK10ewVVcP2UEs408DS35ubP825w/YgSBK1KVLwALvvIG4yX75QJrxjCpcWkzkVRB0BwwYlA== + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"