TTAMeasurementTool.README.md 18 KB

TTAMeasurementTool 胫骨结节前移术测量工具文档

一、业务需求理解

1.1 测量目的

在术前确定胫骨结节应前移的距离(TTA Distance),以优化膝关节稳定性并指导手术规划。

1.2 点定义

点编号 作用 分组
1, 2, 3 确定胫骨平台圆心 胫骨平台圆
4, 5, 6 确定股骨头一侧髁骨圆心 髁骨圆1
7, 8, 9 确定股骨头另一侧髁骨圆心 髁骨圆2
10 标记膝盖骨外侧与韧带的交叉点 参考点
11 标记胫骨粗隆的冠状边缘 测量终点

1.3 TTA距离计算步骤

  1. 拟合圆1: 通过点(1, 2, 3)拟合胫骨平台圆,得到圆心M1
  2. 拟合圆2: 通过点(4, 5, 6)拟合一侧髁骨圆,得到圆心M2
  3. 拟合圆3: 通过点(7, 8, 9)拟合另一侧髁骨圆,得到圆心M3
  4. 计算中点M: 连接M2M3,得到其中点M
  5. 建立参考线: 连接MM1,形成参考线方向
  6. 作平行线L: 在点10处作一条与MM1平行的直线L
  7. 计算TTA: TTA距离 = 点11到直线L的垂直距离

二、技术实现方案

2.1 核心技术元素映射

2.1.1 点的抽象

  • 总共11个点: 存储在handles.points数组中
  • 点的分组:
    • 索引0-2: 胫骨平台圆的3个点
    • 索引3-5: 髁骨圆1的3个点
    • 索引6-8: 髁骨圆2的3个点
    • 索引9: 参考点(点10)
    • 索引10: 测量终点(点11)

2.1.2 圆的抽象

  • 圆的表示: {center: Point3, radius: number}
  • 圆的拟合: 使用fitCircleFrom3Points(p1, p2, p3)函数
  • 需要拟合的圆:
    • 胫骨平台圆: fitCircleFrom3Points(point[0], point[1], point[2])
    • 髁骨圆1: fitCircleFrom3Points(point[3], point[4], point[5])
    • 髁骨圆2: fitCircleFrom3Points(point[6], point[7], point[8])

2.1.3 线的抽象

  • 线段的表示: 起点 + 方向向量
  • M1M2线段:
    • 起点: M1(胫骨平台圆圆心)
    • 终点: M2(髁骨圆1圆心)
  • MM1线段(参考线):
    • M = M1和M2的中点
    • 方向向量 = M1 - M
  • 平行线L:
    • 起点: 点10
    • 方向向量: 与MM1相同

2.1.4 距离计算

  • 点到直线的距离公式:

    设直线L: 起点P0, 方向向量D (单位向量)
    设点P
      
    向量 AP = P - P0
    距离 = |AP × D| / |D|
      
    对于2D情况(z=0):
    距离 = |AP_x * D_y - AP_y * D_x| / sqrt(D_x² + D_y²)
    

2.2 需要的数学函数

2.2.1 已有函数(mathUtils.ts)

  • fitCircleFrom3Points(p1, p2, p3) - 圆拟合
  • calculateMidpoint(p1, p2) - 中点计算
  • vectorSubtract(v1, v2) - 向量减法
  • calculateDistance(p1, p2) - 欧几里得距离

2.2.2 需要新增函数

  • pointToLineDistance(point, lineStart, lineDirection) - 点到直线距离
  • normalizeVector(v) - 向量归一化

三、Cornerstone3D API使用

3.1 核心API映射

功能 Cornerstone3D API 用途
坐标转换 viewport.worldToCanvas(point) 世界坐标→画布坐标(渲染用)
坐标转换 viewport.canvasToWorld(point) 画布坐标→世界坐标(存储用)
圆的绘制 drawCircle(svgHelper, uid, center, radius, options) 绘制拟合的圆
线段绘制 drawLine(svgHelper, uid, lineId, start, end, options) 绘制M1M2、MM1、平行线L
点的绘制 drawHandles(svgHelper, uid, groupId, points, options) 绘制11个可拖拽手柄
文本绘制 drawLinkedTextBox(...) 显示TTA距离值
实线绘制 默认样式 平行线L和垂线都使用实线

