HipNHAAngleMeasurementTool.README.md 16 KB

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

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)

实现

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 线性方程组求解(克拉默法则)

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 角度计算

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
  • 拖拽时保持相对位置,防止跳跃
  • 分别处理左侧和右侧文本框

关键代码

// mouseDown时记录偏移
const textBoxOffset: Point2 = [
  mouseX - textBoxX,
  mouseY - textBoxY
];

// mouseDrag时应用偏移
const newTextBoxPosition: Point2 = [
  mouseX - textBoxOffset[0],
  mouseY - textBoxOffset[1]
];

六、开发人员使用指南

6.1 工具注册与激活

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 创建预设注解

// 创建默认注解(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 导出注解数据

// 获取注解
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 恢复注解数据

// 从导出数据恢复
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个点接近共线时,无法拟合圆

解决

// 检查行列式是否接近0
if (Math.abs(detA) < 1e-10) {
  console.warn('三点共线,无法拟合圆');
  return null;
}

8.2 手柄点不能拖拽

原因:未调用 _activateModify()

解决:在激活工具后调用

toolInstance._activateModify(element);

8.3 圆形显示位置不对

原因drawCircle 需要canvas坐标和像素半径

解决

// 转换圆心到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 适用场景

适合

  • 髋关节发育评估
  • 需要圆形拟合的医学影像测量
  • 对称性分析工具

不适合

  • 简单的长度/角度测量(用基础工具即可)
  • 不需要圆形拟合的场景