# VHSMeasurementTool VHS心椎比测量工具实现文档 ## 一、业务需求理解 ### 1.1 测量目的 评估心脏是否存在扩大或心脏异常变化 ### 1.2 点定义与线段 ``` 点1、2 → 胸椎长度线 点3、4 → 心脏长轴 点5、6 → 心脏短轴 约束条件: - 心脏短轴必须垂直于心脏长轴 - 即先确定长轴,短轴在垂直前提下可调整位置 ``` ### 1.3 计算公式 ``` V = 胸椎长度线距离 / 5 = distance(点1, 点2) / 5 = 一节胸椎的长度 L = 心脏长轴长度 / V = distance(点3, 点4) / V = 心脏长轴约等于几节胸椎 S = 心脏短轴长度 / V = distance(点5, 点6) / V = 心脏短轴约等于几节胸椎 L/S = L除以S的比值 VHS = L + S (心椎比指数) ``` ### 1.4 交互规则 - **6个可拖拽控制点**: 实时更新线段和计算值 - **显示值**: VHS, L, S, L/S (黄色标注, 可自由拖动) - **选中状态**: 线段蓝色, 控制点白色圆圈 - **非选中状态**: 线段白色, 控制点白色圆圈 --- ## 二、技术实现方案 (Step by Step) ### 2.1 核心元素映射 #### **从业务到技术的映射** | 业务概念 | 技术实现 | Cornerstone3D元素 | |---------|---------|------------------| | 点1、2 | points[0], points[1] | Point3 世界坐标 | | 点3、4 | points[2], points[3] | Point3 世界坐标 | | 点5、6 | points[4], points[5] | Point3 世界坐标 | | 胸椎长度线 | Line(points[0], points[1]) | drawLine绘制 | | 心脏长轴 | Line(points[2], points[3]) | drawLine绘制 | | 心脏短轴 | Line(points[4], points[5]) | drawLine绘制 | | V, L, S, L/S, VHS | cachedStats对象 | 存储在annotation.data中 | | 黄色文本框 | textBox | drawLinkedTextBox绘制 | ### 2.2 数据结构设计 ```typescript // 注解初始配置接口 interface VHSAnnotationConfig { spineLineLength?: number; // 胸椎长度线的长度(px), 默认200 longAxisLength?: number; // 心脏长轴的长度(px), 默认150 shortAxisLength?: number; // 心脏短轴的长度(px), 默认100 longAxisAngle?: number; // 心脏长轴的角度(度), 默认0(水平) centerOffset?: [number, number]; // 心脏中心相对viewport中心的偏移(px), 默认[0, 50] } // 注解数据接口 interface VHSMeasurementAnnotation extends Types.Annotation { data: { handles: { points: CoreTypes.Point3[]; // 6个点的世界坐标 activeHandleIndex: number | null; textBox?: CoreTypes.Point2; // 文本框位置(canvas坐标) }; cachedStats?: { [targetId: string]: { vertebraUnit: number; // V: 一节胸椎长度 longAxisCount: number; // L: 长轴相当于几节胸椎 shortAxisCount: number; // S: 短轴相当于几节胸椎 ratio: number; // L/S: 长短轴比例 vhs: number; // VHS: L+S spineLineLength: number; // 胸椎线长度 longAxisLength: number; // 长轴长度 shortAxisLength: number; // 短轴长度 }; }; }; } ``` ### 2.3 几何计算步骤 #### **Step 1: 计算线段长度** ```typescript // 使用欧几里得距离公式 function calculateDistance(p1: Point3, p2: Point3): number { const dx = p2[0] - p1[0]; const dy = p2[1] - p1[1]; const dz = p2[2] - p1[2]; return Math.sqrt(dx * dx + dy * dy + dz * dz); } // 胸椎线长度 const spineLineLength = calculateDistance(points[0], points[1]); // 长轴长度 const longAxisLength = calculateDistance(points[2], points[3]); // 短轴长度 const shortAxisLength = calculateDistance(points[4], points[5]); ``` #### **Step 2: 计算一节胸椎长度V** ```typescript const vertebraUnit = spineLineLength / 5; ``` #### **Step 3: 计算L和S** ```typescript const longAxisCount = longAxisLength / vertebraUnit; // L const shortAxisCount = shortAxisLength / vertebraUnit; // S ``` #### **Step 4: 计算L/S和VHS** ```typescript const ratio = longAxisCount / shortAxisCount; // L/S const vhs = longAxisCount + shortAxisCount; // VHS ``` ### 2.4 垂直约束实现 #### **核心概念** 无论拖动长轴还是短轴的点,都需要保持短轴垂直于长轴。短轴分为固定端点和位置端点: - **固定端点**(点5):拖拽时,只能沿垂直于长轴的方向移动,位置端点不动。 - **位置端点**(点6):拖拽时,可以任意移动,固定端点自动调整沿平行于长轴的方向移动,以保持连线垂直。 #### **情况1: 拖动长轴点(点3或点4)** 当拖动长轴端点时,需要实时更新短轴位置以保持垂直关系(与之前相同)。 #### **情况2: 拖动短轴固定端点(点5)** 固定端点只能沿垂直于长轴的方向移动,位置端点保持不动: ```typescript // 计算长轴方向向量和垂直向量 const longAxisVector = vectorSubtract(points[3], points[2]); const perpendicularVector: Point3 = [ -longAxisVector[1], longAxisVector[0], 0 ]; // 计算拖拽向量在垂直方向上的投影 const dragVector = vectorSubtract(newPosition, points[5]); const projection = dragVector[0] * perpendicularVector[0] + dragVector[1] * perpendicularVector[1]; // 更新固定端点位置 points[5] = vectorAdd( points[5], vectorScale(perpendicularVector, projection) ); ``` **关键点**: - 位置端点(点6)保持不动。 - 固定端点(点5)只在垂直方向移动。 #### **情况3: 拖动短轴位置端点(点6)** 位置端点可以任意移动,固定端点自动调整沿平行于长轴的方向移动,以保持连线垂直: 核心思路: 1. 位置端点有旧位置和新位置,可以计算出移动向量 2. 知道长轴的向量 3. 计算移动向量在长轴向量上的投影向量,即位置端点沿着长轴移动的方向和距离--长轴位移向量 4. 把固定端点沿着“长轴位移向量”移动 **关键点**: - 固定端点(点5)沿长轴方向移动。 - 确保连线垂直于长轴。 ### 2.5 初始点位置计算 ```typescript static createDefaultAnnotation( element: HTMLDivElement, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport, config?: VHSAnnotationConfig ): VHSMeasurementAnnotation { // 获取viewport中心 const canvas = viewport.canvas; const { width, height } = canvas; const centerX = width / 2; const centerY = height / 2; // 应用配置或默认值 const spineLineLength = config?.spineLineLength ?? 200; const longAxisLength = config?.longAxisLength ?? 150; const shortAxisLength = config?.shortAxisLength ?? 100; const longAxisAngle = config?.longAxisAngle ?? 0; // 度数 const centerOffset = config?.centerOffset ?? [0, 50]; // 胸椎线: 竖直线,在中心左侧 const spineX = centerX - 150; const spineY = centerY; const canvasPoints: CoreTypes.Point2[] = [ // 点1、2: 胸椎线(竖直) [spineX, spineY - spineLineLength / 2], [spineX, spineY + spineLineLength / 2], // 点3、4: 心脏长轴(可配置角度) // 心脏中心 const heartCenterX = centerX + centerOffset[0]; const heartCenterY = centerY + centerOffset[1]; const angleRad = (longAxisAngle * Math.PI) / 180; [ heartCenterX - (longAxisLength / 2) * Math.cos(angleRad), heartCenterY - (longAxisLength / 2) * Math.sin(angleRad) ], [ heartCenterX + (longAxisLength / 2) * Math.cos(angleRad), heartCenterY + (longAxisLength / 2) * Math.sin(angleRad) ], // 点5、6: 心脏短轴(垂直于长轴) const perpAngleRad = angleRad + Math.PI / 2; // 垂直角度 [ heartCenterX - (shortAxisLength / 2) * Math.cos(perpAngleRad), heartCenterY - (shortAxisLength / 2) * Math.sin(perpAngleRad) ], [ heartCenterX + (shortAxisLength / 2) * Math.cos(perpAngleRad), heartCenterY + (shortAxisLength / 2) * Math.sin(perpAngleRad) ], ]; // 转换为世界坐标 const worldPoints = canvasPoints.map(p => viewport.canvasToWorld(p)); // ... 创建annotation对象 } ``` ### 2.6 渲染实现 ```typescript renderAnnotation = ( enabledElement: CoreTypes.IEnabledElement, svgDrawingHelper: SVGDrawingHelper ): boolean => { // 获取注解 const annotations = annotation.state.getAnnotations( this.getToolName(), element ); for (const ann of annotations) { const { points } = ann.data.handles; const canvasPoints = points.map(p => viewport.worldToCanvas(p)); // 确定线段颜色(选中蓝色,未选中白色) const lineColor = ann.highlighted ? 'rgb(0, 120, 255)' : 'rgb(255, 255, 255)'; // 绘制胸椎长度线(点1-2) drawLineSvg( svgDrawingHelper, annotationUID, 'spine-line', canvasPoints[0], canvasPoints[1], { color: lineColor, width: 2 } ); // 绘制心脏长轴(点3-4) drawLineSvg( svgDrawingHelper, annotationUID, 'long-axis', canvasPoints[2], canvasPoints[3], { color: lineColor, width: 2 } ); // 绘制心脏短轴(点5-6) drawLineSvg( svgDrawingHelper, annotationUID, 'short-axis', canvasPoints[4], canvasPoints[5], { color: lineColor, width: 2 } ); // 绘制手柄点 drawHandles( svgDrawingHelper, annotationUID, 'handles', canvasPoints, { color: 'rgb(255, 255, 255)', handleRadius: 6 } ); // 绘制文本框(黄色) const stats = ann.data.cachedStats?.[targetId]; if (stats) { const textLines = [ `VHS: ${stats.vhs.toFixed(2)}`, `L: ${stats.longAxisCount.toFixed(2)}`, `S: ${stats.shortAxisCount.toFixed(2)}`, `L/S: ${stats.ratio.toFixed(2)}` ]; // 文本框默认位置在长轴右侧 const defaultTextBoxPosition: CoreTypes.Point2 = ann.data.handles.textBox || [ canvasPoints[3][0] + 20, canvasPoints[3][1] ]; drawLinkedTextBox( svgDrawingHelper, annotationUID, 'text-box', textLines, defaultTextBoxPosition, [canvasPoints[3]], // 连接到长轴端点 {}, { color: 'rgb(255, 255, 0)' } // 黄色 ); } } } ``` --- ## 三、完整实现清单 ### 3.1 需要实现的核心方法 | 方法名 | 功能 | 关键点 | |-------|------|--------| | `createDefaultAnnotation()` | 创建预设的6点注解 | 计算初始点位置,应用配置 | | `_updateCachedStats()` | 计算并缓存统计数据 | 计算V, L, S, L/S, VHS | | `renderAnnotation()` | 渲染注解 | 绘制3条线段、6个点、文本框 | | `_mouseDownModifyCallback()` | 处理鼠标按下 | 检测点击手柄或文本框 | | `_mouseDragModifyCallback()` | 处理鼠标拖拽 | 更新点位置,应用垂直约束 | | `_mouseUpModifyCallback()` | 处理鼠标释放 | 清理状态 | | `_applyPerpendicularConstraint()` | 应用垂直约束 | 当拖动短轴点时保持垂直 | | `exportAnnotationData()` | 导出数据 | 导出点坐标和计算值 | | `restoreFromExportedData()` | 恢复数据 | 从导出数据重建注解 | ### 3.2 依赖的工具函数 (mathUtils.ts) ```typescript - calculateMidpoint(p1, p2) // 计算中点 - vectorSubtract(p1, p2) // 向量减法 - vectorAdd(p1, v) // 向量加法 - vectorScale(v, scalar) // 向量缩放 - calculateDistance(p1, p2) // 计算距离(需要新增) ``` ### 3.3 导出数据结构 ```typescript export interface VHSExportedData { points: Point3[]; // 6个点的世界坐标 measurements: { vertebraUnit: number; // V值 longAxisCount: number; // L值 shortAxisCount: number; // S值 ratio: number; // L/S值 vhs: number; // VHS值 spineLineLength: number; // 胸椎线长度 longAxisLength: number; // 长轴长度 shortAxisLength: number; // 短轴长度 }; textBoxPosition?: Point2; // 文本框位置 metadata: { viewPlaneNormal: Point3; viewUp: Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } ``` --- ## 四、实现难点与解决方案 ### 4.1 垂直约束的实时维护 **难点**: 当拖动任意短轴点时,如何保持短轴垂直于长轴? **解决方案**: 1. 监听短轴点(点5或点6)的拖拽事件 2. 计算当前长轴的方向向量 3. 计算垂直向量 4. 根据拖拽的新位置,重新计算短轴两个点的位置 5. 保持短轴长度不变,只改变方向(沿垂直方向) **实现代码框架**: ```typescript _mouseDragModifyCallback = (evt) => { const activeHandleIndex = this.editData.handleIndex; // 如果拖动的是短轴点(索引4或5) if (activeHandleIndex === 4 || activeHandleIndex === 5) { // 应用垂直约束 this._applyPerpendicularConstraint(annotation); } else { // 其他点正常拖拽 points[activeHandleIndex] = worldPos; } // 重新计算统计数据 this._updateCachedStats(annotation, enabledElement); } ``` ### 4.2 文本框拖拽防跳跃 **解决方案**: 使用与CustomAngleMeasurementTool相同的textBoxOffset机制 ```typescript // mouseDown时记录偏移 const textBoxOffset = [ canvasCoords[0] - textBox[0], canvasCoords[1] - textBox[1] ]; // mouseDrag时应用偏移 const newTextBoxPosition = [ canvasCoords[0] - textBoxOffset[0], canvasCoords[1] - textBoxOffset[1] ]; ``` ### 4.3 除以零的处理 **问题**: 当胸椎线长度为0时,V=0,会导致除以零错误 **解决方案**: ```typescript const vertebraUnit = spineLineLength / 5; if (vertebraUnit === 0 || !isFinite(vertebraUnit)) { // 无效数据,不计算L, S, VHS stats.vertebraUnit = 0; stats.longAxisCount = 0; stats.shortAxisCount = 0; stats.ratio = 0; stats.vhs = 0; return; } const longAxisCount = longAxisLength / vertebraUnit; const shortAxisCount = shortAxisLength / vertebraUnit; const ratio = shortAxisCount === 0 ? 0 : longAxisCount / shortAxisCount; ``` --- ## 五、使用示例 ### 5.1 创建并激活工具 ```typescript // 1. 注册工具 cornerstoneTools.addTool(VHSMeasurementTool); // 2. 添加到工具组 const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); toolGroup.addTool(VHSMeasurementTool.toolName); // 3. 激活工具 toolGroup.setToolActive(VHSMeasurementTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); // 4. 激活修改模式 const toolInstance = toolGroup.getToolInstance( VHSMeasurementTool.toolName ) as VHSMeasurementTool; if (toolInstance && elementRef.current) { toolInstance._activateModify(elementRef.current); } // 5. 创建预设注解 const viewport = renderingEngine.getViewport(viewportId); const annotation = VHSMeasurementTool.createDefaultAnnotation( elementRef.current, viewport, { spineLineLength: 200, longAxisLength: 150, shortAxisLength: 100, longAxisAngle: 0, centerOffset: [0, 50] } ); // 6. 添加并渲染 cornerstoneTools.annotation.state.addAnnotation(annotation, elementRef.current); toolInstance._updateCachedStats(annotation, enabledElement); cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); ``` ### 5.2 导出测量结果 ```typescript const annotations = cornerstoneTools.annotation.state.getAnnotations( VHSMeasurementTool.toolName, elementRef.current ); if (annotations && annotations.length > 0) { const exportedData = VHSMeasurementTool.exportAnnotationData( annotations[0], viewport ); console.log('VHS:', exportedData.measurements.vhs); console.log('L:', exportedData.measurements.longAxisCount); console.log('S:', exportedData.measurements.shortAxisCount); console.log('L/S:', exportedData.measurements.ratio); } ``` --- ## 六、总结 ### 6.1 核心技术点 1. **6点3线的几何结构**: 胸椎线、长轴、短轴 2. **垂直约束**: 短轴始终垂直于长轴 3. **比例计算**: V, L, S, L/S, VHS的实时计算 4. **交互体验**: 拖拽点、拖拽文本框 5. **视觉反馈**: 选中/非选中状态的颜色变化 6. **数据持久化**: 导出/恢复机制 ### 6.2 与CustomAngleMeasurementTool的区别 | 特性 | CustomAngleMeasurementTool | VHSMeasurementTool | |-----|---------------------------|-------------------| | 点数量 | 8个点 | 6个点 | | 线段数 | 4条线(AB、中点线、CD) | 3条线(胸椎、长轴、短轴) | | 几何约束 | 无 | 短轴垂直于长轴 | | 计算内容 | 角度 | 距离比例(V, L, S, VHS) | | 文本显示 | 2个角度文本框 | 1个多行文本框(4个值) | ### 6.3 实现步骤总结 1. ✅ 分析业务需求,识别核心元素 2. ✅ 设计数据结构和接口 3. ⏭️ 实现createDefaultAnnotation方法 4. ⏭️ 实现_updateCachedStats计算逻辑 5. ⏭️ 实现renderAnnotation绘制逻辑 6. ⏭️ 实现鼠标事件处理(拖拽、垂直约束) 7. ⏭️ 实现导出/恢复功能 8. ⏭️ 在页面添加按钮集成 9. ⏭️ 测试验证功能