609 lines
18 KiB
Plaintext
609 lines
18 KiB
Plaintext
import {
|
|
eventTarget,
|
|
triggerEvent,
|
|
VolumeViewport,
|
|
utilities as csUtils
|
|
} from '@cornerstonejs/core'
|
|
|
|
const { transformWorldToIndex } = csUtils
|
|
import * as cornerstoneTools from '@cornerstonejs/tools'
|
|
const {
|
|
Enums,
|
|
utilities,
|
|
annotation,
|
|
drawing
|
|
|
|
} = cornerstoneTools
|
|
|
|
// const { getWorldWidthAndHeightFromTwoPoints } = utilities.planar
|
|
// const { roundNumber } = utilities
|
|
|
|
const { getAnnotations } = annotation.state
|
|
const { isAnnotationVisible } = annotation.visibility
|
|
const { isAnnotationLocked } = annotation.locking
|
|
const drawHandlesSvg = drawing.drawHandles
|
|
const drawCircleSvg = drawing.drawCircle
|
|
const drawLinkedTextBoxSvg = drawing.drawLinkedTextBox
|
|
const { getTextBoxCoordsCanvas } = utilities.drawing
|
|
console.log(utilities)
|
|
// const getCanvasCircleRadius
|
|
// const getCanvasCircleCorners
|
|
// const { getCanvasCircleRadius, getCanvasCircleCorners } = utilities.math.circle
|
|
import {
|
|
getCalibratedLengthUnits,
|
|
getCalibratedAreaUnits,
|
|
getCalibratedScale,
|
|
getCalibratedAspect
|
|
} from './../js/getCalibratedUnits'
|
|
import { getModalityUnit } from './../js/getModalityUnit'
|
|
import { pointInShapeCallback } from './../js/pointInShapeCallback'
|
|
import { vec3 } from 'gl-matrix'
|
|
class CircleROITool extends cornerstoneTools.CircleROITool {
|
|
static toolName;
|
|
// touchDragCallback: any;
|
|
// mouseDragCallback: any;
|
|
// _throttledCalculateCachedStats: any;
|
|
// editData: {
|
|
// annotation: any;
|
|
// viewportIdsToRender: Array<string>;
|
|
// handleIndex?: number;
|
|
// movingTextBox?: boolean;
|
|
// newAnnotation?: boolean;
|
|
// hasMoved?: boolean;
|
|
// } | null;
|
|
// isDrawing: boolean;
|
|
// isHandleOutsideImage = false;
|
|
|
|
constructor(
|
|
toolProps,
|
|
defaultToolProps
|
|
) {
|
|
super(toolProps, defaultToolProps)
|
|
|
|
this._throttledCalculateCachedStats = utilities.throttle(
|
|
this._calculateCachedStats,
|
|
100,
|
|
{ trailing: true }
|
|
)
|
|
|
|
// this._throttledCalculateCachedStats = utilities.throttle(
|
|
// this._calculateCachedStats,
|
|
// 100,
|
|
// { trailing: true }
|
|
// )
|
|
// this._getTextLines = this.getTextLines
|
|
}
|
|
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
|
let renderStatus = false
|
|
const { viewport } = enabledElement
|
|
const { element } = viewport
|
|
// console.log(element)
|
|
let annotations = getAnnotations(this.getToolName(), element)
|
|
|
|
if (!annotations || (annotations && annotations.length === 0)) {
|
|
return renderStatus
|
|
}
|
|
|
|
annotations = this.filterInteractableAnnotationsForElement(
|
|
element,
|
|
annotations
|
|
)
|
|
|
|
if (!annotations || (annotations && annotations.length === 0)) {
|
|
return renderStatus
|
|
}
|
|
|
|
const targetId = this.getTargetId(viewport)
|
|
|
|
const renderingEngine = viewport.getRenderingEngine()
|
|
|
|
const styleSpecifier = {
|
|
toolGroupId: this.toolGroupId,
|
|
toolName: this.getToolName(),
|
|
viewportId: enabledElement.viewport.id
|
|
}
|
|
|
|
for (let i = 0; i < annotations.length; i++) {
|
|
const annotation = annotations[i]
|
|
const { annotationUID, data } = annotation
|
|
const { handles } = data
|
|
const { points, activeHandleIndex } = handles
|
|
|
|
styleSpecifier.annotationUID = annotationUID
|
|
|
|
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation)
|
|
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation)
|
|
const color = this.getStyle('color', styleSpecifier, annotation)
|
|
|
|
const canvasCoordinates = points.map((p) =>
|
|
viewport.worldToCanvas(p)
|
|
)
|
|
const center = canvasCoordinates[0]
|
|
const radius = getCanvasCircleRadius(canvasCoordinates)
|
|
const canvasCorners = getCanvasCircleCorners(canvasCoordinates)
|
|
|
|
const { centerPointRadius } = this.configuration
|
|
|
|
const modalityUnitOptions = {
|
|
isPreScaled: utilities.viewport.isViewportPreScaled(viewport, targetId),
|
|
|
|
isSuvScaled: this.isSuvScaled(
|
|
viewport,
|
|
targetId,
|
|
annotation.metadata.referencedImageId
|
|
)
|
|
}
|
|
|
|
// If cachedStats does not exist, or the unit is missing (as part of import/hydration etc.),
|
|
// force to recalculate the stats from the points
|
|
if (
|
|
!data.cachedStats[targetId] ||
|
|
data.cachedStats[targetId].areaUnit === undefined
|
|
) {
|
|
data.cachedStats[targetId] = {
|
|
Modality: null,
|
|
area: null,
|
|
max: null,
|
|
mean: null,
|
|
stdDev: null,
|
|
areaUnit: null,
|
|
radius: null,
|
|
radiusUnit: null,
|
|
perimeter: null
|
|
}
|
|
console.log(111111)
|
|
this._calculateCachedStats(
|
|
annotation,
|
|
viewport,
|
|
renderingEngine,
|
|
enabledElement,
|
|
modalityUnitOptions
|
|
)
|
|
} else if (annotation.invalidated) {
|
|
console.log(22222)
|
|
|
|
this._throttledCalculateCachedStats(
|
|
annotation,
|
|
viewport,
|
|
renderingEngine,
|
|
enabledElement,
|
|
modalityUnitOptions
|
|
)
|
|
|
|
// If the invalidated data is as a result of volumeViewport manipulation
|
|
// of the tools, we need to invalidate the related viewports data, so that
|
|
// when scrolling to the related slice in which the tool were manipulated
|
|
// we re-render the correct tool position. This is due to stackViewport
|
|
// which doesn't have the full volume at each time, and we are only working
|
|
// on one slice at a time.
|
|
if (viewport instanceof VolumeViewport) {
|
|
const { referencedImageId } = annotation.metadata
|
|
|
|
// invalidate all the relevant stackViewports if they are not
|
|
// at the referencedImageId
|
|
for (const targetId in data.cachedStats) {
|
|
if (targetId.startsWith('imageId')) {
|
|
const viewports = renderingEngine.getStackViewports()
|
|
|
|
const invalidatedStack = viewports.find((vp) => {
|
|
// The stack viewport that contains the imageId but is not
|
|
// showing it currently
|
|
const referencedImageURI =
|
|
csUtils.imageIdToURI(referencedImageId)
|
|
const hasImageURI = vp.hasImageURI(referencedImageURI)
|
|
const currentImageURI = csUtils.imageIdToURI(
|
|
vp.getCurrentImageId()
|
|
)
|
|
return hasImageURI && currentImageURI !== referencedImageURI
|
|
})
|
|
|
|
if (invalidatedStack) {
|
|
delete data.cachedStats[targetId]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If rendering engine has been destroyed while rendering
|
|
if (!viewport.getRenderingEngine()) {
|
|
console.warn('Rendering Engine has been destroyed')
|
|
return renderStatus
|
|
}
|
|
|
|
let activeHandleCanvasCoords
|
|
|
|
if (!isAnnotationVisible(annotationUID)) {
|
|
continue
|
|
}
|
|
|
|
if (
|
|
!isAnnotationLocked(annotation) &&
|
|
!this.editData &&
|
|
activeHandleIndex !== null
|
|
) {
|
|
// Not locked or creating and hovering over handle, so render handle.
|
|
activeHandleCanvasCoords = [canvasCoordinates[activeHandleIndex]]
|
|
}
|
|
|
|
if (activeHandleCanvasCoords) {
|
|
const handleGroupUID = '0'
|
|
drawHandlesSvg(
|
|
svgDrawingHelper,
|
|
annotationUID,
|
|
handleGroupUID,
|
|
activeHandleCanvasCoords,
|
|
{
|
|
color
|
|
}
|
|
)
|
|
}
|
|
|
|
const dataId = `${annotationUID}-circle`
|
|
const circleUID = '0'
|
|
drawCircleSvg(
|
|
svgDrawingHelper,
|
|
annotationUID,
|
|
circleUID,
|
|
center,
|
|
radius,
|
|
{
|
|
color,
|
|
lineDash,
|
|
lineWidth
|
|
},
|
|
dataId
|
|
)
|
|
|
|
// draw center point, if "centerPointRadius" configuration is valid.
|
|
if (centerPointRadius > 0) {
|
|
if (radius > 3 * centerPointRadius) {
|
|
drawCircleSvg(
|
|
svgDrawingHelper,
|
|
annotationUID,
|
|
`${circleUID}-center`,
|
|
center,
|
|
centerPointRadius,
|
|
{
|
|
color,
|
|
lineDash,
|
|
lineWidth
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
renderStatus = true
|
|
|
|
const options = this.getLinkedTextBoxStyle(styleSpecifier, annotation)
|
|
if (!options.visibility) {
|
|
data.handles.textBox = {
|
|
hasMoved: false,
|
|
worldPosition: [0, 0, 0],
|
|
worldBoundingBox: {
|
|
topLeft: [0, 0, 0],
|
|
topRight: [0, 0, 0],
|
|
bottomLeft: [0, 0, 0],
|
|
bottomRight: [0, 0, 0]
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
const textLines = this.configuration.getTextLines(data, targetId)
|
|
if (!textLines || textLines.length === 0) {
|
|
continue
|
|
}
|
|
|
|
// Poor man's cached?
|
|
let canvasTextBoxCoords
|
|
|
|
if (!data.handles.textBox.hasMoved) {
|
|
canvasTextBoxCoords = getTextBoxCoordsCanvas(canvasCorners)
|
|
|
|
data.handles.textBox.worldPosition =
|
|
viewport.canvasToWorld(canvasTextBoxCoords)
|
|
}
|
|
|
|
const textBoxPosition = viewport.worldToCanvas(
|
|
data.handles.textBox.worldPosition
|
|
)
|
|
|
|
const textBoxUID = '1'
|
|
const boundingBox = drawLinkedTextBoxSvg(
|
|
svgDrawingHelper,
|
|
annotationUID,
|
|
textBoxUID,
|
|
textLines,
|
|
textBoxPosition,
|
|
canvasCoordinates,
|
|
{},
|
|
options
|
|
)
|
|
|
|
const { x: left, y: top, width, height } = boundingBox
|
|
|
|
data.handles.textBox.worldBoundingBox = {
|
|
topLeft: viewport.canvasToWorld([left, top]),
|
|
topRight: viewport.canvasToWorld([left + width, top]),
|
|
bottomLeft: viewport.canvasToWorld([left, top + height]),
|
|
bottomRight: viewport.canvasToWorld([left + width, top + height])
|
|
}
|
|
}
|
|
|
|
return renderStatus
|
|
};
|
|
_calculateCachedStats1 = (
|
|
annotation,
|
|
viewport,
|
|
renderingEngine,
|
|
enabledElement,
|
|
modalityUnitOptions
|
|
) => {
|
|
const data = annotation.data
|
|
const { viewportId, renderingEngineId } = enabledElement
|
|
|
|
const { points } = data.handles
|
|
|
|
const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p))
|
|
const { viewPlaneNormal, viewUp } = viewport.getCamera()
|
|
|
|
const [topLeftCanvas, bottomRightCanvas] = (
|
|
getCanvasCircleCorners(canvasCoordinates)
|
|
)
|
|
|
|
const topLeftWorld = viewport.canvasToWorld(topLeftCanvas)
|
|
const bottomRightWorld = viewport.canvasToWorld(bottomRightCanvas)
|
|
const { cachedStats } = data
|
|
|
|
const targetIds = Object.keys(cachedStats)
|
|
const worldPos1 = topLeftWorld
|
|
const worldPos2 = bottomRightWorld
|
|
for (let i = 0; i < targetIds.length; i++) {
|
|
const targetId = targetIds[i]
|
|
|
|
const image = this.getTargetIdImage(targetId, renderingEngine)
|
|
|
|
// If image does not exists for the targetId, skip. This can be due
|
|
// to various reasons such as if the target was a volumeViewport, and
|
|
// the volumeViewport has been decached in the meantime.
|
|
if (!image) {
|
|
continue
|
|
}
|
|
|
|
const { dimensions, imageData, metadata } = image
|
|
|
|
const worldPos1Index = transformWorldToIndex(imageData, worldPos1)
|
|
|
|
worldPos1Index[0] = Math.floor(worldPos1Index[0])
|
|
worldPos1Index[1] = Math.floor(worldPos1Index[1])
|
|
worldPos1Index[2] = Math.floor(worldPos1Index[2])
|
|
|
|
const worldPos2Index = transformWorldToIndex(imageData, worldPos2)
|
|
|
|
worldPos2Index[0] = Math.floor(worldPos2Index[0])
|
|
worldPos2Index[1] = Math.floor(worldPos2Index[1])
|
|
worldPos2Index[2] = Math.floor(worldPos2Index[2])
|
|
|
|
// Check if one of the indexes are inside the volume, this then gives us
|
|
// Some area to do stats over.
|
|
|
|
if (this._isInsideVolume(worldPos1Index, worldPos2Index, dimensions)) {
|
|
const iMin = Math.min(worldPos1Index[0], worldPos2Index[0])
|
|
const iMax = Math.max(worldPos1Index[0], worldPos2Index[0])
|
|
|
|
const jMin = Math.min(worldPos1Index[1], worldPos2Index[1])
|
|
const jMax = Math.max(worldPos1Index[1], worldPos2Index[1])
|
|
|
|
const kMin = Math.min(worldPos1Index[2], worldPos2Index[2])
|
|
const kMax = Math.max(worldPos1Index[2], worldPos2Index[2])
|
|
|
|
const boundsIJK = [
|
|
[iMin, iMax],
|
|
[jMin, jMax],
|
|
[kMin, kMax]
|
|
]
|
|
|
|
const center = [
|
|
(topLeftWorld[0] + bottomRightWorld[0]) / 2,
|
|
(topLeftWorld[1] + bottomRightWorld[1]) / 2,
|
|
(topLeftWorld[2] + bottomRightWorld[2]) / 2
|
|
]
|
|
|
|
const ellipseObj = {
|
|
center,
|
|
xRadius: Math.abs(topLeftWorld[0] - bottomRightWorld[0]) / 2,
|
|
yRadius: Math.abs(topLeftWorld[1] - bottomRightWorld[1]) / 2,
|
|
zRadius: Math.abs(topLeftWorld[2] - bottomRightWorld[2]) / 2
|
|
}
|
|
const { worldWidth, worldHeight } = getWorldWidthAndHeightFromTwoPoints(
|
|
viewPlaneNormal,
|
|
viewUp,
|
|
worldPos1,
|
|
worldPos2
|
|
)
|
|
const isEmptyArea = worldWidth === 0 && worldHeight === 0
|
|
const scale = getCalibratedScale(image)
|
|
const aspect = getCalibratedAspect(image)
|
|
const area = Math.abs(
|
|
Math.PI *
|
|
(worldWidth / scale / 2) *
|
|
(worldHeight / aspect / scale / 2)
|
|
)
|
|
|
|
const modalityUnit = getModalityUnit(
|
|
metadata.Modality,
|
|
annotation.metadata.referencedImageId,
|
|
modalityUnitOptions
|
|
)
|
|
const pointsInShape = pointInShapeCallback(
|
|
imageData,
|
|
(pointLPS, pointIJK) => pointInEllipse(ellipseObj, pointLPS),
|
|
this.configuration.statsCalculator.statsCallback,
|
|
boundsIJK
|
|
)
|
|
|
|
const stats = this.configuration.statsCalculator.getStatistics()
|
|
|
|
cachedStats[targetId] = {
|
|
Modality: metadata.Modality,
|
|
area,
|
|
mean: stats[1] && stats[1].value ? stats[1].value : null,
|
|
max: stats[0] && stats[0].value ? stats[0].value : null,
|
|
stdDev: stats[2] && stats[2].value ? stats[2].value : null,
|
|
statsArray: stats,
|
|
pointsInShape: pointsInShape,
|
|
isEmptyArea,
|
|
areaUnit: getCalibratedAreaUnits(null, image),
|
|
radius: worldWidth / 2 / scale,
|
|
radiusUnit: getCalibratedLengthUnits(null, image),
|
|
perimeter: (2 * Math.PI * (worldWidth / 2)) / scale,
|
|
modalityUnit
|
|
}
|
|
} else {
|
|
this.isHandleOutsideImage = true
|
|
|
|
cachedStats[targetId] = {
|
|
Modality: metadata.Modality
|
|
}
|
|
}
|
|
}
|
|
|
|
annotation.invalidated = false
|
|
|
|
// Dispatching annotation modified
|
|
const eventType = Enums.Events.ANNOTATION_MODIFIED
|
|
|
|
const eventDetail = {
|
|
annotation,
|
|
viewportId,
|
|
renderingEngineId
|
|
}
|
|
triggerEvent(eventTarget, eventType, eventDetail)
|
|
|
|
return cachedStats
|
|
}
|
|
}
|
|
function getCanvasCircleCorners(
|
|
circleCanvasPoints
|
|
) {
|
|
const [center, end] = circleCanvasPoints
|
|
const radius = utilities.math.point.distanceToPoint(center, end)
|
|
|
|
const topLeft = [center[0] - radius, center[1] - radius]
|
|
const bottomRight = [center[0] + radius, center[1] + radius]
|
|
|
|
return [topLeft, bottomRight]
|
|
}
|
|
function getCanvasCircleRadius(
|
|
circleCanvasPoints
|
|
) {
|
|
const [center, end] = circleCanvasPoints
|
|
return utilities.math.point.distanceToPoint(center, end)
|
|
}
|
|
function getWorldWidthAndHeightFromTwoPoints(
|
|
viewPlaneNormal,
|
|
viewUp,
|
|
worldPos1,
|
|
worldPos2
|
|
) {
|
|
const viewRight = vec3.create()
|
|
|
|
vec3.cross(viewRight, viewUp, viewPlaneNormal)
|
|
|
|
const pos1 = vec3.fromValues(...worldPos1)
|
|
const pos2 = vec3.fromValues(...worldPos2)
|
|
|
|
const diagonal = vec3.create()
|
|
vec3.subtract(diagonal, pos1, pos2)
|
|
|
|
const diagonalLength = vec3.length(diagonal)
|
|
|
|
// When the two points are very close to each other return width as 0
|
|
// to avoid NaN the cosTheta formula calculation
|
|
if (diagonalLength < 0.0001) {
|
|
return { worldWidth: 0, worldHeight: 0 }
|
|
}
|
|
|
|
const cosTheta =
|
|
vec3.dot(diagonal, viewRight) / (diagonalLength * vec3.length(viewRight))
|
|
|
|
const sinTheta = Math.sqrt(1 - cosTheta * cosTheta)
|
|
|
|
const worldWidth = sinTheta * diagonalLength
|
|
const worldHeight = cosTheta * diagonalLength
|
|
|
|
return { worldWidth, worldHeight }
|
|
}
|
|
function pointInEllipse(
|
|
ellipse,
|
|
pointLPS
|
|
) {
|
|
const { center: circleCenterWorld, xRadius, yRadius, zRadius } = ellipse
|
|
const [x, y, z] = pointLPS
|
|
const [x0, y0, z0] = circleCenterWorld
|
|
|
|
let inside = 0
|
|
if (xRadius !== 0) {
|
|
inside += ((x - x0) * (x - x0)) / (xRadius * xRadius)
|
|
}
|
|
|
|
if (yRadius !== 0) {
|
|
inside += ((y - y0) * (y - y0)) / (yRadius * yRadius)
|
|
}
|
|
|
|
if (zRadius !== 0) {
|
|
inside += ((z - z0) * (z - z0)) / (zRadius * zRadius)
|
|
}
|
|
|
|
return inside <= 1
|
|
}
|
|
// function defaultGetTextLines(data, targetId) {
|
|
// const cachedVolumeStats = data.cachedStats[targetId]
|
|
// const {
|
|
// radius,
|
|
// radiusUnit,
|
|
// area,
|
|
// mean,
|
|
// stdDev,
|
|
// max,
|
|
// isEmptyArea,
|
|
// areaUnit,
|
|
// modalityUnit
|
|
// } = cachedVolumeStats
|
|
|
|
// const textLines = []
|
|
|
|
// if (radius) {
|
|
// const radiusLine = isEmptyArea
|
|
// ? `Radius: Oblique not supported`
|
|
// : `Radius: ${roundNumber(radius)} ${radiusUnit}`
|
|
// textLines.push(radiusLine)
|
|
// }
|
|
|
|
// if (area) {
|
|
// const areaLine = isEmptyArea
|
|
// ? `Area: Oblique not supported`
|
|
// : `Area: ${roundNumber(area)} ${areaUnit}`
|
|
// textLines.push(areaLine)
|
|
// }
|
|
|
|
// if (mean) {
|
|
// textLines.push(`Mean: ${roundNumber(mean)} ${modalityUnit}`)
|
|
// }
|
|
|
|
// if (max) {
|
|
// textLines.push(`Max: ${roundNumber(max)} ${modalityUnit}`)
|
|
// }
|
|
|
|
// if (stdDev) {
|
|
// textLines.push(`Std Dev: ${roundNumber(stdDev)} ${modalityUnit}`)
|
|
// }
|
|
|
|
// return textLines
|
|
// }
|
|
|
|
CircleROITool.toolName = 'CircleROI'
|
|
export default CircleROITool
|
|
|