# HipNHAAngleMeasurementTool 髋关节NHA角测量工具文档 ## 一、业务需求概述 ### 1.1 测量目的 用于评估髋关节的发育对称性与髋臼覆盖情况,帮助判断是否存在髋关节发育不良。 ### 1.2 业务定义 **交互点定义(共8个可拖动点)**: - **点1, 2, 3**:确定左侧股骨头圆,拟合圆心记为 M1 - **点5, 6, 7**:确定右侧股骨头圆,拟合圆心记为 M2 - **点4**:左侧髋臼边缘点,与 M1 相连形成左侧切线 - **点8**:右侧髋臼边缘点,与 M2 相连形成右侧切线 **圆拟合公式**: ``` x² + y² + Dx + Ey + F = 0 ``` 三个点可求解圆心和半径。 **计算方法**: 1. 通过点(1,2,3)拟合左股骨圆,得到圆心M1 2. 通过点(5,6,7)拟合右股骨圆,得到圆心M2 3. 连接M1–M2形成髋关节基准线 4. 连接M1–4得到左侧髋臼切线 5. 连接M2–8得到右侧髋臼切线 6. 计算两条切线分别与基准线的夹角,即为左右两侧的NHA角 **显示规则**: - **主线**(M1–M2、M1–4、M2–8):实线显示 - **角度线**:虚线显示 - **股骨圆**:实线圆显示 - **控制点**(1~8):均为可拖动的小圆点,所有点可拖动,拖动后实时更新圆心、角度和圆形 - **角度标注**:可自由拖动,避免与其他元素重叠 - **工具选中状态**:圆与线段为蓝色,点为白色 - **工具未选中状态**:圆与线段为白色,点保持白色 --- ## 二、技术实现方案 ### 2.1 核心几何元素映射 | 业务元素 | 技术实现 | Cornerstone3D 对应 | |---------|---------|-------------------| | **8个可拖动点** | 手柄点(Handles) | `data.handles.points: Point3[]` | | **左侧股骨头圆** | 圆拟合结果 | 通过点1,2,3计算,使用 `drawCircle` 绘制 | | **右侧股骨头圆** | 圆拟合结果 | 通过点5,6,7计算,使用 `drawCircle` 绘制 | | **圆心M1** | 计算点 | 存储在 `cachedStats.centerM1: Point3` | | **圆心M2** | 计算点 | 存储在 `cachedStats.centerM2: Point3` | | **基准线M1-M2** | 实线 | 使用 `drawLine` 绘制 | | **左侧切线M1-4** | 实线 | 使用 `drawLine` 绘制 | | **右侧切线M2-8** | 实线 | 使用 `drawLine` 绘制 | | **左侧NHA角** | 角度计算+弧线 | 存储在 `cachedStats.angleLeft`,用 `drawPath` 绘制弧线 | | **右侧NHA角** | 角度计算+弧线 | 存储在 `cachedStats.angleRight`,用 `drawPath` 绘制弧线 | | **角度标注** | 可拖动文本框 | 使用 `drawLinkedTextBox`,位置存储在 `handles.textBoxLeft/Right` | ### 2.2 数据结构设计 ```typescript interface HipNHAAngleMeasurementAnnotation extends Types.Annotation { data: { handles: { points: CoreTypes.Point3[]; // 8个点的世界坐标 activeHandleIndex: number | null; // 当前激活的手柄索引 textBoxLeft?: CoreTypes.Point2; // 左侧角度文本框位置 textBoxRight?: CoreTypes.Point2; // 右侧角度文本框位置 }; cachedStats?: { [targetId: string]: { // 左侧圆信息 centerM1: CoreTypes.Point3; // 左侧圆心M1 radiusLeft: number; // 左侧半径 // 右侧圆信息 centerM2: CoreTypes.Point3; // 右侧圆心M2 radiusRight: number; // 右侧半径 // 角度信息 angleLeft: number; // 左侧NHA角(度数) angleRight: number; // 右侧NHA角(度数) // 辅助向量(用于绘制角度弧线) baselineVector?: CoreTypes.Point3; // M1-M2基准线向量 leftTangentVector?: CoreTypes.Point3; // M1-4切线向量 rightTangentVector?: CoreTypes.Point3; // M2-8切线向量 }; }; }; } ``` ### 2.3 使用的 Cornerstone3D API #### **核心模块 (@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)** | API/模块 | 用途 | |---------|------| | `annotation.state.addAnnotation()` | 将注解添加到状态管理 | | `annotation.state.getAnnotations()` | 获取指定工具的所有注解 | | `utilities.triggerAnnotationRenderForViewportIds()` | 触发指定viewport的渲染更新 | | `drawing.drawLine()` | 绘制线段 | | `drawing.drawCircle()` | 绘制圆形 | | `drawing.drawPath()` | 绘制路径(用于弧线) | | `drawing.drawHandles()` | 绘制可拖拽的手柄点 | | `drawing.drawLinkedTextBox()` | 绘制连接到点的文本框 | --- ## 三、核心算法实现 ### 3.1 圆拟合算法 **目标**:通过3个点拟合圆,求解圆心和半径。 **数学原理**: 圆的一般方程:`x² + y² + Dx + Ey + F = 0` 对于圆上的任意点(x, y),都满足这个方程。给定3个点,可以建立3个方程: ``` x₁² + y₁² + D·x₁ + E·y₁ + F = 0 x₂² + y₂² + D·x₂ + E·y₂ + F = 0 x₃² + y₃² + D·x₃ + E·y₃ + F = 0 ``` 求解D, E, F后: - **圆心坐标**:`(-D/2, -E/2)` - **半径**:`r = √((D² + E²)/4 - F)` **实现**: ```typescript function fitCircleFrom3Points( p1: Point3, p2: Point3, p3: Point3 ): { center: Point3; radius: number } { // 提取2D坐标 const x1 = p1[0], y1 = p1[1]; const x2 = p2[0], y2 = p2[1]; const x3 = p3[0], y3 = p3[1]; // 构建线性方程组 A * [D, E, F]ᵀ = B const A = [ [x1, y1, 1], [x2, y2, 1], [x3, y3, 1] ]; const B = [ -(x1*x1 + y1*y1), -(x2*x2 + y2*y2), -(x3*x3 + y3*y3) ]; // 求解方程组 const [D, E, F] = solveLinearSystem3x3(A, B); // 计算圆心 const centerX = -D / 2; const centerY = -E / 2; const centerZ = p1[2]; // 保持Z坐标 // 计算半径 const radius = Math.sqrt((D*D + E*E) / 4 - F); return { center: [centerX, centerY, centerZ], radius }; } ``` ### 3.2 线性方程组求解(克拉默法则) ```typescript function solveLinearSystem3x3(A: number[][], B: number[]): number[] { // 计算3x3矩阵的行列式 const det = (m: number[][]) => m[0][0] * (m[1][1]*m[2][2] - m[1][2]*m[2][1]) - m[0][1] * (m[1][0]*m[2][2] - m[1][2]*m[2][0]) + m[0][2] * (m[1][0]*m[2][1] - m[1][1]*m[2][0]); const detA = det(A); // 克拉默法则求解 const A1 = [[B[0], A[0][1], A[0][2]], [B[1], A[1][1], A[1][2]], [B[2], A[2][1], A[2][2]]]; const A2 = [[A[0][0], B[0], A[0][2]], [A[1][0], B[1], A[1][2]], [A[2][0], B[2], A[2][2]]]; const A3 = [[A[0][0], A[0][1], B[0]], [A[1][0], A[1][1], B[1]], [A[2][0], A[2][1], B[2]]]; return [det(A1)/detA, det(A2)/detA, det(A3)/detA]; } ``` ### 3.3 角度计算 ```typescript function calculateNHAAngle( centerM: Point3, // 圆心 otherCenter: Point3, // 另一个圆心 tangentPoint: Point3 // 切线点 ): number { // 基准线向量 const baselineVector = vectorSubtract(otherCenter, centerM); // 切线向量 const tangentVector = vectorSubtract(tangentPoint, centerM); // 计算夹角 const angleRad = getAngleBetweenLines(baselineVector, tangentVector); // 转换为度数并取整 return Math.round(degrees(angleRad)); } ``` --- ## 四、渲染流程 ### 4.1 renderAnnotation 方法流程 ``` 1. 获取注解数据和缓存统计 2. 转换所有点为canvas坐标 3. 绘制8个手柄点(白色圆点) 4. 绘制左侧股骨头圆(实线圆) 5. 绘制右侧股骨头圆(实线圆) 6. 绘制基准线 M1-M2(实线) 7. 绘制左侧切线 M1-point4(实线) 8. 绘制右侧切线 M2-point8(实线) 9. 绘制左侧角度弧线(虚线) 10. 绘制右侧角度弧线(虚线) 11. 绘制左侧角度文本框(可拖动) 12. 绘制右侧角度文本框(可拖动) ``` ### 4.2 颜色方案 | 状态 | 圆和线段颜色 | 手柄点颜色 | |-----|------------|-----------| | **选中(highlighted)** | `rgb(0, 120, 255)` 蓝色 | `rgb(255, 255, 255)` 白色 | | **未选中** | `rgb(255, 255, 255)` 白色 | `rgb(255, 255, 255)` 白色 | ### 4.3 线条样式 | 元素 | 样式 | |-----|------| | 股骨头圆 | 实线,宽度2px | | 基准线 M1-M2 | 实线,宽度2px | | 切线 M1-4, M2-8 | 实线,宽度2px | | 角度弧线 | 虚线 `lineDash: '4,4'`,宽度1px | | 手柄点 | 半径6px | --- ## 五、交互功能 ### 5.1 手柄点拖拽 **触发条件**:鼠标按下在手柄点附近(proximity < 15px) **交互流程**: 1. `mouseDown`: 找到最近的手柄点,设置 `activeHandleIndex` 2. `mouseDrag`: 更新手柄点位置,重新计算圆和角度 3. `mouseUp`: 清除 `activeHandleIndex`,结束拖拽 **实时更新**: - 拖拽点1/2/3 → 更新左侧圆M1和左侧NHA角 - 拖拽点4 → 更新左侧NHA角 - 拖拽点5/6/7 → 更新右侧圆M2和右侧NHA角 - 拖拽点8 → 更新右侧NHA角 ### 5.2 文本框拖拽 **实现要点**: - 记录鼠标相对文本框的偏移量(`textBoxOffset`) - 拖拽时保持相对位置,防止跳跃 - 分别处理左侧和右侧文本框 **关键代码**: ```typescript // mouseDown时记录偏移 const textBoxOffset: Point2 = [ mouseX - textBoxX, mouseY - textBoxY ]; // mouseDrag时应用偏移 const newTextBoxPosition: Point2 = [ mouseX - textBoxOffset[0], mouseY - textBoxOffset[1] ]; ``` --- ## 六、开发人员使用指南 ### 6.1 工具注册与激活 ```typescript import HipNHAAngleMeasurementTool from './HipNHAAngleMeasurementTool'; import { ToolGroupManager } from '@cornerstonejs/tools'; // 1. 注册工具 cornerstoneTools.addTool(HipNHAAngleMeasurementTool); // 2. 创建工具组 const toolGroupId = 'myToolGroup'; const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); // 3. 添加工具到工具组 toolGroup.addTool(HipNHAAngleMeasurementTool.toolName); // 4. 关联viewport toolGroup.addViewport(viewportId, renderingEngineId); // 5. 激活工具 toolGroup.setToolActive(HipNHAAngleMeasurementTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); // 6. 激活修改模式 const toolInstance = toolGroup.getToolInstance( HipNHAAngleMeasurementTool.toolName ) as HipNHAAngleMeasurementTool; if (toolInstance && element) { toolInstance._activateModify(element); } ``` ### 6.2 创建预设注解 ```typescript // 创建默认注解(8个预设点) const annotation = HipNHAAngleMeasurementTool.createDefaultAnnotation( element, viewport ); // 添加到状态管理 cornerstoneTools.annotation.state.addAnnotation(annotation, element); // 计算统计数据 const enabledElement = cornerstone.getEnabledElement(element); if (enabledElement && toolInstance) { toolInstance._updateCachedStats(annotation, enabledElement); } // 触发渲染 const viewportIdsToRender = cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender( element, HipNHAAngleMeasurementTool.toolName ); cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); ``` ### 6.3 导出注解数据 ```typescript // 获取注解 const annotations = cornerstoneTools.annotation.state.getAnnotations( HipNHAAngleMeasurementTool.toolName, element ); // 导出数据 if (annotations && annotations.length > 0) { const exportedData = HipNHAAngleMeasurementTool.exportAnnotationData( annotations[0], viewport ); console.log('左侧NHA角:', exportedData.angles.angleLeft, '度'); console.log('右侧NHA角:', exportedData.angles.angleRight, '度'); console.log('左侧圆心M1:', exportedData.circles.centerM1); console.log('右侧圆心M2:', exportedData.circles.centerM2); } ``` ### 6.4 恢复注解数据 ```typescript // 从导出数据恢复 const restoredAnnotation = HipNHAAngleMeasurementTool.restoreFromExportedData( exportedData, element, viewport ); // 添加到状态 cornerstoneTools.annotation.state.addAnnotation(restoredAnnotation, element); // 重新计算 if (enabledElement && toolInstance) { toolInstance._updateCachedStats(restoredAnnotation, enabledElement); } // 触发渲染 cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); ``` --- ## 七、与参考工具的对比 ### 7.1 核心差异 | 特性 | CustomAngleMeasurementTool | HipNHAAngleMeasurementTool | |-----|--------------------------|---------------------------| | **点数量** | 8个点(4条线段) | 8个点(2个圆各3点 + 2个切线点) | | **几何元素** | 4条线段 | 2个圆 + 3条线段 | | **计算核心** | 线段交点、夹角 | 圆拟合、切线夹角 | | **渲染元素** | 线段 + 弧线 + 文本 | 圆 + 线段 + 弧线 + 文本 | | **新增算法** | 无 | 圆拟合算法(`fitCircleFrom3Points`) | | **新增绘制** | 无 | 圆形绘制(`drawCircle`) | ### 7.2 复用的技术 ✅ **完全复用**: - 工具基类继承(`AnnotationTool`) - 事件处理机制(`mouseDown/Drag/Up`) - 坐标系统转换(`worldToCanvas`) - 向量运算函数(`vectorSubtract`, `getAngleBetweenLines`) - 文本框拖拽逻辑(`textBoxOffset`机制) - 状态管理(`cachedStats`, `highlighted`) 📝 **修改使用**: - `_updateCachedStats`: 改为计算圆和圆心 - `renderAnnotation`: 新增圆形绘制 - 数据结构:改为存储圆心和半径 --- ## 八、常见问题与解决方案 ### 8.1 圆拟合失败(三点共线) **问题**:当3个点接近共线时,无法拟合圆 **解决**: ```typescript // 检查行列式是否接近0 if (Math.abs(detA) < 1e-10) { console.warn('三点共线,无法拟合圆'); return null; } ``` ### 8.2 手柄点不能拖拽 **原因**:未调用 `_activateModify()` **解决**:在激活工具后调用 ```typescript toolInstance._activateModify(element); ``` ### 8.3 圆形显示位置不对 **原因**:`drawCircle` 需要canvas坐标和像素半径 **解决**: ```typescript // 转换圆心到canvas坐标 const centerCanvas = viewport.worldToCanvas(centerWorld); // 计算像素半径(使用圆上一点) const pointOnCircle = [ centerWorld[0] + radiusWorld, centerWorld[1], centerWorld[2] ]; const pointOnCircleCanvas = viewport.worldToCanvas(pointOnCircle); const radiusPixels = Math.abs(pointOnCircleCanvas[0] - centerCanvas[0]); // 绘制 drawCircle(svgDrawingHelper, annotationUID, 'circle', centerCanvas, radiusPixels, options); ``` ### 8.4 角度计算不准确 **检查清单**: - ✅ 是否使用世界坐标计算 - ✅ 向量方向是否正确 - ✅ `atan2` 参数顺序 - ✅ 弧度转角度公式 --- ## 九、总结 ### 9.1 技术要点 1. **圆拟合算法**:核心数学计算,通过3点求解圆心和半径 2. **实时更新机制**:拖拽时触发 `_updateCachedStats` → 重新计算 → 触发渲染 3. **双文本框管理**:分别处理左右两侧的角度标注 4. **颜色状态切换**:根据 `highlighted` 属性动态改变颜色 5. **坐标系统转换**:世界坐标存储,canvas坐标渲染 ### 9.2 开发流程 ``` 1. 创建工具类(继承AnnotationTool) 2. 实现圆拟合算法(新增) 3. 实现_updateCachedStats(计算圆和角度) 4. 实现renderAnnotation(绘制圆、线、文本) 5. 复用交互逻辑(拖拽、文本框移动) 6. 实现数据导出/恢复 7. 测试和调试 ``` ### 9.3 适用场景 ✅ **适合**: - 髋关节发育评估 - 需要圆形拟合的医学影像测量 - 对称性分析工具 ❌ **不适合**: - 简单的长度/角度测量(用基础工具即可) - 不需要圆形拟合的场景