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