实现一个医学影像直线灰度测量工具,允许用户在DICOM图像上绘制直线,并实时计算和显示该直线路径上的灰度值统计信息。
基于用户确认,采用以下需求:
以下需求可在后续迭代中实现:
src/pages/view/components/MeasurementPanel.tsx:275-280按钮:
<FunctionButton
title="直线灰度"
action="直线灰度"
iconName="btn_LineGrayscale"
productId="animal"
/>
src/pages/view/components/MeasurementPanel.tsxhandleMeasurementAction(action: string) - 分发测量操作src/pages/view/components/ViewerContainer.tsxuseEffect 监听 measurementAction (line:558-697)switch-case 处理 "直线灰度" actionactivateLineGrayscaleMeasurement(viewportId)src/components/measures/LineGrayscaleMeasurementTool.ts(新建)AnnotationTool (Cornerstone3D基类)static toolName = 'LineGrayscaleMeasurementTool'static createDefaultAnnotation() - 创建预设注解addNewAnnotation() - 用户交互绘制(返回空,禁用手动绘制)_updateCachedStats() - 计算灰度统计数据renderAnnotation() - SVG渲染直线和文本_mouseDownModifyCallback() - 处理鼠标按下_mouseDragModifyCallback() - 处理拖拽_mouseMoveModifyCallback() - 处理悬停_keyDownCallback() - 处理Delete删除_samplePixelsAlongLine() - 核心算法:沿直线采样像素_calculateGrayscaleStats() - 计算统计值src/utils/measurementToolManager.tsstatic activateLineGrayscaleMeasurementTool(viewportId: string): booleanstatic deactivateLineGrayscaleMeasurementTool(viewportId: string): booleanstatic clearLineGrayscaleMeasurements(viewportId: string): booleanstatic clearLineGrayscaleMeasurementsForViewports(viewportIds: string[]): boolean[]src/utils/cornerstoneToolsSetup.tsregisterGlobalTools() 中添加 cornerstoneTools.addTool(LineGrayscaleMeasurementTool)src/pages/view/components/viewers/stack.image.viewer.tsxregisterTools() 添加工具到工具组 (line:179-254)setupDefaultToolStates() 设置工具默认状态 (line:259-311)activateLineGrayscaleMeasurement() 函数src/states/view/measurementPanelSlice.tssetMeasurementAction('直线灰度') - 设置当前测量动作clearMeasurementAction() - 清除动作setToolActive() - 标记工具为激活状态setToolInactive() - 标记工具为非激活状态@cornerstonejs/coreviewport.worldToCanvas(point3D) - 世界坐标转Canvas坐标viewport.canvasToWorld(point2D) - Canvas坐标转世界坐标viewport.getImageData() - 获取图像数据对象viewport.getCurrentImageId() - 获取当前图像IDviewport.render() - 触发重新渲染@cornerstonejs/coreimageData.getScalarData() - 获取像素数组(Uint16Array)imageData.getDimensions() - 获取图像尺寸 [width, height, depth]imageData.getSpacing() - 获取像素间距 [x, y, z]@cornerstonejs/toolsannotation.state.addAnnotation() - 添加注解annotation.state.removeAnnotation() - 删除注解annotation.state.getAnnotations() - 获取注解列表@cornerstonejs/toolsdrawLineSvg() - 绘制SVG线段drawHandles() - 绘制控制点drawLinkedTextBox() - 绘制文本框src/components/measures/LineGrayscaleMeasurementTool.tsLineGrayscaleAnnotation 接口LineGrayscaleMeasurementTool 类src/components/measures/LineGrayscaleMeasurementTool.README.mdsrc/pages/view/components/ViewerContainer.tsxtypescript
import {
// ... 现有导入
activateLineGrayscaleMeasurement,
} from './viewers/stack.image.viewer';
位置2: 工具配置映射(line:77-163)
const MEASUREMENT_TOOL_CONFIGS: Record<string, MeasurementToolConfig> = {
// ... 现有配置
'直线灰度': {
toolName: 'LineGrayscaleMeasurementTool',
activateFunction: activateLineGrayscaleMeasurement,
logPrefix: 'LineGrayscale',
},
};
typescript
case '直线灰度': {
const config = MEASUREMENT_TOOL_CONFIGS[measurementAction];
if (config) {
activateMeasurementTool(config, selectedViewportIds);
}
break;
}
位置4: 清除测量(line:600-621)
case '清除测量':
if (selectedViewportIds.length > 0) {
// ... 现有清除逻辑
MeasurementToolManager.clearLineGrayscaleMeasurementsForViewports(selectedViewportIds);
}
break;
src/pages/view/components/viewers/stack.image.viewer.tsxtypescript
import LineGrayscaleMeasurementTool from '@/components/measures/LineGrayscaleMeasurementTool';
位置2: registerTools函数(line:218-246)
toolGroup.addTool(LineGrayscaleMeasurementTool.toolName);
typescript
toolGroup.setToolPassive(LineGrayscaleMeasurementTool.toolName);
位置4: 新增导出函数(文件末尾,约line:1432后)
``typescript
export function activateLineGrayscaleMeasurement(viewportId: string): boolean {
console.log([activateLineGrayscaleMeasurement] Activating for viewport: ${viewportId}`);
return MeasurementToolManager.activateLineGrayscaleMeasurementTool(viewportId);
}
export function deactivateLineGrayscaleMeasurement(viewportId: string): boolean {
console.log(`[deactivateLineGrayscaleMeasurement] Deactivating for viewport: ${viewportId}`);
return MeasurementToolManager.deactivateLineGrayscaleMeasurementTool(viewportId);
}
export function clearLineGrayscaleMeasurements(viewportId: string): boolean {
console.log(`[clearLineGrayscaleMeasurements] Clearing for viewport: ${viewportId}`);
return MeasurementToolManager.clearLineGrayscaleMeasurements(viewportId);
}
#### 📝 修改3: measurementToolManager.ts
- **文件**: [`src/utils/measurementToolManager.ts`](src/utils/measurementToolManager.ts)
- **位置1**: 导入区域(line:3-19)
```typescript
import LineGrayscaleMeasurementTool from '@/components/measures/LineGrayscaleMeasurementTool';
static activateLineGrayscaleMeasurementTool(viewportId: string): boolean {
const toolGroup = this.getToolGroup(viewportId);
if (!toolGroup) return false;
try {
// 停用其他工具
toolGroup.setToolPassive(WindowLevelTool.toolName, { removeAllBindings: true });
// ... 其他工具
toolGroup.setToolActive(LineGrayscaleMeasurementTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
const toolInstance = toolGroup.getToolInstance(LineGrayscaleMeasurementTool.toolName);
const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
if (toolInstance && viewport.element) {
toolInstance._activateModify(viewport.element);
}
// 创建预设注解
try {
if (viewport && viewport.element) {
const defaultAnnotation = LineGrayscaleMeasurementTool.createDefaultAnnotation(
viewport.element as HTMLDivElement,
viewport as cornerstone.Types.IStackViewport
);
cornerstoneTools.annotation.state.addAnnotation(defaultAnnotation, viewport.element);
const enabledElement = cornerstone.getEnabledElement(viewport.element);
if (enabledElement && toolInstance && '_updateCachedStats' in toolInstance) {
(toolInstance as any)._updateCachedStats(defaultAnnotation, enabledElement);
}
viewport.render();
}
} catch (error) {
console.error('[MeasurementToolManager] Failed to create default LineGrayscale annotation:', error);
}
return true;
} catch (error) {
console.error('[MeasurementToolManager] Error activating LineGrayscale tool:', error);
return false;
}
}
static deactivateLineGrayscaleMeasurementTool(viewportId: string): boolean {
const toolGroup = this.getToolGroup(viewportId);
if (!toolGroup) return false;
try {
toolGroup.setToolPassive(LineGrayscaleMeasurementTool.toolName, { removeAllBindings: true });
return true;
} catch (error) {
console.error('[MeasurementToolManager] Error deactivating LineGrayscale tool:', error);
return false;
}
}
static clearLineGrayscaleMeasurements(viewportId: string): boolean {
try {
const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
if (!viewport) return false;
const annotations = cornerstoneTools.annotation.state.getAnnotations(
LineGrayscaleMeasurementTool.toolName,
viewport.element
);
let removedCount = 0;
annotations.forEach((annotation) => {
if (annotation.annotationUID) {
cornerstoneTools.annotation.state.removeAnnotation(annotation.annotationUID);
removedCount++;
}
});
viewport.render();
console.log(`[MeasurementToolManager] Cleared ${removedCount} LineGrayscale measurements`);
return true;
} catch (error) {
console.error('[MeasurementToolManager] Error clearing LineGrayscale measurements:', error);
return false;
}
}
static clearLineGrayscaleMeasurementsForViewports(viewportIds: string[]): boolean[] {
return viewportIds.map((viewportId) => this.clearLineGrayscaleMeasurements(viewportId));
}
#### 📝 修改4: cornerstoneToolsSetup.ts
- **文件**: [`src/utils/cornerstoneToolsSetup.ts`](src/utils/cornerstoneToolsSetup.ts)
- **位置1**: 导入区域(line:2-19)
```typescript
import LineGrayscaleMeasurementTool from '@/components/measures/LineGrayscaleMeasurementTool';
位置2: registerGlobalTools函数(line:75后)
cornerstoneTools.addTool(LineGrayscaleMeasurementTool); // 添加直线灰度测量工具
sequenceDiagram
participant User as 用户
participant MP as MeasurementPanel
participant Redux as Redux Store
participant VC as ViewerContainer
participant MTM as MeasurementToolManager
participant Tool as LineGrayscaleTool
participant CS as Cornerstone3D
participant VP as Viewport
User->>MP: 点击"直线灰度"按钮
MP->>Redux: dispatch setMeasurementAction
Redux-->>VC: measurementAction = 直线灰度
VC->>VC: 解析selectedViewers
VC->>MTM: activateLineGrayscaleMeasurementTool
MTM->>MTM: getToolGroup
MTM->>MTM: 停用其他工具
MTM->>Tool: setToolActive
MTM->>Tool: activateModify
MTM->>Tool: createDefaultAnnotation
Tool->>VP: 获取图像中心和尺寸
Tool-->>MTM: 返回默认注解对象
MTM->>CS: annotation.state.addAnnotation
MTM->>Tool: updateCachedStats
Tool->>VP: getImageData
Tool->>Tool: samplePixelsAlongLine
Tool->>Tool: calculateGrayscaleStats
Tool-->>MTM: 更新cachedStats
MTM->>VP: viewport.render
VP->>Tool: renderAnnotation
Tool->>CS: drawLineSvg, drawHandles, drawLinkedTextBox
CS-->>User: 显示直线和灰度统计数据
Redux->>Redux: setToolActive
Redux->>Redux: clearMeasurementAction
Note over User,CS: 用户拖拽端点
User->>Tool: 鼠标按下端点
Tool->>Tool: mouseDownModifyCallback
Tool->>Tool: 记录activeHandleIndex
User->>Tool: 拖拽鼠标
Tool->>Tool: mouseDragModifyCallback
Tool->>VP: canvasToWorld
Tool->>Tool: 更新handles.points
Tool->>Tool: updateCachedStats
Tool->>VP: render
VP-->>User: 实时显示新的统计数据
User->>Tool: 鼠标释放
Tool->>Tool: mouseUpModifyCallback
Tool->>Tool: activeHandleIndex = null
Note over User,CS: 用户删除注解
User->>Tool: 按Delete键
Tool->>Tool: keyDownCallback
Tool->>CS: annotation.state.removeAnnotation
Tool->>VP: render
VP-->>User: 注解消失
graph TD
A[用户点击按钮] --> B[Redux Action]
B --> C[ViewerContainer监听]
C --> D[调用激活函数]
D --> E[MeasurementToolManager]
E --> F[创建默认注解对象]
F --> G[获取Viewport图像数据]
G --> H[执行像素采样算法]
H --> I[Bresenham直线算法]
I --> J[遍历直线上的像素坐标]
J --> K[从ImageData读取灰度值]
K --> L[构建灰度值数组]
L --> M[计算统计值]
M --> N[更新cachedStats]
N --> O[触发渲染]
O --> P[绘制SVG直线]
O --> Q[绘制控制点]
O --> R[绘制文本框]
R --> S[显示统计数据]
T[用户拖拽端点] --> U[鼠标事件]
U --> V[更新坐标]
V --> W[重新采样计算]
W --> M
Canvas坐标(鼠标位置)
→ viewport.canvasToWorld()
→ 世界坐标Point3[x, y, z]
→ 存储在annotation.data.handles.points
世界坐标Point3[x, y, z]
→ 考虑spacing和dimensions
→ 像素坐标[col, row, slice]
→ 像素索引 = row * width + col
→ scalarData[index]
像素数组Uint16Array
→ 沿直线采样
→ 灰度值数组number[]
→ Math.min/max/average
→ cachedStats对象
cachedStats: {mean, min, max}
→ 格式化文本: "平均:123 最小:45 最大:234"
→ drawLinkedTextBox()
→ SVG <text>元素
→ 屏幕显示
/**
* 直线灰度测量注解接口
*/
interface LineGrayscaleAnnotation extends Types.Annotation {
data: {
handles: {
// 控制点(两个端点,世界坐标)
points: [CoreTypes.Point3, CoreTypes.Point3];
// 当前激活的手柄索引(0或1,null表示未激活)
activeHandleIndex: number | null;
};
// 文本框位置(Canvas坐标)
textBox?: CoreTypes.Point2;
// 缓存的统计结果
cachedStats?: {
[targetId: string]: {
// 平均灰度值
mean: number;
// 最小灰度值
min: number;
// 最大灰度值
max: number;
// 采样点数量
sampleCount: number;
// 直线长度(mm)
lineLength: number;
};
};
};
}
/**
* 像素采样结果
*/
interface PixelSampleResult {
// 灰度值数组
values: number[];
// 采样点的像素坐标数组
coordinates: Array<{x: number, y: number}>;
// 采样点总数
count: number;
}
/**
* 灰度统计结果
*/
interface GrayscaleStats {
// 平均值
mean: number;
// 最小值
min: number;
// 最大值
max: number;
// 标准差(扩展需求)
stdDev?: number;
// 中位数(扩展需求)
median?: number;
}
启动阶段:
MeasurementPanel.handleMeasurementAction() 分发Redux actionmeasurementAction = '直线灰度'激活阶段:
ViewerContainer.useEffect 监听到action变化activateLineGrayscaleMeasurement(viewportId)MeasurementToolManager.activateLineGrayscaleMeasurementTool() 执行激活逻辑工具配置阶段:
getToolGroup(viewportId)_activateModify(element)创建注解阶段:
LineGrayscaleTool.createDefaultAnnotation()annotation.state.addAnnotation() 添加到状态管理数据计算阶段:
_updateCachedStats(annotation, enabledElement)viewport.getImageData()_samplePixelsAlongLine() 执行Bresenham算法_calculateGrayscaleStats() 计算统计值annotation.data.cachedStats渲染阶段:
viewport.render() 触发渲染renderAnnotation() 方法被调用drawLineSvg)drawHandles)drawLinkedTextBox)完成阶段:
setToolActive({toolName, viewportId}) 更新工具状态clearMeasurementAction() 清除action_mouseDownModifyCallback() → 记录activeHandleIndex_mouseDragModifyCallback() → 更新坐标 → 重新计算 → 实时渲染_mouseUpModifyCallback() → activeHandleIndex=null_keyDownCallback() 处理annotation.state.removeAnnotation()前置条件: 已登录系统,进入检查页面,图像已加载
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 点击右侧测量面板的"直线灰度"按钮 | 按钮高亮激活 |
| 2 | 观察图像中心 | 自动生成一条水平直线(两个绿色端点) |
| 3 | 观察直线旁文本框 | 显示"平均:xxx 最小:yyy 最大:zzz" |
| 4 | 鼠标悬停在左端点上 | 光标变为十字形,端点高亮 |
| 5 | 拖拽左端点到图像左上角 | 直线跟随移动,统计数据实时更新 |
| 6 | 拖拽右端点到图像右下角 | 直线呈对角线,统计数据更新 |
| 7 | 鼠标悬停在直线中间 | 光标变为移动形状 |
| 8 | 拖拽直线整体 | 直线平移到新位置,统计数据更新 |
| 9 | 点击选中注解后按Delete键 | 注解被删除,直线消失 |
前置条件: 布局切换为2x2,加载4张图像
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 点击左上角图像 | 图像高亮选中(蓝色边框) |
| 2 | 点击"直线灰度"按钮 | 仅左上角图像出现直线 |
| 3 | 按住Ctrl点击右下角图像 | 两张图像同时选中 |
| 4 | 再次点击"直线灰度"按钮 | 两张图像都出现直线 |
| 5 | 观察统计数据 | 不同图像显示不同的灰度值 |
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 拖拽端点到图像边缘外 | 端点被限制在图像边界内 |
| 2 | 绘制极短直线(2个像素) | 仍能计算统计值,不报错 |
| 3 | 绘制极长直线(跨整张图) | 统计值正确,性能流畅 |
| 4 | 在纯黑色区域绘制 | 平均/最小/最大值均为0 |
| 5 | 在纯白色区域绘制 | 统计值接近最大值(如4095) |
解决方案: 在拖拽时限制坐标在图像范围内
解决方案: 返回默认统计值(全0)
解决方案: 检查imageData是否存在,不存在则返回null
解决方案: 应用DICOM Rescale Slope/Intercept参数
解决方案: 检查sampleCount > 0
解决方案: 超过5000像素考虑使用Web Worker
解决方案: 使用requestAnimationFrame节流
算法目的: 确定从起点到终点的所有像素坐标
伪代码:
function bresenhamLine(x0, y0, x1, y1): Pixel[] {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const pixels: Pixel[] = [];
let x = x0, y = y0;
while (true) {
pixels.push({x, y});
if (x === x1 && y === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
return pixels;
}
优势:
公式:
index = (row * imageWidth) + col
坐标系转换:
世界坐标 → 像素坐标:
pixelX = (worldX - origin[0]) / spacing[0]
pixelY = (worldY - origin[1]) / spacing[1]
像素坐标 → 数组索引:
index = Math.floor(pixelY) * dimensions[0] + Math.floor(pixelX)
平均值:
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
最小/最大值:
const min = Math.min(...values);
const max = Math.max(...values);
LineGrayscaleMeasurementTool.ts预计总工期: 5-8天
| 版本 | 日期 | 作者 | 变更内容 |
|---|---|---|---|
| v1.0 | 2025-12-10 | Architect Mode | 初始版本,完整设计文档 |