# TibialPlateauAngleTool 胫骨平台夹角 TPA 测量工具文档 ## 一、业务需求分析 ### 1.1 测量目的 胫骨平台角(TPA)的测量目的是评估狗狗膝关节胫骨平台的倾斜程度,从而判断其前交叉韧带受伤风险。 ### 1.2 点定义 | 点编号 | 作用 | 位置说明 | |--------|------|----------| | 1、2 | 生成胫骨内侧平台线 | 连接点1→点2形成平台线 | | 3、4 | 生成胫骨功能轴线 | 连接点3→点4形成功能轴线 | **关键线段定义**: - **胫骨内侧平台线**: 点1→点2的连线 - **胫骨功能轴线**: 点3→点4的连线 - **交汇点**: 胫骨内侧平台线与胫骨功能轴线延长后的交点(即两条线的数学交点) - **平台线中垂线**: 从交汇点出发,垂直于胫骨内侧平台线的直线 ### 1.3 角度计算公式 **TPA角度** = 胫骨内侧平台线(1→2)与胫骨功能轴线(3→4)的垂线夹角 具体计算步骤: 1. 计算胫骨内侧平台线向量: `plateauVector = 点2 - 点1` 2. 计算胫骨功能轴线向量: `functionalAxisVector = 点4 - 点3` 3. 计算平台线的垂线向量: `perpendicularVector = (-plateauVector.y, plateauVector.x, 0)` (2D平面垂直向量) 4. 计算功能轴线与垂线的夹角: `TPA = angle(functionalAxisVector, perpendicularVector)` ### 1.4 交互规则 - **4个可交互控制点**: 用户可拖动4个点以实时更新所有线段和TPA值 - **实时更新**: 所有变化均即时刷新显示 - **TPA数值显示**: - 以**黄色**标注 - 可自由拖动调整显示位置 - **选中状态**: - 所有线段以**蓝色**显示 - 控制点以**白色圆圈**显示 - **非选中状态**: - 线段以**白色**显示 - 点仍为**白色圆圈** --- ## 二、技术原理与使用的 Cornerstone3D API ### 2.1 核心继承关系 ```typescript class TibialPlateauAngleTool extends AnnotationTool ``` - 继承自 `@cornerstonejs/tools` 的 `AnnotationTool` 基类 - 获得注解工具的基础功能:创建、编辑、渲染、状态管理 ### 2.2 使用的主要 Cornerstone3D API #### **核心模块 (@cornerstonejs/core)** ```typescript import { getEnabledElement, Types as CoreTypes } from '@cornerstonejs/core'; ``` | API | 用途 | |-----|------| | `getEnabledElement(element)` | 获取启用的element及其viewport | | `viewport.worldToCanvas(point)` | 将世界坐标转换为canvas坐标(用于渲染) | | `viewport.canvasToWorld(point)` | 将canvas坐标转换为世界坐标(用于存储) | | `viewport.getCamera()` | 获取相机参数(viewPlaneNormal, viewUp) | | `viewport.getCurrentImageId()` | 获取当前图像ID | | `viewport.getFrameOfReferenceUID()` | 获取参考坐标系UID | #### **工具模块 (@cornerstonejs/tools)** ```typescript import { AnnotationTool, utilities, annotation, drawing } from '@cornerstonejs/tools'; ``` | API/模块 | 用途 | |---------|------| | `annotation.state.addAnnotation()` | 将注解添加到状态管理 | | `annotation.state.getAnnotations()` | 获取指定工具的所有注解 | | `utilities.viewportFilters.getViewportIdsWithToolToRender()` | 获取需要渲染的viewport ID列表 | | `utilities.triggerAnnotationRenderForViewportIds()` | 触发指定viewport的渲染更新 | | `drawing.drawLine()` | 绘制线段 | | `drawing.drawPath()` | 绘制路径(用于弧线) | | `drawing.drawHandles()` | 绘制可拖拽的手柄点 | | `drawing.drawLinkedTextBox()` | 绘制连接到点的文本框 | #### **事件系统** ```typescript // 鼠标事件监听 element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', callback); element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', callback); element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', callback); ``` ### 2.3 关键技术实现 #### **1. 坐标系统转换** - **世界坐标系(World Coordinates)**: 3D空间中的绝对坐标,用于存储 - **Canvas坐标系(Canvas Coordinates)**: 2D屏幕坐标,用于渲染和交互 ```typescript // 存储时:canvas → world const worldPoint = viewport.canvasToWorld(canvasPoint); // 渲染时:world → canvas const canvasPoint = viewport.worldToCanvas(worldPoint); ``` #### **2. 注解数据结构** ```typescript interface TibialPlateauAngleAnnotation { metadata: { viewPlaneNormal: Point3; // 视图平面法向量 viewUp: Point3; // 视图向上向量 FrameOfReferenceUID: string; // 参考坐标系 referencedImageId: string; // 图像ID toolName: string; // 工具名称 }; data: { handles: { points: Point3[]; // 4个世界坐标点 activeHandleIndex: number | null; // 当前活动的手柄索引 textBoxTPA?: Point2; // TPA角度文本框位置(canvas坐标) }; cachedStats: { [targetId: string]: { TPA: number; // TPA角度值 functionalAxisVector: Point3; // 功能轴线向量(点3→点4) plateauLineVector: Point3; // 平台线向量(点1→点2) perpendicularVector: Point3; // 平台线的垂线向量 plateauMidpoint: Point3; // 平台线中点 intersectionPoint?: Point3; // 功能轴线与中垂线的交点 }; }; }; } ``` #### **3. 交互模式** - **修改模式(Modify Mode)**: `_activateModify()` - 用于编辑现有注解 - 监听鼠标按下、拖拽、释放事件 - 支持拖拽手柄点和文本框 #### **4. 几何计算步骤** ```typescript // Step 1: 计算胫骨内侧平台线向量(点1→点2) const plateauLineVector = vectorSubtract(point2, point1); // Step 2: 计算胫骨功能轴线向量(点3→点4) const functionalAxisVector = vectorSubtract(point4, point3); // Step 3: 计算平台线与功能轴线的交点(作为中垂线的起点) const intersectionPoint = getLineIntersection( point1, // 平台线起点 plateauLineVector, // 平台线方向 point3, // 功能轴线起点 functionalAxisVector // 功能轴线方向 ); // Step 4: 计算平台线的中垂线向量 // 在2D平面上,向量(x,y)的逆时针垂直向量为(-y,x) // 这条垂线从交点出发,垂直于平台线 const perpendicularVector: Point3 = [ -plateauLineVector[1], plateauLineVector[0], 0 ]; // Step 5: 计算TPA角度(功能轴线与平台线中垂线的夹角) const angleRad = getAngleBetweenLines(functionalAxisVector, perpendicularVector); const TPA = Math.round(degrees(angleRad)); ``` #### **5. 可视化元素** 1. **胫骨内侧平台线**(点1→点2): 白色/蓝色实线(选中时) 2. **胫骨功能轴线**(点3→点4): 白色/蓝色实线(选中时) 3. **平台线延长虚线**: 灰色虚线,从平台线的**较近端点经过交汇点继续延伸50像素** 4. **功能轴线延长虚线**: 灰色虚线,从功能轴线的**较近端点经过交汇点继续延伸50像素** 5. **交汇点**: 延长虚线的交汇处,即平台线与功能轴线的数学交点 6. **平台线中垂线**: 绿色虚线,从交汇点向两端延伸,垂直于平台线 7. **角度弧线**: 黄色,在交汇点处绘制,表示TPA角度 8. **TPA文本**: 黄色,可拖拽,显示角度值 9. **手柄点**: 4个白色圆圈,可拖拽 **延长线实现细节**: - **延长线起点**: 自动选择离交汇点较近的端点作为延长线起点 - **延长逻辑**: 1. 计算从端点到交汇点的方向向量 2. 归一化该方向向量 3. 从交汇点沿该方向继续延伸额外的50像素 - **视觉效果**: 延长线从端点穿过交汇点后继续延伸,使两条线的交汇更加清晰明显 --- ## 三、开发人员使用指南 ### 3.1 工具注册与激活 #### **步骤1: 导入并注册工具** ```typescript import TibialPlateauAngleTool from './TibialPlateauAngleTool'; import { ToolGroupManager } from '@cornerstonejs/tools'; // 注册工具 cornerstoneTools.addTool(TibialPlateauAngleTool); ``` #### **步骤2: 创建工具组并添加工具** ```typescript const toolGroupId = 'myToolGroup'; const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); // 添加工具到工具组 toolGroup.addTool(TibialPlateauAngleTool.toolName); // 将工具组关联到viewport toolGroup.addViewport(viewportId, renderingEngineId); ``` #### **步骤3: 激活工具** ```typescript // 激活工具 toolGroup.setToolActive(TibialPlateauAngleTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); // 激活修改模式(允许拖拽) const toolInstance = toolGroup.getToolInstance( TibialPlateauAngleTool.toolName ) as TibialPlateauAngleTool; if (toolInstance && elementRef.current) { toolInstance._activateModify(elementRef.current); } ``` ### 3.2 创建注解(输入) #### **方式1: 使用默认配置** ```typescript const annotation = TibialPlateauAngleTool.createDefaultAnnotation( element, viewport ); ``` #### **方式2: 使用自定义配置** ```typescript const config = { axisLineLength: 200, // 功能轴线长度(px) plateauLineLength: 150, // 平台线长度(px) axisLineCenterX: 400, // 功能轴线中心X坐标 axisLineCenterY: 300, // 功能轴线中心Y坐标 plateauLineCenterX: 600, // 平台线中心X坐标 plateauLineCenterY: 300, // 平台线中心Y坐标 }; const annotation = TibialPlateauAngleTool.createDefaultAnnotation( element, viewport, config ); ``` #### **配置参数说明** | 参数 | 类型 | 默认值 | 说明 | |-----|------|-------|------| | axisLineLength | number | 200 | 胫骨功能轴线长度(像素) | | plateauLineLength | number | 150 | 胫骨内侧平台线长度(像素) | | axisLineCenterX | number | centerX - 100 | 功能轴线中心点X坐标 | | axisLineCenterY | number | centerY | 功能轴线中心点Y坐标 | | plateauLineCenterX | number | centerX + 100 | 平台线中心点X坐标 | | plateauLineCenterY | number | centerY | 平台线中心点Y坐标 | **默认布局**: 功能轴线在左侧(垂直),平台线在右侧(水平) #### **将注解添加到状态管理** ```typescript // 添加注解 cornerstoneTools.annotation.state.addAnnotation(annotation, element); // 计算统计数据 const enabledElement = cornerstone.getEnabledElement(element); if (enabledElement) { toolInstance._updateCachedStats(annotation, enabledElement); } // 触发渲染 const viewportIdsToRender = cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender( element, TibialPlateauAngleTool.toolName ); cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); ``` ### 3.3 导出注解数据(输出) #### **导出数据结构** ```typescript export interface ExportedAnnotationData { // 4个点的世界坐标 points: Point3[]; // TPA角度值(度数) TPA: number; // 文本框位置(可选,canvas坐标) textBoxPosition?: Point2; // 元数据 metadata: { viewPlaneNormal: Point3; viewUp: Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } ``` #### **导出方法** ```typescript // 获取注解 const annotations = cornerstoneTools.annotation.state.getAnnotations( TibialPlateauAngleTool.toolName, element ); // 导出第一个注解的数据 if (annotations && annotations.length > 0) { const exportedData = TibialPlateauAngleTool.exportAnnotationData( annotations[0], viewport ); if (exportedData) { // 保存数据(例如发送到服务器或保存到localStorage) console.log('导出的数据:', exportedData); // 获取TPA角度值 console.log('TPA角度:', exportedData.TPA, '度'); // 获取4个控制点的世界坐标 console.log('点1(平台线起点):', exportedData.points[0]); console.log('点2(平台线终点):', exportedData.points[1]); console.log('点3(功能轴线起点):', exportedData.points[2]); console.log('点4(功能轴线终点):', exportedData.points[3]); // 获取文本框位置 if (exportedData.textBoxPosition) { console.log('TPA文本框位置:', exportedData.textBoxPosition); } } else { console.log('注解数据未计算完成,无法导出'); } } ``` ### 3.4 恢复注解数据 ```typescript // 从保存的数据恢复注解 const restoredAnnotation = TibialPlateauAngleTool.restoreFromExportedData( exportedData, element, viewport ); // 添加到状态 cornerstoneTools.annotation.state.addAnnotation(restoredAnnotation, element); // 重新计算统计数据 const enabledElement = cornerstone.getEnabledElement(element); if (enabledElement) { const toolInstance = toolGroup.getToolInstance( TibialPlateauAngleTool.toolName ) as TibialPlateauAngleTool; if (toolInstance) { toolInstance._updateCachedStats(restoredAnnotation, enabledElement); } } // 触发渲染 const viewportIdsToRender = cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender( element, TibialPlateauAngleTool.toolName ); cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); ``` ### 3.5 完整使用示例 ```typescript import React, { useEffect, useRef, useState } from 'react'; import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import TibialPlateauAngleTool from './TibialPlateauAngleTool'; const TPAMeasurementComponent = () => { const elementRef = useRef(null); const [tpaValue, setTpaValue] = useState(null); useEffect(() => { const setup = async () => { // 1. 初始化 await cornerstone.init(); await cornerstoneTools.init(); // 2. 注册工具 cornerstoneTools.addTool(TibialPlateauAngleTool); // 3. 创建rendering engine和viewport const renderingEngine = new cornerstone.RenderingEngine('myEngine'); const viewportInput = { viewportId: 'myViewport', element: elementRef.current, type: cornerstone.Enums.ViewportType.STACK, }; renderingEngine.enableElement(viewportInput); // 4. 加载图像... // 5. 创建工具组 const toolGroup = cornerstoneTools.ToolGroupManager.createToolGroup('myToolGroup'); toolGroup.addTool(TibialPlateauAngleTool.toolName); toolGroup.addViewport('myViewport', 'myEngine'); // 6. 激活工具 toolGroup.setToolActive(TibialPlateauAngleTool.toolName, { bindings: [{ mouseButton: cornerstoneTools.Enums.MouseBindings.Primary }], }); // 7. 创建预设注解 const viewport = renderingEngine.getViewport('myViewport'); const annotation = TibialPlateauAngleTool.createDefaultAnnotation( elementRef.current, viewport, { axisLineLength: 200, plateauLineLength: 150, } ); // 8. 添加注解 cornerstoneTools.annotation.state.addAnnotation(annotation, elementRef.current); // 9. 计算统计数据 const enabledElement = cornerstone.getEnabledElement(elementRef.current); const toolInstance = toolGroup.getToolInstance( TibialPlateauAngleTool.toolName ) as TibialPlateauAngleTool; if (enabledElement && toolInstance) { toolInstance._updateCachedStats(annotation, enabledElement); toolInstance._activateModify(elementRef.current); } // 10. 触发渲染 const viewportIdsToRender = cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender( elementRef.current, TibialPlateauAngleTool.toolName ); cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); }; setup(); }, []); // 导出TPA数据 const handleExportTPA = () => { const annotations = cornerstoneTools.annotation.state.getAnnotations( TibialPlateauAngleTool.toolName, elementRef.current ); if (annotations && annotations.length > 0) { const renderingEngine = cornerstone.getRenderingEngine('myEngine'); const viewport = renderingEngine.getViewport('myViewport'); const data = TibialPlateauAngleTool.exportAnnotationData( annotations[0], viewport ); if (data) { setTpaValue(data.TPA); console.log('TPA测量结果:', data); // 发送到服务器或其他处理 // sendToServer(data); } } }; return (
{tpaValue !== null && (
当前TPA值: {tpaValue}°
)}
); }; ``` --- ## 四、常见问题与解决方案 ### 4.1 手柄点不能拖拽 **原因**: 未调用 `_activateModify()` 或事件监听器未注册 **解决**: ```typescript const toolInstance = toolGroup.getToolInstance( TibialPlateauAngleTool.toolName ) as TibialPlateauAngleTool; if (toolInstance && elementRef.current) { toolInstance._activateModify(elementRef.current); } ``` ### 4.2 TPA文本框拖拽跳跃 **原因**: 未记录鼠标相对文本框的偏移量 **解决**: 已在实现中通过 `textBoxOffset` 解决,在mouseDown时记录偏移量,mouseDrag时应用 ### 4.3 角度计算不符合预期 **原因**: 垂线向量计算错误或角度方向问题 **检查**: - 确保平台线的垂线向量计算正确: `(-y, x, 0)` - 验证向量方向是否符合业务需求 - 检查角度计算函数的实现 ### 4.4 中垂线不显示或位置错误 **原因**: 交点计算失败或延伸长度不够 **解决**: - 确认两条直线不平行(判断行列式非零) - 增加延伸长度使中垂线清晰可见 - 检查归一化向量计算 ### 4.5 渲染不更新 **原因**: 未触发渲染更新 **解决**: ```typescript utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); ``` --- ## 五、技术要点总结 ### 5.1 几何计算要点 1. **交点计算**: 首先计算平台线与功能轴线的交点,这是中垂线的起点 2. **垂线向量**: 2D平面上向量(x,y)的垂直向量为(-y,x),中垂线从交点出发垂直于平台线 3. **角度计算**: 使用 `getAngleBetweenLines()` 计算功能轴线与中垂线的夹角 4. **向量归一化**: 用于延长线段的绘制,确保延伸距离准确 ### 5.2 可视化要点 1. **实线**: 功能轴线、平台线使用实线绘制(白色/蓝色) 2. **灰色虚线**: 平台线和功能轴线的延长线 - 从各自较近的端点出发 - 经过交汇点后继续延伸50像素 - 清晰显示两线的交汇关系 3. **绿色虚线**: 平台线的中垂线,从交点延伸,垂直于平台线 4. **角度弧线**: 在交点处绘制,半径适中(如30px) 5. **文本框**: 黄色显示,支持拖拽,避免遮挡关键线段 **延长线绘制技术细节**: ```typescript // 1. 选择离交点较近的端点 const dist1 = distance(intersection, point1); const dist2 = distance(intersection, point2); const extensionStart = dist1 < dist2 ? point1 : point2; // 2. 计算从端点到交点的方向向量并归一化 const directionVector = subtract(intersection, extensionStart); const normalizedDir = normalize(directionVector); // 3. 计算延长线的终点(交点后继续延伸50像素) const extraExtensionLength = 50; const extensionEnd = add(intersection, scale(normalizedDir, extraExtensionLength)); // 4. 绘制从端点到延长终点的完整虚线 drawLine(extensionStart, extensionEnd, { lineDash: '4,4' }); ``` ### 5.3 交互要点 1. **手柄点识别**: 检测鼠标与点的距离,设置合理的proximity阈值 2. **文本框拖拽**: 记录初始偏移量,避免跳跃 3. **实时更新**: 每次拖拽后立即调用 `_updateCachedStats()` 重新计算 4. **事件管理**: 正确注册和注销事件监听器 --- ## 六、总结 TibialPlateauAngleTool展示了: 1. **医学影像测量**: 基于4个点计算胫骨平台角度 2. **几何计算**: 中点、垂线、交点、角度的综合运用 3. **可视化设计**: 实线+虚线组合,清晰表达测量逻辑 4. **交互体验**: 支持拖拽调整,实时反馈 5. **数据持久化**: 完整的导出/恢复机制 这个工具可用于: - **临床诊断**: 评估犬类前交叉韧带风险 - **术前规划**: 确定手术方案 - **疗效评估**: 对比治疗前后的TPA变化 - **教学研究**: 演示胫骨平台角度测量方法