VHSMeasurementTool.README.md 17 KB

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 数据结构设计

// 注解初始配置接口
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: 计算线段长度

// 使用欧几里得距离公式
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

const vertebraUnit = spineLineLength / 5;

Step 3: 计算L和S

const longAxisCount = longAxisLength / vertebraUnit;   // L
const shortAxisCount = shortAxisLength / vertebraUnit; // S

Step 4: 计算L/S和VHS

const ratio = longAxisCount / shortAxisCount;  // L/S
const vhs = longAxisCount + shortAxisCount;    // VHS

2.4 垂直约束实现

核心概念

无论拖动长轴还是短轴的点,都需要保持短轴垂直于长轴。短轴分为固定端点和位置端点:

  • 固定端点(点5):拖拽时,只能沿垂直于长轴的方向移动,位置端点不动。
  • 位置端点(点6):拖拽时,可以任意移动,固定端点自动调整沿平行于长轴的方向移动,以保持连线垂直。

情况1: 拖动长轴点(点3或点4)

当拖动长轴端点时,需要实时更新短轴位置以保持垂直关系(与之前相同)。

情况2: 拖动短轴固定端点(点5)

固定端点只能沿垂直于长轴的方向移动,位置端点保持不动:

// 计算长轴方向向量和垂直向量
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 初始点位置计算

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 渲染实现

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)

- calculateMidpoint(p1, p2)      // 计算中点
- vectorSubtract(p1, p2)         // 向量减法
- vectorAdd(p1, v)               // 向量加法
- vectorScale(v, scalar)         // 向量缩放
- calculateDistance(p1, p2)      // 计算距离(需要新增)

3.3 导出数据结构

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. 保持短轴长度不变,只改变方向(沿垂直方向)

实现代码框架:

_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机制

// 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,会导致除以零错误

解决方案:

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 创建并激活工具

// 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 导出测量结果

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. ⏭️ 测试验证功能