基于cornerstone3D的dicom影像浏览器 第三十章 心胸比例测量工具CTRTool

文章目录

  • 前言
  • 一、实现过程
    • [1. 学习CobbAngleTool源码](#1. 学习CobbAngleTool源码)
    • [2. 新建CTRTool.js文件](#2. 新建CTRTool.js文件)
    • [3. 重写constructor函数](#3. 重写constructor函数)
    • [4. 重写defaultGetTextLines函数](#4. 重写defaultGetTextLines函数)
    • [5. 增加_calculateLength函数](#5. 增加_calculateLength函数)
    • [6. 重写_calculateCachedStats函数](#6. 重写_calculateCachedStats函数)
    • [7. 重写renderAnnotation函数](#7. 重写renderAnnotation函数)
  • 二、使用步骤
    • 1.引入库
    • [2. 添加到cornerstoneTools](#2. 添加到cornerstoneTools)
    • [3. 添加到toolGroup](#3. 添加到toolGroup)
  • 总结

前言

在cornerstone3D中找了找,没有找到测量心胸比例的工具,观察CobbAngleTool,已经有两条直线,但是显示的文字是两直线的夹角,可以从CobbAngleTool派生,只需重写renderAnnotation函数,显示每条直线的长度及两条直线的比值即可。

本章实现心胸比例测量工具CTRTool,效果如下:


一、实现过程

1. 学习CobbAngleTool源码

  1. 源码位置:packages\tools\src\tools\annotation\CobbAngleTool.ts
  2. 确定两个需要重写的函数constructor,renderAnnotation
  3. 从constructor,renderAnnotation两个函数中找出所有需要重写的函数以及需要导入的库。
    尤其是导入库,因为源码中都是从相对路径导入,查找比较费劲,我已整理如下:

1)需要重写或新增加的函数

因为CobbAngleTool中不需要计算直线长度,所以要新增一个函数_calculateLength来计算直线长度

javascript 复制代码
constructor
renderAnnotation
defaultGetTextLines
_throttledCalculateCachedStats
_calculateCachedStats
_calculateLength

2)导入库

其中还有一个函数midPoint2没找到导入方法,直接把函数拷贝过来。

javascript 复制代码
import { vec3 } from "gl-matrix";
import * as cornerstoneTools from "@cornerstonejs/tools";
import { utilities as csUtils } from "@cornerstonejs/core";

const { Enums: csToolsEnums, CobbAngleTool, annotation, drawing, utilities } = cornerstoneTools;

const { transformWorldToIndex } = csUtils;
const { ChangeTypes } = csToolsEnums;
const { getAnnotations, triggerAnnotationModified } = annotation.state;
const { isAnnotationLocked } = annotation.locking;
const { isAnnotationVisible } = annotation.visibility;
const {
	drawHandles: drawHandlesSvg,
	drawTextBox: drawTextBoxSvg,
	drawLine: drawLineSvg,
	drawLinkedTextBox: drawLinkedTextBoxSvg
} = drawing;

const { getCalibratedLengthUnitsAndScale, throttle } = utilities;
const { getTextBoxCoordsCanvas } = utilities.drawing;

const midPoint2 = (...args) => {
	const ret = args[0].length === 2 ? [0, 0] : [0, 0, 0];
	const len = args.length;
	for (const arg of args) {
		ret[0] += arg[0] / len;
		ret[1] += arg[1] / len;
		if (ret.length === 3) {
			ret[2] += arg[2] / len;
		}
	}
	return ret;
};

2. 新建CTRTool.js文件

从CobbAngleTool派生CTRTool类,toolName取为"CardiothoracicRatio"

javascript 复制代码
class CTRTool extends CobbAngleTool {
	static toolName = "CardiothoracicRatio";
}

3. 重写constructor函数

修改三处:

  1. 设置配置项中的getTextLines为重写的defaultGetTextLines函数,就可以获取我们想要显示的两条直线长度之比。
  2. 增加配置项showLinesText,用来控制是否显示每条直线的长度。
  3. 为主计算函数_calculateCachedStats生成节流函数
  4. 代码如下,注意注释中标有如:"修改1" 的地方
javascript 复制代码
class CTRTool extends CobbAngleTool {
	static toolName = "CardiothoracicRatio";
	constructor(
		toolProps = {},
		defaultToolProps = {
			supportedInteractionTypes: ["Mouse", "Touch"],
			configuration: {
				shadow: true,
				preventHandleOutsideImage: false,
				getTextLines: defaultGetTextLines, // 修改1
				showLinesText: true                // 修改2
			}
		}
	) {
		super(toolProps, defaultToolProps);
		// 修改3
		this._throttledCalculateCachedStats = throttle(this._calculateCachedStats, 25, {
			trailing: true
		});
	}
}

4. 重写defaultGetTextLines函数

单独函数,不属于CTRTool类

从cachedStates中找到自定义的ratio生成要显示的文字返回

javascript 复制代码
function defaultGetTextLines(data, targetId) {
	const cachedVolumeStats = data.cachedStats[targetId];
	const { ratio } = cachedVolumeStats;
	if (ratio === undefined) {
		return;
	}
	const textLines = [`${ratio.toFixed(2)}`];
	return textLines;
}

5. 增加_calculateLength函数

用来计算直线长度。

javascript 复制代码
_calculateLength(pos1, pos2) {
	const dx = pos1[0] - pos2[0];
	const dy = pos1[1] - pos2[1];
	const dz = pos1[2] - pos2[2];

	return Math.sqrt(dx * dx + dy * dy + dz * dz);
}

6. 重写_calculateCachedStats函数

从源码拷贝原函数,修改处做了注释。
重点:

CobbAngleTool的cachedStats结构:

javascript 复制代码
{
	angle: null,
	arc1Angle: null,
	arc2Angle: null,
	points: {
		world: {
			arc1Start: null,
			arc1End: null,
			arc2Start: null,
			arc2End: null,
		},
		canvas: {
			arc1Start: null,
			arc1End: null,
			arc2Start: null,
			arc2End: null,
		}
	}
};

CTRTool的cachedStats结构:

其中如arc1Start,arc1End等属性名可以改为如line1Start,line1End。本文就不改了。

javascript 复制代码
{
	length1: null,
	length2: null,
	unit: null,
	ratio: null,
	points: {
		world: {
			arc1Start: null,
			arc1End: null,
			arc2Start: null,
			arc2End: null
		},
		canvas: {
			arc1Start: null,
			arc1End: null,
			arc2Start: null,
			arc2End: null
		}
	}
};

完成后代码:

javascript 复制代码
_calculateCachedStats(annotation, renderingEngine, enabledElement) {
		const data = annotation.data;

		// Until we have all four anchors bail out
		if (data.handles.points.length !== 4) {
			return;
		}

		const seg1 = [null, null];
		const seg2 = [null, null];
		let minDist = Number.MAX_VALUE;

		// Order the endpoints of each line segment such that seg1[1] and seg2[0]
		// are the closest (Euclidean distance-wise) to each other. Thus
		// the angle formed between the vectors seg1[1]->seg1[0] and seg2[0]->seg[1]
		// is calculated.
		// The assumption here is that the Cobb angle line segments are drawn
		// such that the segments intersect nearest the segment endpoints
		// that are closest AND those closest endpoints are the tails of the
		// vectors used to calculate the angle between the vectors/line segments.
		for (let i = 0; i < 2; i += 1) {
			for (let j = 2; j < 4; j += 1) {
				const dist = vec3.distance(data.handles.points[i], data.handles.points[j]);
				if (dist < minDist) {
					minDist = dist;
					seg1[1] = data.handles.points[i];
					seg1[0] = data.handles.points[(i + 1) % 2];
					seg2[0] = data.handles.points[j];
					seg2[1] = data.handles.points[2 + ((j - 1) % 2)];
				}
			}
		}
		const { viewport } = enabledElement;
		const { element } = viewport;

		const canvasPoints = data.handles.points.map(p => viewport.worldToCanvas(p));

		const firstLine = [canvasPoints[0], canvasPoints[1]];
		const secondLine = [canvasPoints[2], canvasPoints[3]];

		const mid1 = midPoint2(firstLine[0], firstLine[1]);
		const mid2 = midPoint2(secondLine[0], secondLine[1]);

		const { arc1Start, arc1End, arc2End, arc2Start } =
			this.getArcsStartEndPoints({
				firstLine,
				secondLine,
				mid1,
				mid2
			});

		// 新增,两条直线世界坐标,用来计算长度
		const wdArc1Start = data.handles.points[0];
		const wdArc1End = data.handles.points[1];
		const wdArc2Start = data.handles.points[2];
		const wdArc2End = data.handles.points[3];

		const { cachedStats } = data;
		const targetIds = Object.keys(cachedStats);

		for (let i = 0; i < targetIds.length; i++) {
			const targetId = targetIds[i];

			// 新增,计算两条直线长度,获取长度单位,计算两直线比例
			const image = this.getTargetImageData(targetId);
			if (!image) {
				continue;
			}

			const { imageData } = image;
			
			let index1 = transformWorldToIndex(imageData, wdArc1Start);
			let index2 = transformWorldToIndex(imageData, wdArc1End);
			let handles = [index1, index2];
			const len1 = getCalibratedLengthUnitsAndScale(image, handles);
			const length1 = this._calculateLength(wdArc1Start, wdArc1End) / len1.scale;

			index1 = transformWorldToIndex(imageData, wdArc2Start);
			index2 = transformWorldToIndex(imageData, wdArc2End);
			handles = [index1, index2];
			const { scale, unit } = getCalibratedLengthUnitsAndScale(image, handles);
			const length2 = this._calculateLength(wdArc2Start, wdArc2End) / scale;
			
			// 计算两直线比例
			const ratio = length1 / length2;
			/

			cachedStats[targetId] = {
				length1,
				length2,
				unit,
				ratio,
				points: {
					canvas: {
						arc1Start,
						arc1End,
						arc2End,
						arc2Start
					},
					world: {
						arc1Start: viewport.canvasToWorld(arc1Start),
						arc1End: viewport.canvasToWorld(arc1End),
						arc2End: viewport.canvasToWorld(arc2End),
						arc2Start: viewport.canvasToWorld(arc2Start)
					}
				}
			};
		}

		const invalidated = annotation.invalidated;
		annotation.invalidated = false;

		// Dispatching annotation modified only if it was invalidated
		if (invalidated) {
			triggerAnnotationModified(annotation, element, ChangeTypes.StatsUpdated);
		}

		return cachedStats;
	}

7. 重写renderAnnotation函数

修改处标有注释 "修改..."

javascript 复制代码
renderAnnotation = (enabledElement, svgDrawingHelper) => {
	let renderStatus = false;

	const { viewport } = enabledElement;
	const { element } = viewport;

	let annotations = getAnnotations(this.getToolName(), element);

	// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
	if (!annotations?.length) {
		return renderStatus;
	}

	annotations = this.filterInteractableAnnotationsForElement(element, annotations);

	if (!annotations?.length) {
		return renderStatus;
	}

	const targetId = this.getTargetId(viewport);
	const renderingEngine = viewport.getRenderingEngine();

	const styleSpecifier = {
		toolGroupId: this.toolGroupId,
		toolName: this.getToolName(),
		viewportId: enabledElement.viewport.id
	};

	// Draw SVG
	for (let i = 0; i < annotations.length; i++) {
		const annotation = annotations[i];
		const { annotationUID, data } = annotation;
		const { points, activeHandleIndex } = data.handles;

		styleSpecifier.annotationUID = annotationUID;

		const { color, lineWidth, lineDash } = this.getAnnotationStyle({
			annotation,
			styleSpecifier
		});

		const canvasCoordinates = points.map(p => viewport.worldToCanvas(p));

		// WE HAVE TO CACHE STATS BEFORE FETCHING TEXT
		if (!data.cachedStats[targetId] || data.cachedStats[targetId].ratio == null) {
			data.cachedStats[targetId] = {
				length1: null,
				length2: null,
				unit: null,
				ratio: null,
				points: {
					world: {
						arc1Start: null,
						arc1End: null,
						arc2Start: null,
						arc2End: null
					},
					canvas: {
						arc1Start: null,
						arc1End: null,
						arc2Start: null,
						arc2End: null
					}
				}
			};

			this._calculateCachedStats(annotation, renderingEngine, enabledElement);
		} else if (annotation.invalidated) {
			this._throttledCalculateCachedStats(annotation, renderingEngine, enabledElement);
		}

		let activeHandleCanvasCoords;

		if (
			!isAnnotationLocked(annotationUID) &&
			!this.editData &&
			activeHandleIndex !== null
		) {
			// Not locked or creating and hovering over handle, so render handle.
			activeHandleCanvasCoords = [canvasCoordinates[activeHandleIndex]];
		}

		// If rendering engine has been destroyed while rendering
		if (!viewport.getRenderingEngine()) {
			console.warn("Rendering Engine has been destroyed");
			return renderStatus;
		}

		if (!isAnnotationVisible(annotationUID)) {
			continue;
		}

		if (activeHandleCanvasCoords) {
			const handleGroupUID = "0";

			drawHandlesSvg(svgDrawingHelper, annotationUID, handleGroupUID, canvasCoordinates, {
				color,
				lineDash,
				lineWidth
			});
		}

		const firstLine = [canvasCoordinates[0], canvasCoordinates[1]];
		const secondLine = [canvasCoordinates[2], canvasCoordinates[3]];

		let lineUID = "line1";
		drawLineSvg(svgDrawingHelper, annotationUID, lineUID, firstLine[0], firstLine[1], {
			color,
			width: lineWidth,
			lineDash
		});

		renderStatus = true;

		// Don't add the stats until annotation has 4 anchor points
		if (canvasCoordinates.length < 4) {
			return renderStatus;
		}

		lineUID = "line2";

		drawLineSvg(svgDrawingHelper, annotationUID, lineUID, secondLine[0], secondLine[1], {
			color,
			width: lineWidth,
			lineDash
		});

		lineUID = "linkLine";
		const mid1 = midPoint2(firstLine[0], firstLine[1]);
		const mid2 = midPoint2(secondLine[0], secondLine[1]);
		drawLineSvg(svgDrawingHelper, annotationUID, lineUID, mid1, mid2, {
			color,
			lineWidth: "1",
			lineDash: "1,4"
		});

		// Calculating the arcs
		const { arc1Start, arc1End, arc2End, arc2Start } =
			data.cachedStats[targetId].points.canvas;
		const { length1, length2, unit } = data.cachedStats[targetId];

		if (!data.cachedStats[targetId]?.ratio) {
			continue;
		}

		const options = this.getLinkedTextBoxStyle(styleSpecifier, annotation);
		if (!options.visibility) {
			data.handles.textBox = {
				hasMoved: false,
				worldPosition,
				worldBoundingBox: {
					topLeft,
					topRight,
					bottomLeft,
					bottomRight
				}
			};
			continue;
		}

		const textLines = this.configuration.getTextLines(data, targetId);

		if (!data.handles.textBox.hasMoved) {
			const canvasTextBoxCoords = getTextBoxCoordsCanvas(canvasCoordinates);
			data.handles.textBox.worldPosition = viewport.canvasToWorld(canvasTextBoxCoords);
		}

		// 修改,绘制主文本,两直线之比
		const textBoxPosition = viewport.worldToCanvas(data.handles.textBox.worldPosition);
		textBoxPosition[1] -= 50;

		const textBoxUID = "ctrRatioText";
		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])
		};

		if (this.configuration.showLinesText) {
		    // 修改,绘制直线1长度
			const arc1TextBoxUID = "lineText1";
			const arc1TextLine = [`${length1.toFixed(2)} ${unit}`];
			const arch1TextPosCanvas = midPoint2(arc1Start, arc1End);
			arch1TextPosCanvas[0] -= 30;
			arch1TextPosCanvas[1] = arc1Start[1] - 24;

			drawTextBoxSvg(
				svgDrawingHelper,
				annotationUID,
				arc1TextBoxUID,
				arc1TextLine,
				arch1TextPosCanvas,
				{
					...options,
					padding: 3
				}
			);

			// 修改,绘制直线2长度
			const arc2TextBoxUID = "lineText2";
			const arc2TextLine = [`${length2.toFixed(2)} ${unit}`];
			const arch2TextPosCanvas = midPoint2(arc2Start, arc2End);
			arch2TextPosCanvas[0] -= 30;
			arch2TextPosCanvas[1] = arc2Start[1] - 24;

			drawTextBoxSvg(
				svgDrawingHelper,
				annotationUID,
				arc2TextBoxUID,
				arc2TextLine,
				arch2TextPosCanvas,
				{
					...options,
					padding: 3
				}
			);
		}
	}

	return renderStatus;
};

二、使用步骤

与添加cornerstoneTool中的工具流程一样。

1.引入库

javascript 复制代码
import CTRTool from "./CTRTool";

2. 添加到cornerstoneTools

javascript 复制代码
cornerstoneTools.addTool(CTRTool);

3. 添加到toolGroup

javascript 复制代码
toolGroup.addTool(CTRTool.toolName, {
	showLinesText: true
});

总结

本章实现心胸比例测量工具CTRTool。

展示了从cornerstonejs库中派生自定义类的过程