3.2 数据结构设计

interface TTAMeasurementAnnotation extends Types.Annotation {
  data: {
    handles: {
      points: Point3[];  // 11个点
      activeHandleIndex: number | null;
      textBox?: Point2;  // TTA距离文本框位置
    };
    cachedStats?: {
      [targetId: string]: {
        // 三个圆的信息
        tibialCircle?: { center: Point3; radius: number };     // M1
        condyleCircle1?: { center: Point3; radius: number };   // M2
        condyleCircle2?: { center: Point3; radius: number };   // M3
        
        // 关键点
        midPoint?: Point3;           // M (M1M2的中点)
        
        // 参考线方向
        referenceLine?: {
          start: Point3;             // MM1的起点(M)
          direction: Point3;         // MM1的方向向量
        };
        
        // 平行线L
        parallelLine?: {
          start: Point3;             // 点10
          direction: Point3;         // 与MM1相同的方向
        };
        
        // TTA距离
        ttaDistance?: number;        // 点11到平行线L的距离
      };
    };
  };
}

四、可视化设计

4.1 颜色方案

元素 颜色 样式 说明
点1-3 红色 实心圆点 胫骨平台圆的点
点4-6 蓝色 实心圆点 髁骨圆1的点
点7-9 绿色 实心圆点 髁骨圆2的点
点10 黄色 实心圆点 参考点
点11 紫色 实心圆点 测量终点
胫骨平台圆 红色 虚线 拟合圆1
髁骨圆1 蓝色 虚线 拟合圆2
髁骨圆2 绿色 虚线 拟合圆3
M1M2线段 橙色 实线 连接两个圆心
MM1线段 橙色 实线 参考线
平行线L 黄色 实线 通过点10的平行线
垂直线 紫色 实线 点11到平行线L的垂线
圆心标记 白色 小十字 M1, M2, M3
TTA距离文本 白色 带背景 显示数值

4.2 渲染顺序

  1. 圆的渲染: 绘制3个拟合圆(虚线)
  2. 圆心标记: 绘制M1, M2, M3的十字标记
  3. 线段渲染:
    • M1M2线段(橙色实线)
    • 中点M的标记
    • MM1线段(橙色实线)
  4. 平行线L: 通过点10的平行线(黄色实线)
  5. 测量线段: 点10到垂足的实线(黄色,突出显示测量线段)
  6. 垂直线: 点11到垂足的垂线(紫色实线)
  7. 手柄点: 11个可拖拽的点
  8. 文本: TTA距离值

4.3 TTA测量几何关系绘制思路

绘制逻辑分析

在TTA测量中,核心是计算点11到平行线L的垂直距离。为了更清楚地展示测量几何关系,我们需要绘制:

  1. 平行线L: 通过点10,与MM1方向平行的参考线
  2. 垂足计算: 点11到平行线L的垂线与平行线L的交点
  3. 测量线段: 从点10到垂足的实线,表示测量基准线段
  4. 垂直线: 从点11到垂足的实线,表示垂直距离

具体实现步骤

// 1. 计算垂足(交点)
const AP = vectorSubtract(points[10], point10);  // 向量 P10->P11
const dotProduct = AP[0] * lineDirection[0] + AP[1] * lineDirection[1];
const directionMagSq = lineDirection[0]**2 + lineDirection[1]**2;
const t = dotProduct / directionMagSq;

// 垂足坐标 = 点10 + t * 方向向量
const footPoint = [
  point10[0] + t * lineDirection[0],
  point10[1] + t * lineDirection[1],
  point10[2]
];

// 2. 绘制测量线段(点10到垂足)
drawLineSvg(svgDrawingHelper, annotationUID, 'measurementLine',
  point10Canvas, footPointCanvas,
  { color: colors.point10, width: 3 }  // 黄色实线,较粗
);

// 3. 绘制垂直线(点11到垂足)
drawLineSvg(svgDrawingHelper, annotationUID, 'perpLine',
  point11Canvas, footPointCanvas,
  { color: colors.perpLine, width: 2 }  // 紫色实线
);

视觉效果说明

  • 平行线L: 黄色实线,作为测量基准线
  • 测量线段: 黄色实线,线宽3,突出显示从点10到垂足的距离
  • 垂直线: 紫色实线,线宽2,显示点11到垂足的垂直距离

这样的绘制方式清晰地展示了:

  1. 平行线L是测量基准
  2. 垂足是垂直线的交点
  3. 点10到垂足的线段是测量线段
  4. 点11到垂足的线段是垂直距离

交互意义

  • 拖拽点10:改变平行线L的位置
  • 拖拽点11:改变垂足位置,实时更新TTA距离
  • 垂线始终垂直于平行线L
  • 测量线段实时反映当前的测量基准

平行线L几何关系修正

问题: 最初的实现中,平行线L被绘制为无限长的直线,但根据TTA测量几何关系,平行线L应该是从点10到垂足的线段。

几何关系: 平行线L是通过点10,与MM1方向平行的直线。垂足是点11到平行线L的垂线与平行线L的交点。因此平行线L应该只绘制从点10到垂足的部分。

修复: 修改绘制逻辑,让平行线L只绘制从点10到垂足的线段。

// 修正后的代码:平行线L从点10到垂足
if (cachedStats?.parallelLine && points.length >= 11 && annotationUID) {
  const point10Canvas = canvasPoints[9];
  const point10 = points[9];
  const lineDirection = cachedStats.parallelLine.direction;

  // 计算垂足
  const AP = vectorSubtract(points[10], point10);
  const dotProduct = AP[0] * lineDirection[0] + AP[1] * lineDirection[1];
  const directionMagSq = lineDirection[0] * lineDirection[0] + lineDirection[1] * lineDirection[1];
  const t = dotProduct / directionMagSq;

  const footPoint = [
    point10[0] + t * lineDirection[0],
    point10[1] + t * lineDirection[1],
    point10[2]
  ] as CoreTypes.Point3;
  const footPointCanvas = viewport.worldToCanvas(footPoint);

  // 绘制从点10到垂足的平行线L
  drawLineSvg(svgDrawingHelper, annotationUID, 'parallelLine',
    point10Canvas, footPointCanvas,
    { color: colors.parallelLine, width: 2 }
  );
}

五、计算流程详解

5.1 _updateCachedStats 核心逻辑

_updateCachedStats(annotation, enabledElement) {
  const points = annotation.data.handles.points;
  
  // 步骤1: 拟合三个圆
  const tibialCircle = fitCircleFrom3Points(points[0], points[1], points[2]);  // M1
  const condyleCircle1 = fitCircleFrom3Points(points[3], points[4], points[5]); // M2
  const condyleCircle2 = fitCircleFrom3Points(points[6], points[7], points[8]); // M3
  
  if (!tibialCircle || !condyleCircle1 || !condyleCircle2) {
    // 圆拟合失败,退出
    return;
  }
  
  const M1 = tibialCircle.center;
  const M2 = condyleCircle1.center;
  const M3 = condyleCircle2.center;
  
  // 步骤2: 计算M1M2的中点M
  const M = calculateMidpoint(M1, M2);
  
  // 步骤3: 计算MM1的方向向量
  const MM1_direction = vectorSubtract(M1, M);
  
  // 步骤4: 构建平行线L (通过点10, 方向与MM1相同)
  const point10 = points[9];
  const parallelLine = {
    start: point10,
    direction: MM1_direction  // 与MM1平行
  };
  
  // 步骤5: 计算点11到平行线L的距离 (TTA)
  const point11 = points[10];
  const ttaDistance = pointToLineDistance(point11, point10, MM1_direction);
  
  // 保存到cachedStats
  stats.tibialCircle = tibialCircle;
  stats.condyleCircle1 = condyleCircle1;
  stats.condyleCircle2 = condyleCircle2;
  stats.midPoint = M;
  stats.referenceLine = { start: M, direction: MM1_direction };
  stats.parallelLine = parallelLine;
  stats.ttaDistance = ttaDistance;
}

5.2 点到直线距离计算

// 需要新增到mathUtils.ts
function pointToLineDistance(
  point: Point3, 
  lineStart: Point3, 
  lineDirection: Point3
): number {
  // 向量AP = point - lineStart
  const AP = vectorSubtract(point, lineStart);
  
  // 叉积的z分量 = AP_x * D_y - AP_y * D_x
  const crossProduct = AP[0] * lineDirection[1] - AP[1] * lineDirection[0];
  
  // 方向向量的模
  const directionMagnitude = Math.sqrt(
    lineDirection[0] * lineDirection[0] + 
    lineDirection[1] * lineDirection[1]
  );
  
  // 距离 = |叉积| / |方向向量|
  return Math.abs(crossProduct) / directionMagnitude;
}

六、初始化配置

6.1 默认布局配置

interface TTAAnnotationInitialConfig {
  // 三个圆的布局
  tibialCircleConfig?: {
    centerX: number;      // 胫骨平台圆中心X
    centerY: number;      // 胫骨平台圆中心Y
    radius: number;       // 半径
  };
  
  condyleCircle1Config?: {
    centerX: number;      // 髁骨圆1中心X
    centerY: number;      // 髁骨圆1中心Y
    radius: number;       // 半径
  };
  
  condyleCircle2Config?: {
    centerX: number;      // 髁骨圆2中心X
    centerY: number;      // 髁骨圆2中心Y
    radius: number;       // 半径
  };
  
  // 点10和点11的位置
  point10Offset?: { x: number; y: number };  // 相对于viewport中心
  point11Offset?: { x: number; y: number };  // 相对于viewport中心
}

6.2 默认值

const defaultConfig = {
  tibialCircleConfig: {
    centerX: centerX - 100,   // 左侧
    centerY: centerY,
    radius: 80
  },
  condyleCircle1Config: {
    centerX: centerX + 50,    // 右上
    centerY: centerY - 80,
    radius: 60
  },
  condyleCircle2Config: {
    centerX: centerX + 50,    // 右下
    centerY: centerY + 80,
    radius: 60
  },
  point10Offset: { x: -50, y: 100 },   // 点10在左下
  point11Offset: { x: -50, y: 150 }    // 点11在点10下方
};

6.3 createDefaultAnnotation 实现

static createDefaultAnnotation(element, viewport, config?) {
  const canvas = viewport.canvas;
  const { width, height } = canvas;
  const centerX = width / 2;
  const centerY = height / 2;
  
  // 应用默认值
  const tibialConfig = config?.tibialCircleConfig ?? defaultConfig.tibialCircleConfig;
  const condyle1Config = config?.condyleCircle1Config ?? defaultConfig.condyleCircle1Config;
  const condyle2Config = config?.condyleCircle2Config ?? defaultConfig.condyleCircle2Config;
  
  // 生成圆上的3个点(每个圆120度间隔)
  const generateCirclePoints = (centerX, centerY, radius) => {
    const angles = [0, 120, 240];  // 度数
    return angles.map(deg => {
      const rad = (deg * Math.PI) / 180;
      return [
        centerX + radius * Math.cos(rad),
        centerY + radius * Math.sin(rad)
      ];
    });
  };
  
  // 生成胫骨平台圆的3个点 (点1,2,3)
  const tibialPoints = generateCirclePoints(
    tibialConfig.centerX, 
    tibialConfig.centerY, 
    tibialConfig.radius
  );
  
  // 生成髁骨圆1的3个点 (点4,5,6)
  const condyle1Points = generateCirclePoints(
    condyle1Config.centerX,
    condyle1Config.centerY,
    condyle1Config.radius
  );
  
  // 生成髁骨圆2的3个点 (点7,8,9)
  const condyle2Points = generateCirclePoints(
    condyle2Config.centerX,
    condyle2Config.centerY,
    condyle2Config.radius
  );
  
  // 点10和点11
  const point10 = [
    centerX + (config?.point10Offset?.x ?? -50),
    centerY + (config?.point10Offset?.y ?? 100)
  ];
  
  const point11 = [
    centerX + (config?.point11Offset?.x ?? -50),
    centerY + (config?.point11Offset?.y ?? 150)
  ];
  
  // 合并所有canvas坐标点
  const canvasPoints = [
    ...tibialPoints,    // 0-2
    ...condyle1Points,  // 3-5
    ...condyle2Points,  // 6-8
    point10,            // 9
    point11             // 10
  ];
  
  // 转换为世界坐标
  const worldPoints = canvasPoints.map(p => viewport.canvasToWorld(p));
  
  // 创建注解数据...
  return annotationData;
}

七、交互功能

7.1 手柄拖拽

  • 11个手柄点都可以独立拖拽
  • 拖拽任意点后,实时重新计算:
    1. 重新拟合相关的圆
    2. 重新计算M点
    3. 重新计算参考线和平行线
    4. 重新计算TTA距离

7.2 文本框拖拽

  • TTA距离文本框可以拖拽到任意位置
  • 使用textBoxOffset防止拖拽跳跃

八、导出数据结构

export interface ExportedTTAData {
  points: Point3[];                    // 11个点的世界坐标
  circles: {
    tibialCircle: {                    // 胫骨平台圆
      center: Point3;
      radius: number;
    };
    condyleCircle1: {                  // 髁骨圆1
      center: Point3;
      radius: number;
    };
    condyleCircle2: {                  // 髁骨圆2
      center: Point3;
      radius: number;
    };
  };
  measurements: {
    midPoint: Point3;                  // M点
    ttaDistance: number;               // TTA距离值
  };
  textBoxPosition?: Point2;            // 文本框位置
  metadata: {
    viewPlaneNormal: Point3;
    viewUp: Point3;
    FrameOfReferenceUID: string;
    referencedImageId: string;
  };
}

九、开发清单

9.1 需要在mathUtils.ts新增的函数

  • pointToLineDistance(point, lineStart, lineDirection) - 点到直线距离
  • normalizeVector(v) - 向量归一化(可选,用于方向向量标准化)

9.2 TTAMeasurementTool类需要实现的方法

  • createDefaultAnnotation() - 创建预设的11点注解
  • _updateCachedStats() - 计算圆心、TTA距离
  • renderAnnotation() - 渲染圆、线、点、文本
  • _activateModify() / _deactivateModify() - 编辑模式
  • exportAnnotationData() - 导出数据
  • restoreFromExportedData() - 恢复数据
  • 鼠标事件处理(拖拽手柄、文本框)

9.3 页面集成

  • 在stack.image.viewer.tsx注册工具
  • 添加"激活TTA测量工具"按钮
  • 实现工具激活/停用逻辑

十、关键实现要点

  1. 圆拟合验证: 如果三点共线,fitCircleFrom3Points会返回null,需要处理这种情况
  2. 方向向量处理: MM1方向向量不需要归一化,因为点到直线距离公式会自动处理
  3. 坐标系统: 所有计算使用世界坐标,渲染时转换为canvas坐标
  4. 实时更新: 任意点拖拽都会触发_updateCachedStats,重新计算所有几何元素
  5. 可视化反馈: 用不同颜色区分不同元素,便于医生理解测量结果

十一、使用示例

```typescript // 1. 激活工具并创建预设注解 const annotation = TTAMeasurementTool.createDefaultAnnotation( element, viewport );

// 2. 添加到状态 cornerstoneTools.annotation.state.addAnnotation(annotation, element);

// 3. 计算统计数据 toolInstance._updateCachedStats(annotation, enabledElement);

// 4. 触发渲染 cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(viewportIds);

// 5. 导出数据 const exportedData = TTAMeasurementTool.exportAnnotationData(annotation, viewport); console.log('TTA距离:', exportedData.measurements.ttaDistance, 'mm');