以CornerstoneTools为基础自定义心胸比标注工具
CornerstoneTools提供了许多工具以便我们标注数据,然鹅在日常业务使用的时候这些工具可能会无法满足业务需求,这时候就需要我们基于CornerstoneTools自定义能满足业务需求的工具。
接下来我们将以CornerstoneTools提供的基类,以及暴漏出来的工具方法实现一个自带工具类中并没有的心胸比测量工具。医学影像的心胸比主要是指在X线片上心脏横径与胸廓横径之比,是评估心脏增大的常用指标,具体计算方法为心脏最大横径/胸廓最大横径
前提
需要了解CornerstoneTools的基本原理,工具模式以及设计思路。大概介绍可以看之前的文章cornerstoneTools简介以及官方文档。由于CornerstoneTools官方文档并没有详细的介绍如何自定义工具,并且需要用到的许多API没有文档说明,具体需要详细阅读源码,可自行翻阅其github源码实现
思路
CornerstoneTools由于工具绘制的复杂性尽可能的对其工具方法进行了解耦,所以在使用一些通用的工具方法的时候可以通过全局的importInternal方法进行导入,此方法接收一个工具方法对应的路径,具体可以参考源码
-
确定工具类型。心胸比工具属于测量类型的工具,所以需要引入Base Annotation Tool类
// 引入基础测量工具类和cornerstoneTools import csTools from 'cornerstone-tools/dist/cornerstoneTools.min.js'; const BaseAnnotationTool = csTools.importInternal('base/BaseAnnotationTool'); const external = csTools.external;
-
引入CornerstoneTools中的绘图工具
// 引入绘制工具 const getNewContext = csTools.importInternal('drawing/getNewContext'); // 引入画线、阴影、文本、绘制手柄工具 const draw = csTools.importInternal('drawing/draw'); const setShadow = csTools.importInternal('drawing/setShadow'); const drawLine = csTools.importInternal('drawing/drawLine'); const drawLinkedTextBox = csTools.importInternal('drawing/drawLinkedTextBox'); const drawHandles = csTools.importInternal('drawing/drawHandles');
-
引入全局工具状态管理类
// 工具状态管理,用于存储工具画线数据 import { getToolState, removeToolState } from './stateManagement/toolState.js'; // 工具样式和颜色 import toolStyle from './stateManagement/toolStyle.js'; import toolColors from './stateManagement/toolColors.js';
-
引入绘制线条的手柄实现类
// 引入手柄,可以通过移动手柄修改测量位置 const moveHandle = csTools.importInternal('manipulators/moveHandle'); const moveAllHandles = csTools.importInternal('manipulators/moveAllHandles'); const getHandleNearImagePoint = csTools.importInternal('manipulators/getHandleNearImagePoint'); const anyHandlesOutsideImage = csTools.importInternal('manipulators/anyHandlesOutsideImage'); const MouseCursor = csTools.importInternal('tools/cursors/MouseCursor');
-
初始化工具类
由于我们需要的心胸比工具是一个测量工具所以工具类继承BaseAnnotationTool类,在构造函数中配置具体的配置信息,如下
constructor(props = {}) { const defaultProps = { name: 'CardiothoracicRatio', supportedInteractionTypes: ['Mouse', 'Touch'], svgCursor: lengthCursor, configuration: { drawHandles: true, // 选择打开手柄 drawHandlesOnHover: false, hideHandlesIfMoving: false, // 不影藏手柄 renderDashed: false, }, }; super(props, defaultProps); this.throttledUpdateCachedStats = throttle(this.updateCachedStats, 110); // 更新缓存的数据信息 }
-
监听工具在初始化时候的回调,获取图像信息,初始化手柄设置
createNewMeasurement(eventData) { const goodEventData = eventData && eventData.currentPoints && eventData.currentPoints.image; if (!goodEventData) { return; } // 获取初始化时候的图像位置信息 const { x, y } = eventData.currentPoints.image; // 返回手柄配置 return { visible: true, active: true, color: undefined, invalidated: true, handles: { start: { x, y, highlight: true, active: false, handleId: 0, }, end: { x, y, highlight: true, active: true, handleId: 1, }, textBox: { active: false, hasMoved: false, movesIndependently: false, drawnIndependently: true, allowedOutsideImage: true, hasBoundingBox: true, }, start2: { x, y, highlight: true, active: false, handleId: 2, }, start3: { x, y, highlight: true, active: false, handleId: 3, }, }, vertPoints: [], }; }
-
更新缓存信息,获取线长度
updateCachedStats(image, element, data) { const { rowPixelSpacing, colPixelSpacing } = getPixelSpacing(image); const dx = (data.handles.end.x - data.handles.start.x) * (colPixelSpacing || 1); const dy = (data.handles.end.y - data.handles.start.y) * (rowPixelSpacing || 1); const length = Math.sqrt(dx * dx + dy * dy); data.length = length; data.invalidated = false; }
-
画线
renderToolData(evt) { const eventData = evt.detail; const { handleRadius, drawHandlesOnHover, hideHandlesIfMoving, renderDashed } = this.configuration; const toolData = getToolState(evt.currentTarget, this.name); if (!toolData) { return; } // 获取图像的canvas上下文 const context = getNewContext(eventData.canvasContext.canvas); const { image, element } = eventData; const { rowPixelSpacing, colPixelSpacing } = getPixelSpacing(image); const lineWidth = toolStyle.getToolWidth(); const lineDash = [4, 4]; // 遍历数据并划线 for (let i = 0; i < toolData.data.length; i++) { const data = toolData.data[i]; if (data.visible === false) { continue; } draw(context, (context) => { // 阴影信息 setShadow(context, this.configuration); const color = toolColors.getColorIfActive(data); const lineOptions = { color }; if (renderDashed) { lineOptions.lineDash = lineDash; } //初始化线段 // 获取手柄的位置 const handleEndCanvas = external.cornerstone.pixelToCanvas(eventData.element, data.handles.end); const handleStartCanvas = external.cornerstone.pixelToCanvas(eventData.element, data.handles.start); const handleStartCanvas2 = external.cornerstone.pixelToCanvas(eventData.element, data.handles.start2); const handleStartCanvas3 = external.cornerstone.pixelToCanvas(eventData.element, data.handles.start3); const options = { e: evt, element: eventData.element, context: context, num: 2, lineWidth: lineWidth, color: color, start: handleStartCanvas, end: handleEndCanvas, start2: handleStartCanvas2, start3: handleStartCanvas3, curIndex: i, data: data, }; data.handles = initLineSegment(options, data.handles); // 画手柄 const handleOptions = { color, handleRadius, drawHandlesIfActive: drawHandlesOnHover, hideHandlesIfMoving, }; if (this.configuration.drawHandles) { drawHandles(context, eventData, data.handles, handleOptions); } if (!data.handles.textBox.hasMoved) { const coords = { x: Math.max(data.handles.start.x, data.handles.end.x), }; // 计算手柄的位置,根据手柄位置计算文字的位置 if (coords.x === data.handles.start.x) { coords.y = data.handles.start.y; } else { coords.y = data.handles.end.y; } data.handles.textBox.x = coords.x; data.handles.textBox.y = coords.y; } // 设置偏移量让文字在手柄旁边 const xOffset = 10; // 判断有没有获取到长度,更新文字信息 if (data.invalidated === true) { if (data.length) { this.throttledUpdateCachedStats(image, element, data); } else { this.updateCachedStats(image, element, data); } } const text = textBoxText(data, rowPixelSpacing, colPixelSpacing); // 画文字信息 drawLinkedTextBox( context, element, data.handles.textBox, text, data.handles, textBoxAnchorPoints, color, lineWidth, xOffset, true, ); }); } // - 计算心胸比 function textBoxText(annotation, rowPixelSpacing, colPixelSpacing) { const measuredValue = _sanitizeMeasuredValue(annotation.length); // 获取两个垂直点的坐标 const p1 = annotation.vertPoints[0]; const p2 = annotation.vertPoints[1]; // 计算两点之间的距离。勾股定理 const vdx = (p1.x - p2.x) * colPixelSpacing; const vdy = (p1.y - p2.y) * rowPixelSpacing; const vdis = Math.sqrt(vdx * vdx + vdy * vdy); const ctr = vdis ? vdis / measuredValue : 0; const text = 'Ratio: ' + ctr.toFixed(2); if (!measuredValue) { return ''; } return text; } function textBoxAnchorPoints(handles) { const midpoint = { x: (handles.start.x + handles.end.x) / 2, y: (handles.start.y + handles.end.y) / 2, }; return [handles.start, midpoint, handles.end]; } // 初始化垂直线段 function initLineSegment(opts, handlesData) { var element = opts.element; var start_x = opts.start.x, //起始点 start_y = opts.start.y, end_x = opts.end.x, //结束点 end_y = opts.end.y, start2_x = opts.start2.x, //垂直点一 start2_y = opts.start2.y, start3_x = opts.start3.x, //垂直点二 start3_y = opts.start3.y, num = opts.num, context = opts.context, //初始化线段长度 k = (end_y - start_y) / (end_x - start_x), //斜率 b = (start_x * end_y - end_x * start_y) / (start_x - end_x); //直线截距 const color = toolColors.getColorIfActive(opts.data); const lineOptions = { color }; if (renderDashed) { lineOptions.lineDash = lineDash; } // 绘制主线延长线,这样当垂直线越界时,也能显示正常 if (handlesData.start.x != handlesData.start.y) { const extensionLineOptions = { color, lineDash: [1, 10], }; const ddx = handlesData.end.x - handlesData.start.x; const ddy = handlesData.end.y - handlesData.start.y; let _t = 2000 / Math.hypot(handlesData.start.x - handlesData.end.x, handlesData.start.y - handlesData.end.y); _t = Math.max(10, Math.min(_t, 10000)); const extensionLineStart = { x: handlesData.start.x - _t * ddx, y: handlesData.start.y - _t * ddy, }; const extensionLineEnd = { x: handlesData.start.x + _t * ddx, y: handlesData.start.y + _t * ddy, }; drawLine(context, element, extensionLineStart, extensionLineEnd, extensionLineOptions); } //先画主线段 drawLine(context, element, handlesData.start, handlesData.end, lineOptions); //再画垂直等分线 //初次创建 var x1 = 0, y1 = 0, x2 = 0, y2 = 0, pt; opts.data.vertPoints = []; if (opts.data.invalidated) { if (start_x === end_x) { start_x += 0.01; } var aX = parseInt((end_x - start_x) / (num + 1)), aY = parseInt((end_y - start_y) / (num + 1)), a0 = parseInt(Math.sqrt(Math.pow(end_y - start_y, 2) + Math.pow(end_x - start_x, 2)) / (num + 1)); for (var i = 1; i <= num; i++) { start_x = start_x + aX; start_y = start_y + aY; (x1 = start_x + a0 * Math.sin(Math.atan((end_y - start_y) / (end_x - start_x)))), (y1 = start_y - a0 * Math.cos(Math.atan((end_y - start_y) / (end_x - start_x)))); context.moveTo(x1, y1); context.lineTo(start_x, start_y); //存储两点,用来计算两点距离 pt = external.cornerstone.canvasToPixel(element, { x: x1, y: y1 }); opts.data.vertPoints.push(pt); if (i == 1) { handlesData.start2.x = pt.x; handlesData.start2.y = pt.y; } else { handlesData.start3.x = pt.x; handlesData.start3.y = pt.y; } } } else { x1 = (start2_x + k * start2_y - k * b) / (k * k + 1); y1 = (k * start2_x + k * k * start2_y + b) / (k * k + 1); x2 = (start3_x + k * start3_y - k * b) / (k * k + 1); y2 = (k * start3_x + k * k * start3_y + b) / (k * k + 1); context.moveTo(start2_x, start2_y); context.lineTo(x1, y1); context.moveTo(start3_x, start3_y); context.lineTo(x2, y2); opts.data.vertPoints.push( external.cornerstone.canvasToPixel(element, { x: x1, y: y1, }), external.cornerstone.canvasToPixel(element, { x: x2, y: y2, }), ); } context.stroke(); return handlesData; } }
-
监听鼠标事件,在手柄变化的时候重新更新数据
preMouseDownCallback(evt) { const eventData = evt.detail; var data = this.createNewMeasurement(eventData); var element = eventData.element; // 手柄移动完成之后的回调 function handleDoneMove() { data.invalidated = false; if (anyHandlesOutsideImage(eventData, data.handles)) { removeToolState(element, 'CardiothoracicRatio', data); } external.cornerstone.updateImage(element); } const coords = eventData.startPoints.canvas; const toolData = getToolState(evt.currentTarget, this.name); if (!toolData) { return; } for (let i = 0; i < toolData.data.length; i++) { data = toolData.data[i]; var distance = 25; var handle = getHandleNearImagePoint(element, data.handles, coords, distance); // 判断点击的是不是手柄,如果是调用moveHandle,传入移动后的回调 if (handle) { element.removeEventListener('cornerstonetoolstouchdrag', this._moveCallback); data.active = true; moveHandle( eventData, this.name, data, handle, { deleteIfHandleOutsideImage: true, preventHandleOutsideImage: false }, 'mouse', handleDoneMove, ); preventPropagation(evt); return true; } } for (let i = 0; i < toolData.data.length; i++) { data = toolData.data[i]; // 判断点击的是不是手柄,如果是调用moveHandle,传入移动后的回调 if (this.pointNearTool(element, data, coords, 'mouse')) { element.removeEventListener('cornerstonetoolstouchdrag', this._moveCallback); data.active = true; moveAllHandles( eventData, this.name, data, handle, { deleteIfHandleOutsideImage: true, preventHandleOutsideImage: false }, 'mouse', handleDoneMove, ); preventPropagation(evt); return true; } } }
-
效果如下所示