549 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Plaintext
		
	
	
			
		
		
	
	
			549 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Plaintext
		
	
	
| 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
 | |
| }
 |