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 <igoroctaviano@gmail.com>
This commit is contained in:
Davide Punzo 2022-02-03 09:33:02 +01:00 committed by GitHub
parent 1953118c8a
commit 5beb12a7fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2286 additions and 231 deletions

View File

@ -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) => {

View File

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

View File

@ -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({

View File

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

View File

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

View File

@ -0,0 +1,8 @@
export default {
POINT: 'POINT',
MULTIPOINT: 'MULTIPOINT',
POLYLINE: 'POLYLINE',
CIRCLE: 'CIRCLE',
ELLIPSE: 'ELLIPSE',
POLYGON: 'POLYGON',
};

View File

@ -0,0 +1,5 @@
const TOOL_NAMES = {
DICOM_SR_DISPLAY_TOOL: 'DICOMSRDisplayTool',
};
export default TOOL_NAMES;

View File

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

View File

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

View File

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

View File

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

View File

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

23
lerna-debug.log Normal file
View File

@ -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.<anonymous> (/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)

View File

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

View File

@ -0,0 +1,8 @@
export default {
POINT: 'POINT',
MULTIPOINT: 'MULTIPOINT',
POLYLINE: 'POLYLINE',
CIRCLE: 'CIRCLE',
ELLIPSE: 'ELLIPSE',
POLYGON: 'POLYGON',
};

View File

@ -0,0 +1,5 @@
const TOOL_NAMES = {
DICOM_SR_DISPLAY_TOOL: 'DICOMSRDisplayTool',
};
export default TOOL_NAMES;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
const getSequenceAsArray = sequence =>
Array.isArray(sequence) ? sequence : [sequence];
export default getSequenceAsArray;

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
};
/**

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import pubSubServiceInterface from './pubSubServiceInterface';
export { pubSubServiceInterface };

View File

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

View File

@ -139,7 +139,6 @@ class MeasurementTable extends Component {
const itemIndex = measurement.itemNumber || index + 1;
const itemClass =
selectedKey === key && !this.props.readOnly ? 'selected' : '';
return (
<MeasurementTableItem
key={key}

View File

@ -22,11 +22,15 @@ class MeasurementTableItem extends Component {
};
render() {
const { warningTitle = '', hasWarnings } = this.props.measurementData;
const {
warningTitle = '',
hasWarnings,
isReadOnly,
} = this.props.measurementData;
return (
<React.Fragment>
{hasWarnings ? (
{hasWarnings && !isReadOnly ? (
<OverlayTrigger
key={this.props.itemIndex}
placement="left"
@ -44,8 +48,8 @@ class MeasurementTableItem extends Component {
<div>{this.getTableListItem()}</div>
</OverlayTrigger>
) : (
<React.Fragment>{this.getTableListItem()}</React.Fragment>
)}
<React.Fragment>{this.getTableListItem()}</React.Fragment>
)}
</React.Fragment>
);
}
@ -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 {
})}
</div>
<div className="displayTexts">{this.getDataDisplayText()}</div>
<div className="rowActions">{actionButtons}</div>
{!this.props.measurementData.isReadOnly && (
<div className="rowActions">{actionButtons}</div>
)}
</div>
</TableListItem>
);

View File

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

View File

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

View File

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

View File

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