胫骨平台角(TPA)的测量目的是评估狗狗膝关节胫骨平台的倾斜程度,从而判断其前交叉韧带受伤风险。
| 点编号 | 作用 | 位置说明 |
|---|---|---|
| 1、2 | 生成胫骨内侧平台线 | 连接点1→点2形成平台线 |
| 3、4 | 生成胫骨功能轴线 | 连接点3→点4形成功能轴线 |
关键线段定义:
TPA角度 = 胫骨内侧平台线(1→2)与胫骨功能轴线(3→4)的垂线夹角
具体计算步骤:
plateauVector = 点2 - 点1functionalAxisVector = 点4 - 点3perpendicularVector = (-plateauVector.y, plateauVector.x, 0) (2D平面垂直向量)TPA = angle(functionalAxisVector, perpendicularVector)class TibialPlateauAngleTool extends AnnotationTool
@cornerstonejs/tools 的 AnnotationTool 基类import { getEnabledElement, Types as CoreTypes } from '@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 |
import {
AnnotationTool,
utilities,
annotation,
drawing
} from '@cornerstonejs/tools';
| API/模块 | 用途 |
|---|---|
annotation.state.addAnnotation() |
将注解添加到状态管理 |
annotation.state.getAnnotations() |
获取指定工具的所有注解 |
utilities.viewportFilters.getViewportIdsWithToolToRender() |
获取需要渲染的viewport ID列表 |
utilities.triggerAnnotationRenderForViewportIds() |
触发指定viewport的渲染更新 |
drawing.drawLine() |
绘制线段 |
drawing.drawPath() |
绘制路径(用于弧线) |
drawing.drawHandles() |
绘制可拖拽的手柄点 |
drawing.drawLinkedTextBox() |
绘制连接到点的文本框 |
// 鼠标事件监听
element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', callback);
element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', callback);
element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', callback);
// 存储时:canvas → world
const worldPoint = viewport.canvasToWorld(canvasPoint);
// 渲染时:world → canvas
const canvasPoint = viewport.worldToCanvas(worldPoint);
interface TibialPlateauAngleAnnotation {
metadata: {
viewPlaneNormal: Point3; // 视图平面法向量
viewUp: Point3; // 视图向上向量
FrameOfReferenceUID: string; // 参考坐标系
referencedImageId: string; // 图像ID
toolName: string; // 工具名称
};
data: {
handles: {
points: Point3[]; // 4个世界坐标点
activeHandleIndex: number | null; // 当前活动的手柄索引
textBoxTPA?: Point2; // TPA角度文本框位置(canvas坐标)
};
cachedStats: {
[targetId: string]: {
TPA: number; // TPA角度值
functionalAxisVector: Point3; // 功能轴线向量(点3→点4)
plateauLineVector: Point3; // 平台线向量(点1→点2)
perpendicularVector: Point3; // 平台线的垂线向量
plateauMidpoint: Point3; // 平台线中点
intersectionPoint?: Point3; // 功能轴线与中垂线的交点
};
};
};
}
_activateModify() - 用于编辑现有注解
// Step 1: 计算胫骨内侧平台线向量(点1→点2)
const plateauLineVector = vectorSubtract(point2, point1);
// Step 2: 计算胫骨功能轴线向量(点3→点4)
const functionalAxisVector = vectorSubtract(point4, point3);
// Step 3: 计算平台线与功能轴线的交点(作为中垂线的起点)
const intersectionPoint = getLineIntersection(
point1, // 平台线起点
plateauLineVector, // 平台线方向
point3, // 功能轴线起点
functionalAxisVector // 功能轴线方向
);
// Step 4: 计算平台线的中垂线向量
// 在2D平面上,向量(x,y)的逆时针垂直向量为(-y,x)
// 这条垂线从交点出发,垂直于平台线
const perpendicularVector: Point3 = [
-plateauLineVector[1],
plateauLineVector[0],
0
];
// Step 5: 计算TPA角度(功能轴线与平台线中垂线的夹角)
const angleRad = getAngleBetweenLines(functionalAxisVector, perpendicularVector);
const TPA = Math.round(degrees(angleRad));
延长线实现细节:
import TibialPlateauAngleTool from './TibialPlateauAngleTool';
import { ToolGroupManager } from '@cornerstonejs/tools';
// 注册工具
cornerstoneTools.addTool(TibialPlateauAngleTool);
const toolGroupId = 'myToolGroup';
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
// 添加工具到工具组
toolGroup.addTool(TibialPlateauAngleTool.toolName);
// 将工具组关联到viewport
toolGroup.addViewport(viewportId, renderingEngineId);
// 激活工具
toolGroup.setToolActive(TibialPlateauAngleTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
// 激活修改模式(允许拖拽)
const toolInstance = toolGroup.getToolInstance(
TibialPlateauAngleTool.toolName
) as TibialPlateauAngleTool;
if (toolInstance && elementRef.current) {
toolInstance._activateModify(elementRef.current);
}
const annotation = TibialPlateauAngleTool.createDefaultAnnotation(
element,
viewport
);
const config = {
axisLineLength: 200, // 功能轴线长度(px)
plateauLineLength: 150, // 平台线长度(px)
axisLineCenterX: 400, // 功能轴线中心X坐标
axisLineCenterY: 300, // 功能轴线中心Y坐标
plateauLineCenterX: 600, // 平台线中心X坐标
plateauLineCenterY: 300, // 平台线中心Y坐标
};
const annotation = TibialPlateauAngleTool.createDefaultAnnotation(
element,
viewport,
config
);
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| axisLineLength | number | 200 | 胫骨功能轴线长度(像素) |
| plateauLineLength | number | 150 | 胫骨内侧平台线长度(像素) |
| axisLineCenterX | number | centerX - 100 | 功能轴线中心点X坐标 |
| axisLineCenterY | number | centerY | 功能轴线中心点Y坐标 |
| plateauLineCenterX | number | centerX + 100 | 平台线中心点X坐标 |
| plateauLineCenterY | number | centerY | 平台线中心点Y坐标 |
默认布局: 功能轴线在左侧(垂直),平台线在右侧(水平)
// 添加注解
cornerstoneTools.annotation.state.addAnnotation(annotation, element);
// 计算统计数据
const enabledElement = cornerstone.getEnabledElement(element);
if (enabledElement) {
toolInstance._updateCachedStats(annotation, enabledElement);
}
// 触发渲染
const viewportIdsToRender =
cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender(
element,
TibialPlateauAngleTool.toolName
);
cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
viewportIdsToRender
);
export interface ExportedAnnotationData {
// 4个点的世界坐标
points: Point3[];
// TPA角度值(度数)
TPA: number;
// 文本框位置(可选,canvas坐标)
textBoxPosition?: Point2;
// 元数据
metadata: {
viewPlaneNormal: Point3;
viewUp: Point3;
FrameOfReferenceUID: string;
referencedImageId: string;
};
}
// 获取注解
const annotations = cornerstoneTools.annotation.state.getAnnotations(
TibialPlateauAngleTool.toolName,
element
);
// 导出第一个注解的数据
if (annotations && annotations.length > 0) {
const exportedData = TibialPlateauAngleTool.exportAnnotationData(
annotations[0],
viewport
);
if (exportedData) {
// 保存数据(例如发送到服务器或保存到localStorage)
console.log('导出的数据:', exportedData);
// 获取TPA角度值
console.log('TPA角度:', exportedData.TPA, '度');
// 获取4个控制点的世界坐标
console.log('点1(平台线起点):', exportedData.points[0]);
console.log('点2(平台线终点):', exportedData.points[1]);
console.log('点3(功能轴线起点):', exportedData.points[2]);
console.log('点4(功能轴线终点):', exportedData.points[3]);
// 获取文本框位置
if (exportedData.textBoxPosition) {
console.log('TPA文本框位置:', exportedData.textBoxPosition);
}
} else {
console.log('注解数据未计算完成,无法导出');
}
}
// 从保存的数据恢复注解
const restoredAnnotation = TibialPlateauAngleTool.restoreFromExportedData(
exportedData,
element,
viewport
);
// 添加到状态
cornerstoneTools.annotation.state.addAnnotation(restoredAnnotation, element);
// 重新计算统计数据
const enabledElement = cornerstone.getEnabledElement(element);
if (enabledElement) {
const toolInstance = toolGroup.getToolInstance(
TibialPlateauAngleTool.toolName
) as TibialPlateauAngleTool;
if (toolInstance) {
toolInstance._updateCachedStats(restoredAnnotation, enabledElement);
}
}
// 触发渲染
const viewportIdsToRender =
cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender(
element,
TibialPlateauAngleTool.toolName
);
cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
viewportIdsToRender
);
import React, { useEffect, useRef, useState } from 'react';
import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
import TibialPlateauAngleTool from './TibialPlateauAngleTool';
const TPAMeasurementComponent = () => {
const elementRef = useRef<HTMLDivElement>(null);
const [tpaValue, setTpaValue] = useState<number | null>(null);
useEffect(() => {
const setup = async () => {
// 1. 初始化
await cornerstone.init();
await cornerstoneTools.init();
// 2. 注册工具
cornerstoneTools.addTool(TibialPlateauAngleTool);
// 3. 创建rendering engine和viewport
const renderingEngine = new cornerstone.RenderingEngine('myEngine');
const viewportInput = {
viewportId: 'myViewport',
element: elementRef.current,
type: cornerstone.Enums.ViewportType.STACK,
};
renderingEngine.enableElement(viewportInput);
// 4. 加载图像...
// 5. 创建工具组
const toolGroup = cornerstoneTools.ToolGroupManager.createToolGroup('myToolGroup');
toolGroup.addTool(TibialPlateauAngleTool.toolName);
toolGroup.addViewport('myViewport', 'myEngine');
// 6. 激活工具
toolGroup.setToolActive(TibialPlateauAngleTool.toolName, {
bindings: [{ mouseButton: cornerstoneTools.Enums.MouseBindings.Primary }],
});
// 7. 创建预设注解
const viewport = renderingEngine.getViewport('myViewport');
const annotation = TibialPlateauAngleTool.createDefaultAnnotation(
elementRef.current,
viewport,
{
axisLineLength: 200,
plateauLineLength: 150,
}
);
// 8. 添加注解
cornerstoneTools.annotation.state.addAnnotation(annotation, elementRef.current);
// 9. 计算统计数据
const enabledElement = cornerstone.getEnabledElement(elementRef.current);
const toolInstance = toolGroup.getToolInstance(
TibialPlateauAngleTool.toolName
) as TibialPlateauAngleTool;
if (enabledElement && toolInstance) {
toolInstance._updateCachedStats(annotation, enabledElement);
toolInstance._activateModify(elementRef.current);
}
// 10. 触发渲染
const viewportIdsToRender =
cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender(
elementRef.current,
TibialPlateauAngleTool.toolName
);
cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
viewportIdsToRender
);
};
setup();
}, []);
// 导出TPA数据
const handleExportTPA = () => {
const annotations = cornerstoneTools.annotation.state.getAnnotations(
TibialPlateauAngleTool.toolName,
elementRef.current
);
if (annotations && annotations.length > 0) {
const renderingEngine = cornerstone.getRenderingEngine('myEngine');
const viewport = renderingEngine.getViewport('myViewport');
const data = TibialPlateauAngleTool.exportAnnotationData(
annotations[0],
viewport
);
if (data) {
setTpaValue(data.TPA);
console.log('TPA测量结果:', data);
// 发送到服务器或其他处理
// sendToServer(data);
}
}
};
return (
<div>
<div ref={elementRef} style={{ width: '100%', height: '500px' }} />
<button onClick={handleExportTPA}>获取TPA值</button>
{tpaValue !== null && (
<div>当前TPA值: {tpaValue}°</div>
)}
</div>
);
};
原因: 未调用 _activateModify() 或事件监听器未注册
解决:
const toolInstance = toolGroup.getToolInstance(
TibialPlateauAngleTool.toolName
) as TibialPlateauAngleTool;
if (toolInstance && elementRef.current) {
toolInstance._activateModify(elementRef.current);
}
原因: 未记录鼠标相对文本框的偏移量
解决: 已在实现中通过 textBoxOffset 解决,在mouseDown时记录偏移量,mouseDrag时应用
原因: 垂线向量计算错误或角度方向问题
检查:
(-y, x, 0)原因: 交点计算失败或延伸长度不够
解决:
原因: 未触发渲染更新
解决:
utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
getAngleBetweenLines() 计算功能轴线与中垂线的夹角延长线绘制技术细节:
// 1. 选择离交点较近的端点
const dist1 = distance(intersection, point1);
const dist2 = distance(intersection, point2);
const extensionStart = dist1 < dist2 ? point1 : point2;
// 2. 计算从端点到交点的方向向量并归一化
const directionVector = subtract(intersection, extensionStart);
const normalizedDir = normalize(directionVector);
// 3. 计算延长线的终点(交点后继续延伸50像素)
const extraExtensionLength = 50;
const extensionEnd = add(intersection, scale(normalizedDir, extraExtensionLength));
// 4. 绘制从端点到延长终点的完整虚线
drawLine(extensionStart, extensionEnd, { lineDash: '4,4' });
_updateCachedStats() 重新计算TibialPlateauAngleTool展示了:
这个工具可用于: