import * as cornerstoneTools from 'cornerstone-tools' const external = cornerstoneTools.external // State const getToolState = cornerstoneTools.getToolState const toolStyle = cornerstoneTools.toolStyle const toolColors = cornerstoneTools.toolColors // Drawing const getNewContext = cornerstoneTools.import('drawing/getNewContext') const draw = cornerstoneTools.import('drawing/draw') const drawHandles = cornerstoneTools.import('drawing/drawHandles') const drawRect = cornerstoneTools.import('drawing/drawRect') const drawLinkedTextBox = cornerstoneTools.import('drawing/drawLinkedTextBox') const setShadow = cornerstoneTools.import('drawing/setShadow') // Util const calculateSUV = cornerstoneTools.import('util/calculateSUV') const getROITextBoxCoords = cornerstoneTools.import('util/getROITextBoxCoords') const numbersWithCommas = cornerstoneTools.import('util/numbersWithCommas') const throttle = cornerstoneTools.import('util/throttle') const rectangleRoiCursor = cornerstoneTools.import('tools/cursors') const getPixelSpacing = cornerstoneTools.import('util/getPixelSpacing') const getModule = cornerstoneTools.getModule /** * @public * @class RectangleRoiTool * @memberof Tools.Annotation * @classdesc Tool for drawing rectangular regions of interest, and measuring * the statistics of the enclosed pixels. * @extends Tools.Base.BaseAnnotationTool */ export default class RectangleRoiTool extends cornerstoneTools.RectangleRoiTool { constructor(props = {}) { const defaultProps = { name: 'RectangleRoi', supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { drawHandles: true, drawHandlesOnHover: false, hideHandlesIfMoving: false, renderDashed: false // showMinMax: false, // showHounsfieldUnits: true }, svgCursor: rectangleRoiCursor } super(props, defaultProps) this.throttledUpdateCachedStats = throttle(this.updateCachedStats, 110) } createNewMeasurement(eventData) { const goodEventData = eventData && eventData.currentPoints && eventData.currentPoints.image if (!goodEventData) { console.log( `required eventData not supplied to tool ${this.name}'s createNewMeasurement` ) return } return { visible: true, active: true, color: undefined, invalidated: true, handles: { start: { x: eventData.currentPoints.image.x, y: eventData.currentPoints.image.y, highlight: true, active: false }, end: { x: eventData.currentPoints.image.x, y: eventData.currentPoints.image.y, highlight: true, active: true }, initialRotation: eventData.viewport.rotation, textBox: { active: false, hasMoved: false, movesIndependently: false, drawnIndependently: true, allowedOutsideImage: true, hasBoundingBox: true } } } } pointNearTool(element, data, coords, interactionType) { const hasStartAndEndHandles = data && data.handles && data.handles.start && data.handles.end const validParameters = hasStartAndEndHandles if (!validParameters) { console.log( `invalid parameters supplied to tool ${this.name}'s pointNearTool` ) } if (!validParameters || data.visible === false) { return false } const distance = interactionType === 'mouse' ? 15 : 25 const startCanvas = external.cornerstone.pixelToCanvas( element, data.handles.start ) const endCanvas = external.cornerstone.pixelToCanvas( element, data.handles.end ) const rect = { left: Math.min(startCanvas.x, endCanvas.x), top: Math.min(startCanvas.y, endCanvas.y), width: Math.abs(startCanvas.x - endCanvas.x), height: Math.abs(startCanvas.y - endCanvas.y) } const distanceToPoint = external.cornerstoneMath.rect.distanceToPoint( rect, coords ) return distanceToPoint < distance } updateCachedStats(image, element, data) { const seriesModule = external.cornerstone.metaData.get('generalSeriesModule', image.imageId) || {} const modality = seriesModule.modality const pixelSpacing = getPixelSpacing(image) const stats = _calculateStats( image, element, data.handles, modality, pixelSpacing ) data.cachedStats = stats data.invalidated = false } renderToolData(evt) { const toolData = getToolState(evt.currentTarget, this.name) if (!toolData) { return } const eventData = evt.detail const { image, element } = eventData const lineWidth = toolStyle.getToolWidth() const lineDash = getModule('globalConfiguration').configuration.lineDash const { handleRadius, drawHandlesOnHover, hideHandlesIfMoving, renderDashed } = this.configuration const context = getNewContext(eventData.canvasContext.canvas) const { rowPixelSpacing, colPixelSpacing } = getPixelSpacing(image) // Meta const seriesModule = external.cornerstone.metaData.get('generalSeriesModule', image.imageId) || {} // Pixel Spacing const modality = seriesModule.modality const hasPixelSpacing = rowPixelSpacing && colPixelSpacing draw(context, context => { // If we have tool data for this element - iterate over each set and draw it for (let i = 0; i < toolData.data.length; i++) { const data = toolData.data[i] if (data.visible === false) { continue } // Configure const color = toolColors.getColorIfActive(data) const handleOptions = { color, handleRadius, drawHandlesIfActive: drawHandlesOnHover, hideHandlesIfMoving } setShadow(context, this.configuration) const rectOptions = { color } if (renderDashed) { rectOptions.lineDash = lineDash } // Draw drawRect( context, element, data.handles.start, data.handles.end, rectOptions, 'pixel', data.handles.initialRotation ) if (this.configuration.drawHandles) { drawHandles(context, eventData, data.handles, handleOptions) } // Update textbox stats if (data.invalidated === true) { if (data.cachedStats) { this.throttledUpdateCachedStats(image, element, data) } else { this.updateCachedStats(image, element, data) } } // Default to textbox on right side of ROI if (!data.handles.textBox.hasMoved) { const defaultCoords = getROITextBoxCoords( eventData.viewport, data.handles ) Object.assign(data.handles.textBox, defaultCoords) } const textBoxAnchorPoints = handles => _findTextBoxAnchorPoints(handles.start, handles.end) const textBoxContent = _createTextBoxContent( context, image.color, data.cachedStats, modality, hasPixelSpacing, this.configuration, data ) data.unit = _getUnit(modality, this.configuration.showHounsfieldUnits) drawLinkedTextBox( context, element, data.handles.textBox, textBoxContent, data.handles, textBoxAnchorPoints, color, lineWidth, 10, true ) } }) } } /** * TODO: This is the same method (+ GetPixels) for the other ROIs * TODO: The pixel filtering is the unique bit * * @param {*} startHandle * @param {*} endHandle * @returns {{ left: number, top: number, width: number, height: number}} */ function _getRectangleImageCoordinates(startHandle, endHandle) { return { left: Math.min(startHandle.x, endHandle.x), top: Math.min(startHandle.y, endHandle.y), width: Math.abs(startHandle.x - endHandle.x), height: Math.abs(startHandle.y - endHandle.y) } } /** * * * @param {*} image * @param {*} element * @param {*} handles * @param {*} modality * @param {*} pixelSpacing * @returns {Object} The Stats object */ function _calculateStats(image, element, handles, modality, pixelSpacing) { // Retrieve the bounds of the rectangle in image coordinates const roiCoordinates = _getRectangleImageCoordinates( handles.start, handles.end ) // Retrieve the array of pixels that the rectangle bounds cover const pixels = external.cornerstone.getPixels( element, roiCoordinates.left, roiCoordinates.top, roiCoordinates.width, roiCoordinates.height ) // Calculate the mean & standard deviation from the pixels and the rectangle details const roiMeanStdDev = _calculateRectangleStats(pixels, roiCoordinates) let meanStdDevSUV if (modality === 'PT') { meanStdDevSUV = { mean: calculateSUV(image, roiMeanStdDev.mean, true) || 0, stdDev: calculateSUV(image, roiMeanStdDev.stdDev, true) || 0 } } // Calculate the image area from the rectangle dimensions and pixel spacing const area = roiCoordinates.width * (pixelSpacing.colPixelSpacing || 1) * (roiCoordinates.height * (pixelSpacing.rowPixelSpacing || 1)) const perimeter = roiCoordinates.width * 2 * (pixelSpacing.colPixelSpacing || 1) + roiCoordinates.height * 2 * (pixelSpacing.rowPixelSpacing || 1) return { area: area || 0, perimeter, count: roiMeanStdDev.count || 0, mean: roiMeanStdDev.mean || 0, variance: roiMeanStdDev.variance || 0, stdDev: roiMeanStdDev.stdDev || 0, min: roiMeanStdDev.min || 0, max: roiMeanStdDev.max || 0, meanStdDevSUV } } /** * * * @param {*} sp * @param {*} rectangle * @returns {{ count, number, mean: number, variance: number, stdDev: number, min: number, max: number }} */ function _calculateRectangleStats(sp, rectangle) { let sum = 0 let sumSquared = 0 let count = 0 let index = 0 let min = sp ? sp[0] : null let max = sp ? sp[0] : null for (let y = rectangle.top; y < rectangle.top + rectangle.height; y++) { for (let x = rectangle.left; x < rectangle.left + rectangle.width; x++) { sum += sp[index] sumSquared += sp[index] * sp[index] min = Math.min(min, sp[index]) max = Math.max(max, sp[index]) count++ // TODO: Wouldn't this just be sp.length? index++ } } if (count === 0) { return { count, mean: 0.0, variance: 0.0, stdDev: 0.0, min: 0.0, max: 0.0 } } const mean = sum / count const variance = sumSquared / count - mean * mean return { count, mean, variance, stdDev: Math.sqrt(variance), min, max } } /** * * * @param {*} startHandle * @param {*} endHandle * @returns {Array.<{x: number, y: number}>} */ function _findTextBoxAnchorPoints(startHandle, endHandle) { const { left, top, width, height } = _getRectangleImageCoordinates( startHandle, endHandle ) return [ { // Top middle point of rectangle x: left + width / 2, y: top }, { // Left middle point of rectangle x: left, y: top + height / 2 }, { // Bottom middle point of rectangle x: left + width / 2, y: top + height }, { // Right middle point of rectangle x: left + width, y: top + height / 2 } ] } /** * * * @param {*} area * @param {*} hasPixelSpacing * @returns {string} The formatted label for showing area */ // function _formatArea(area, hasPixelSpacing) { // // This uses Char code 178 for a superscript 2 // const suffix = hasPixelSpacing // ? ` mm${String.fromCharCode(178)}` // : ` px${String.fromCharCode(178)}` // return `Area: ${numbersWithCommas(area.toFixed(2))}${suffix}` // } function _getUnit(modality, showHounsfieldUnits) { return modality === 'CT' && showHounsfieldUnits !== false ? 'HU' : '' } /** * TODO: This is identical to EllipticalROI's same fn * TODO: We may want to make this a utility for ROIs with these values? * * @param {*} context * @param {*} isColorImage * @param {*} { area, mean, stdDev, min, max, meanStdDevSUV } * @param {*} modality * @param {*} hasPixelSpacing * @param {*} [options={}] * @returns {string[]} */ function _createTextBoxContent( context, isColorImage, { area, mean, stdDev, min, max, meanStdDevSUV }, modality, hasPixelSpacing, options = {}, data ) { const showMinMax = options.showMinMax || false const textLines = [] const otherLines = [] if (!isColorImage) { const hasStandardUptakeValues = meanStdDevSUV && meanStdDevSUV.mean !== 0 const unit = _getUnit(modality, options.showHounsfieldUnits) let meanString = `Mean: ${numbersWithCommas(mean.toFixed(2))} ${unit}` const stdDevString = `Std Dev: ${numbersWithCommas( stdDev.toFixed(2) )} ${unit}` // If this image has SUV values to display, concatenate them to the text line if (hasStandardUptakeValues) { const SUVtext = ' SUV: ' const meanSuvString = `${SUVtext}${numbersWithCommas( meanStdDevSUV.mean.toFixed(2) )}` const stdDevSuvString = `${SUVtext}${numbersWithCommas( meanStdDevSUV.stdDev.toFixed(2) )}` const targetStringLength = Math.floor( context.measureText(`${stdDevString} `).width ) while (context.measureText(meanString).width < targetStringLength) { meanString += ' ' } otherLines.push(`${meanString}${meanSuvString}`) otherLines.push(`${stdDevString} ${stdDevSuvString}`) } else { otherLines.push(`${meanString}`) otherLines.push(`${stdDevString}`) } if (showMinMax) { let minString = `Min: ${min} ${unit}` const maxString = `Max: ${max} ${unit}` const targetStringLength = hasStandardUptakeValues ? Math.floor(context.measureText(`${stdDevString} `).width) : Math.floor(context.measureText(`${meanString} `).width) while (context.measureText(minString).width < targetStringLength) { minString += ' ' } otherLines.push(`${minString}${maxString}`) } } // textLines.push(_formatArea(area, hasPixelSpacing)) // otherLines.forEach(x => textLines.push(x)) if (data.hasOwnProperty('remark')) { if (data.hasOwnProperty('status') && data.status) { textLines.push(`${data.remark}(${data.status})`) } else { textLines.push(data.remark) } } return textLines }