评估心脏是否存在扩大或心脏异常变化
点1、2 → 胸椎长度线
点3、4 → 心脏长轴
点5、6 → 心脏短轴
约束条件:
- 心脏短轴必须垂直于心脏长轴
- 即先确定长轴,短轴在垂直前提下可调整位置
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 (心椎比指数)
| 业务概念 | 技术实现 | 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绘制 |
// 注解初始配置接口
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; // 短轴长度
};
};
};
}
// 使用欧几里得距离公式
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]);
const vertebraUnit = spineLineLength / 5;
const longAxisCount = longAxisLength / vertebraUnit; // L
const shortAxisCount = shortAxisLength / vertebraUnit; // S
const ratio = longAxisCount / shortAxisCount; // L/S
const vhs = longAxisCount + shortAxisCount; // VHS
无论拖动长轴还是短轴的点,都需要保持短轴垂直于长轴。短轴分为固定端点和位置端点:
当拖动长轴端点时,需要实时更新短轴位置以保持垂直关系(与之前相同)。
固定端点只能沿垂直于长轴的方向移动,位置端点保持不动:
// 计算长轴方向向量和垂直向量
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)
);
关键点:
位置端点可以任意移动,固定端点自动调整沿平行于长轴的方向移动,以保持连线垂直:
核心思路:
关键点:
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对象
}
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)' } // 黄色
);
}
}
}
| 方法名 | 功能 | 关键点 |
|---|---|---|
createDefaultAnnotation() |
创建预设的6点注解 | 计算初始点位置,应用配置 |
_updateCachedStats() |
计算并缓存统计数据 | 计算V, L, S, L/S, VHS |
renderAnnotation() |
渲染注解 | 绘制3条线段、6个点、文本框 |
_mouseDownModifyCallback() |
处理鼠标按下 | 检测点击手柄或文本框 |
_mouseDragModifyCallback() |
处理鼠标拖拽 | 更新点位置,应用垂直约束 |
_mouseUpModifyCallback() |
处理鼠标释放 | 清理状态 |
_applyPerpendicularConstraint() |
应用垂直约束 | 当拖动短轴点时保持垂直 |
exportAnnotationData() |
导出数据 | 导出点坐标和计算值 |
restoreFromExportedData() |
恢复数据 | 从导出数据重建注解 |
- calculateMidpoint(p1, p2) // 计算中点
- vectorSubtract(p1, p2) // 向量减法
- vectorAdd(p1, v) // 向量加法
- vectorScale(v, scalar) // 向量缩放
- calculateDistance(p1, p2) // 计算距离(需要新增)
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;
};
}
难点: 当拖动任意短轴点时,如何保持短轴垂直于长轴?
解决方案:
实现代码框架:
_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);
}
解决方案: 使用与CustomAngleMeasurementTool相同的textBoxOffset机制
// mouseDown时记录偏移
const textBoxOffset = [
canvasCoords[0] - textBox[0],
canvasCoords[1] - textBox[1]
];
// mouseDrag时应用偏移
const newTextBoxPosition = [
canvasCoords[0] - textBoxOffset[0],
canvasCoords[1] - textBoxOffset[1]
];
问题: 当胸椎线长度为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;
// 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);
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);
}
| 特性 | CustomAngleMeasurementTool | VHSMeasurementTool |
|---|---|---|
| 点数量 | 8个点 | 6个点 |
| 线段数 | 4条线(AB、中点线、CD) | 3条线(胸椎、长轴、短轴) |
| 几何约束 | 无 | 短轴垂直于长轴 |
| 计算内容 | 角度 | 距离比例(V, L, S, VHS) |
| 文本显示 | 2个角度文本框 | 1个多行文本框(4个值) |