用于评估髋关节的发育对称性与髋臼覆盖情况,帮助判断是否存在髋关节发育不良。
交互点定义(共8个可拖动点):
圆拟合公式:
x² + y² + Dx + Ey + F = 0
三个点可求解圆心和半径。
计算方法:
显示规则:
| 业务元素 | 技术实现 | 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 |
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切线向量
};
};
};
}
| API | 用途 |
|---|---|
getEnabledElement(element) |
获取启用的element及其viewport |
viewport.worldToCanvas(point) |
将世界坐标转换为canvas坐标(用于渲染) |
viewport.canvasToWorld(point) |
将canvas坐标转换为世界坐标(用于存储) |
viewport.getCamera() |
获取相机参数(viewPlaneNormal, viewUp) |
viewport.getCurrentImageId() |
获取当前图像ID |
viewport.getFrameOfReferenceUID() |
获取参考坐标系UID |
| API/模块 | 用途 |
|---|---|
annotation.state.addAnnotation() |
将注解添加到状态管理 |
annotation.state.getAnnotations() |
获取指定工具的所有注解 |
utilities.triggerAnnotationRenderForViewportIds() |
触发指定viewport的渲染更新 |
drawing.drawLine() |
绘制线段 |
drawing.drawCircle() |
绘制圆形 |
drawing.drawPath() |
绘制路径(用于弧线) |
drawing.drawHandles() |
绘制可拖拽的手柄点 |
drawing.drawLinkedTextBox() |
绘制连接到点的文本框 |
目标:通过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
};
}
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];
}
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));
}
1. 获取注解数据和缓存统计
2. 转换所有点为canvas坐标
3. 绘制8个手柄点(白色圆点)
4. 绘制左侧股骨头圆(实线圆)
5. 绘制右侧股骨头圆(实线圆)
6. 绘制基准线 M1-M2(实线)
7. 绘制左侧切线 M1-point4(实线)
8. 绘制右侧切线 M2-point8(实线)
9. 绘制左侧角度弧线(虚线)
10. 绘制右侧角度弧线(虚线)
11. 绘制左侧角度文本框(可拖动)
12. 绘制右侧角度文本框(可拖动)
| 状态 | 圆和线段颜色 | 手柄点颜色 |
|---|---|---|
| 选中(highlighted) | rgb(0, 120, 255) 蓝色 |
rgb(255, 255, 255) 白色 |
| 未选中 | rgb(255, 255, 255) 白色 |
rgb(255, 255, 255) 白色 |
| 元素 | 样式 |
|---|---|
| 股骨头圆 | 实线,宽度2px |
| 基准线 M1-M2 | 实线,宽度2px |
| 切线 M1-4, M2-8 | 实线,宽度2px |
| 角度弧线 | 虚线 lineDash: '4,4',宽度1px |
| 手柄点 | 半径6px |
触发条件:鼠标按下在手柄点附近(proximity < 15px)
交互流程:
mouseDown: 找到最近的手柄点,设置 activeHandleIndexmouseDrag: 更新手柄点位置,重新计算圆和角度mouseUp: 清除 activeHandleIndex,结束拖拽实时更新:
实现要点:
textBoxOffset)关键代码:
// mouseDown时记录偏移
const textBoxOffset: Point2 = [
mouseX - textBoxX,
mouseY - textBoxY
];
// mouseDrag时应用偏移
const newTextBoxPosition: Point2 = [
mouseX - textBoxOffset[0],
mouseY - textBoxOffset[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);
}
// 创建默认注解(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
);
// 获取注解
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);
}
// 从导出数据恢复
const restoredAnnotation = HipNHAAngleMeasurementTool.restoreFromExportedData(
exportedData,
element,
viewport
);
// 添加到状态
cornerstoneTools.annotation.state.addAnnotation(restoredAnnotation, element);
// 重新计算
if (enabledElement && toolInstance) {
toolInstance._updateCachedStats(restoredAnnotation, enabledElement);
}
// 触发渲染
cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
viewportIdsToRender
);
| 特性 | CustomAngleMeasurementTool | HipNHAAngleMeasurementTool |
|---|---|---|
| 点数量 | 8个点(4条线段) | 8个点(2个圆各3点 + 2个切线点) |
| 几何元素 | 4条线段 | 2个圆 + 3条线段 |
| 计算核心 | 线段交点、夹角 | 圆拟合、切线夹角 |
| 渲染元素 | 线段 + 弧线 + 文本 | 圆 + 线段 + 弧线 + 文本 |
| 新增算法 | 无 | 圆拟合算法(fitCircleFrom3Points) |
| 新增绘制 | 无 | 圆形绘制(drawCircle) |
✅ 完全复用:
AnnotationTool)mouseDown/Drag/Up)worldToCanvas)vectorSubtract, getAngleBetweenLines)textBoxOffset机制)cachedStats, highlighted)📝 修改使用:
_updateCachedStats: 改为计算圆和圆心renderAnnotation: 新增圆形绘制问题:当3个点接近共线时,无法拟合圆
解决:
// 检查行列式是否接近0
if (Math.abs(detA) < 1e-10) {
console.warn('三点共线,无法拟合圆');
return null;
}
原因:未调用 _activateModify()
解决:在激活工具后调用
toolInstance._activateModify(element);
原因: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);
检查清单:
atan2 参数顺序_updateCachedStats → 重新计算 → 触发渲染highlighted 属性动态改变颜色1. 创建工具类(继承AnnotationTool)
2. 实现圆拟合算法(新增)
3. 实现_updateCachedStats(计算圆和角度)
4. 实现renderAnnotation(绘制圆、线、文本)
5. 复用交互逻辑(拖拽、文本框移动)
6. 实现数据导出/恢复
7. 测试和调试
✅ 适合:
❌ 不适合: