# 添加掩码功能实现方案 ## 📋 一、需求详细描述 ### 1.1 核心需求 **功能名称**:添加掩码(Add Mask) **功能描述**:用户可以在医学图像上绘制一个封闭的多边形区域作为掩码,掩码内的图像区域保持可见,掩码外的区域变为黑色(不可见)。这类似于图像处理中的 Mask/遮罩功能。 ### 1.2 用户交互流程 1. **启动功能**:用户点击"添加Mask"按钮 - 按钮位置1:`FunctionArea.tsx` - 主功能区 - 按钮位置2:`RectCropPanel.tsx` - 矩形裁剪面板 2. **绘制掩码**: - 鼠标光标变为十字准星 - 用户在图像上点击多个点(至少3个点) - 每次点击添加一个多边形顶点 - 实时显示已绘制的线段和预览线 3. **完成绘制**: - 双击鼠标完成绘制 - 自动连接最后一个点到第一个点,形成封闭多边形 - 应用掩码效果:多边形内可见,外部变黑 4. **修改掩码**: - 可以拖拽顶点调整形状 - 可以移动整个掩码 5. **删除掩码**: - 点击"删除Mask"按钮删除当前掩码 ### 1.3 功能约束 - 每个 viewport 同时只能有一个活动掩码 - 掩码形状至少需要 3 个顶点 - 掩码效果实时应用到图像渲染 - 掩码数据可以保存和恢复 - 支持多分格模式下为每个分格独立设置掩码 ### 1.4 参考功能 本功能参考 `PolygonLengthMeasurementTool.ts`(多边形长度测量工具)的实现方式,但主要区别在于: - **测量工具**:绘制多边形后显示周长数据 - **掩码工具**:绘制多边形后应用掩码渲染效果 --- ## 🏗️ 二、架构设计 ### 2.1 系统分层架构 ``` ┌─────────────────────────────────────────────────────────────┐ │ UI 层 (React) │ │ ┌──────────────────┐ ┌─────────────────────────┐ │ │ │ FunctionArea.tsx │ │ RectCropPanel.tsx │ │ │ │ "Add Mask" 按钮 │ │ "添加Mask" 按钮 │ │ │ └────────┬─────────┘ └───────────┬─────────────┘ │ └───────────┼───────────────────────────────┼─────────────────┘ │ │ └───────────┬───────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 状态管理层 (Redux) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ rectCropPanelSlice / functionAreaSlice │ │ │ │ action: "添加Mask" | "删除Mask" │ │ │ └────────────────────────┬─────────────────────────────┘ │ └───────────────────────────┼─────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 控制层 (ViewerContainer) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 监听 Redux action 变化 │ │ │ │ 调用工具激活/停用函数 │ │ │ └────────────────────────┬─────────────────────────────┘ │ └───────────────────────────┼─────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 工具管理层 (Manager) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ MeasurementToolManager.activateMaskTool() │ │ │ │ MeasurementToolManager.clearMaskAnnotations() │ │ │ └────────────────────────┬─────────────────────────────┘ │ └───────────────────────────┼─────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 核心工具层 (MaskTool) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ MaskTool extends AnnotationTool │ │ │ │ - 绘制模式 (_activateDraw) │ │ │ │ - 修改模式 (_activateModify) │ │ │ │ - 渲染逻辑 (renderAnnotation) │ │ │ │ - 掩码效果 (applyMaskEffect) │ │ │ └────────────────────────┬─────────────────────────────┘ │ └───────────────────────────┼─────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Cornerstone3D 框架层 │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ - annotation.state (注解状态管理) │ │ │ │ - viewport.render() (视口渲染) │ │ │ │ - SVGDrawingHelper (SVG绘制) │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 2.2 参与者清单 #### UI 层 1. **FunctionArea.tsx** - 职责:提供主功能区的"Add Mask"按钮 - 交互:点击后 dispatch action 2. **RectCropPanel.tsx** - 职责:提供裁剪面板的"添加Mask"按钮 - 交互:点击后 dispatch action #### 状态管理层 3. **rectCropPanelSlice.ts** - 职责:管理裁剪面板的 action 状态 - 数据:`currentAction: CropAction | null` 4. **functionAreaSlice.ts** - 职责:管理功能区的 action 状态 - 数据:`action: string | null` #### 控制层 5. **ViewerContainer.tsx** - 职责:监听 Redux 状态,调度工具激活 - 方法: - 监听 `rectCropPanel.currentAction` - 监听 `functionArea.action` - 调用工具激活函数 #### 工具管理层 6. **MeasurementToolManager.ts** - 职责:统一管理掩码工具的生命周期 - 方法: - `activateMaskTool(viewportId: string): boolean` - `deactivateMaskTool(viewportId: string): boolean` - `clearMaskAnnotations(viewportId: string): boolean` 7. **stack.image.viewer.tsx** - 职责:注册工具、导出便捷函数 - 方法: - `activateMask(viewportId: string): boolean` - `deactivateMask(viewportId: string): boolean` - `clearMask(viewportId: string): boolean` #### 核心工具层 8. **MaskTool.ts** (新建) - 职责:核心掩码工具实现 - 继承:`AnnotationTool` - 主要方法: - `_activateDraw()` - 激活绘制模式 - `_deactivateDraw()` - 停用绘制模式 - `_activateModify()` - 激活修改模式 - `_deactivateModify()` - 停用修改模式 - `renderAnnotation()` - 渲染掩码 - `applyMaskEffect()` - 应用掩码效果到图像 - `_mouseClickDrawCallback()` - 鼠标点击添加顶点 - `_mouseDoubleClickDrawCallback()` - 双击完成绘制 - `_mouseMoveDrawCallback()` - 鼠标移动预览 - `_mouseDragModifyCallback()` - 拖拽修改 #### Cornerstone3D 框架 9. **AnnotationTool (基类)** 10. **annotation.state (注解管理)** 11. **viewport.render() (渲染引擎)** --- ## 📊 三、Mermaid 图表 ### 3.1 序列图 - 用户操作流程 ```mermaid sequenceDiagram autonumber participant User as 用户 participant UI as FunctionArea/RectCropPanel participant Redux as Redux Store participant Container as ViewerContainer participant Manager as MeasurementToolManager participant Tool as MaskTool participant CS as Cornerstone3D participant Canvas as 渲染画布 User->>UI: 点击"添加Mask"按钮 UI->>Redux: dispatch(setAction("Add Mask")) Redux-->>Container: action 状态变化 Container->>Container: useEffect 监听到变化 Container->>Manager: activateMaskTool(viewportId) Manager->>Tool: setToolActive() Tool->>Tool: _activateDraw(element) Tool-->>User: 鼠标变为十字准星 User->>Tool: 点击添加第1个顶点 Tool->>CS: annotation.state.addAnnotation() Tool->>Canvas: 触发渲染 Canvas-->>User: 显示第1个点 User->>Tool: 点击添加第2个顶点 Tool->>Tool: _mouseClickDrawCallback() Tool->>Canvas: 触发渲染 Canvas-->>User: 显示线段 1-2 User->>Tool: 点击添加第3个顶点 Tool->>Tool: _mouseClickDrawCallback() Tool->>Canvas: 触发渲染 Canvas-->>User: 显示线段 2-3 + 预览闭合线 User->>Tool: 双击完成绘制 Tool->>Tool: _mouseDoubleClickDrawCallback() Tool->>Tool: _deactivateDraw() Tool->>Tool: _activateModify() Tool->>Tool: applyMaskEffect() Tool->>Canvas: 渲染掩码效果 Canvas-->>User: 显示完整掩码(内部可见,外部变黑) ``` ### 3.2 流程图 - 掩码工具激活流程 ```mermaid flowchart TD Start([用户点击按钮]) --> Dispatch[Dispatch Redux Action] Dispatch --> Listen{ViewerContainer
监听到变化?} Listen -->|是| GetViewports[获取选中的 viewportIds] Listen -->|否| End([结束]) GetViewports --> HasSelection{有选中的
viewport?} HasSelection -->|是| LoopSelected[遍历选中的 viewportIds] HasSelection -->|否| LoopAll[遍历所有可见 viewportIds] LoopSelected --> CallActivate[调用 activateMask(viewportId)] LoopAll --> CallActivate CallActivate --> GetToolGroup[获取 ToolGroup] GetToolGroup --> GroupExists{ToolGroup
存在?} GroupExists -->|否| ReturnFalse[返回 false] GroupExists -->|是| DeactivateOthers[停用其他冲突工具] DeactivateOthers --> ActivateTool[setToolActive(MaskTool)] ActivateTool --> GetInstance[获取工具实例] GetInstance --> ActivateDraw[调用 _activateDraw()] ActivateDraw --> AddListeners[添加事件监听器] AddListeners --> SetCursor[设置十字准星光标] SetCursor --> ReturnTrue[返回 true] ReturnFalse --> End ReturnTrue --> End ``` ### 3.3 类图 - MaskTool 类设计 ```mermaid classDiagram class AnnotationTool { <> +getToolName() string +renderAnnotation() boolean +cancel() string } class MaskTool { -currentAnnotation: MaskAnnotation | null -isDrawing: boolean -drawingElement: HTMLDivElement | null -previewMousePosition: Point2 | null -editData: EditData | null +static toolName: string +static createDefaultAnnotation() MaskAnnotation +addNewAnnotation() MaskAnnotation +cancel() string +isPointNearTool() boolean +getHandleNearImagePoint() ToolHandle +renderAnnotation() boolean #_activateDraw(element: HTMLDivElement) void #_deactivateDraw(element: HTMLDivElement) void #_activateModify(element: HTMLDivElement) void #_deactivateModify(element: HTMLDivElement) void -_mouseClickDrawCallback(evt) void -_mouseDoubleClickDrawCallback(evt) void -_mouseMoveDrawCallback(evt) void -_mouseDownModifyCallback(evt) void -_mouseDragModifyCallback(evt) void -_mouseUpModifyCallback(evt) void -_keyDownCallback(evt) void -applyMaskEffect(viewport, points) void -clearMaskEffect(viewport) void -_updateCachedStats(annotation, enabledElement) void -_distanceToSegment(point, lineStart, lineEnd) number } class MaskAnnotation { +annotationUID: string +highlighted: boolean +isSelected: boolean +metadata: AnnotationMetadata +data: MaskAnnotationData } class MaskAnnotationData { +handles: MaskHandles +cachedStats: object +maskApplied: boolean } class MaskHandles { +points: Point3[] +activeHandleIndex: number | null +textBox: Point2 } AnnotationTool <|-- MaskTool : 继承 MaskTool --> MaskAnnotation : 创建/管理 MaskAnnotation --> MaskAnnotationData : 包含 MaskAnnotationData --> MaskHandles : 包含 ``` ### 3.4 状态机图 - 工具状态转换 ```mermaid stateDiagram-v2 [*] --> Inactive: 初始状态 Inactive --> Drawing: 用户点击"添加Mask" Drawing --> Drawing: 单击添加顶点 Drawing --> Modifying: 双击完成绘制
(点数≥3) Drawing --> Inactive: 双击取消
(点数<3) Drawing --> Inactive: 按 Esc 取消 Modifying --> Modifying: 拖拽顶点 Modifying --> Modifying: 移动整体 Modifying --> Inactive: 点击"删除Mask" Modifying --> Inactive: 按 Delete 键 Inactive --> [*]: 工具停用 ``` --- ## 📝 四、数据结构设计 ### 4.1 MaskAnnotation 类型定义 ```typescript import { Types as CoreTypes } from '@cornerstonejs/core'; import { Types } from '@cornerstonejs/tools'; /** * 掩码注解数据结构 */ type MaskAnnotation = Types.Annotation & { data: { handles: { points: CoreTypes.Point3[]; // 多边形顶点(世界坐标) activeHandleIndex: number | null; // 当前激活的顶点索引 textBox?: CoreTypes.Point2; // 文本框位置(可选) }; maskApplied: boolean; // 掩码是否已应用 cachedStats?: { [targetId: string]: { vertexCount: number; // 顶点数量 area: number; // 多边形面积(像素) }; }; }; }; /** * 编辑数据结构(工具内部使用) */ interface EditData { annotation: Types.Annotation; viewportIdsToRender: string[]; handleIndex?: number; newAnnotation?: boolean; hasMoved?: boolean; textBoxBeingMoved?: boolean; textBoxOffset?: CoreTypes.Point2; movingWholeTool?: boolean; wholeToolOffset?: CoreTypes.Point2; } /** * 导出的注解数据接口 */ export interface ExportedMaskData { points: CoreTypes.Point3[]; // 多边形顶点 maskApplied: boolean; // 掩码应用状态 metadata: { viewPlaneNormal: CoreTypes.Point3; viewUp: CoreTypes.Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } ``` ### 4.2 Redux 状态结构(无需修改) ```typescript // rectCropPanelSlice.ts interface RectCropPanelState { currentAction: CropAction | null; // 已包含 '添加Mask' | '删除Mask' selectedRatio: CropRatio | null; } // functionAreaSlice.ts interface FunctionAreaState { action: string | null; // 可以是 'Add Mask' | 'Delete Mask' } ``` --- ## 🔄 五、数据流分析 ### 5.1 正向数据流(用户操作 → 视图更新) ``` 用户点击按钮 ↓ UI 组件触发事件 (onClick) ↓ Dispatch Redux Action ↓ Redux Store 状态更新 ↓ ViewerContainer useEffect 监听状态变化 ↓ 调用 MeasurementToolManager.activateMaskTool() ↓ 获取 ToolGroup,激活 MaskTool ↓ MaskTool._activateDraw() 激活绘制模式 ↓ 添加事件监听器 ↓ 用户点击 → _mouseClickDrawCallback ↓ 创建/更新 annotation ↓ cornerstoneTools.annotation.state.addAnnotation() ↓ 触发 viewport.render() ↓ renderAnnotation() 渲染掩码图形 ↓ Canvas 更新显示 ``` ### 5.2 掩码效果应用流程 ``` 用户双击完成绘制 ↓ _mouseDoubleClickDrawCallback() ↓ 验证顶点数量 (≥3) ↓ 调用 applyMaskEffect(viewport, points) ↓ 获取 viewport 的 canvas context ↓ 创建裁剪路径 (clip path) ↓ 使用 canvas.clip() 设置裁剪区域 ↓ 填充外部区域为黑色 ↓ 设置 annotation.data.maskApplied = true ↓ 触发 viewport.render() ↓ Canvas 显示掩码效果 ``` ### 5.3 反向数据流(删除掩码) ``` 用户点击"删除Mask"按钮 ↓ Dispatch Redux Action ↓ ViewerContainer 监听到 action ↓ 调用 MeasurementToolManager.clearMaskAnnotations() ↓ 获取所有 MaskTool 注解 ↓ 遍历删除:annotation.state.removeAnnotation() ↓ 调用 clearMaskEffect(viewport) 清除掩码效果 ↓ 恢复原始渲染 ↓ 触发 viewport.render() ↓ Canvas 恢复正常显示 ``` --- ## 🛠️ 六、实现清单 (Todolist) ### 6.1 新建文件 - [ ] **`src/components/tools/MaskTool.ts`** - 实现 MaskTool 类 - 继承 AnnotationTool - 实现绘制、修改、渲染逻辑 - 实现掩码效果应用 ### 6.2 修改文件 #### 1. `src/pages/view/components/viewers/stack.image.viewer.tsx` - [ ] 导入 MaskTool 类 ```typescript import MaskTool from '@/components/tools/MaskTool'; ``` - [ ] 在 `addToolsToToolGroup` 函数中注册工具 ```typescript toolGroup.addTool(MaskTool.toolName); ``` - [ ] 导出激活函数 ```typescript export const activateMask = (viewportId: string): boolean => { return MeasurementToolManager.activateMaskTool(viewportId); }; export const deactivateMask = (viewportId: string): boolean => { return MeasurementToolManager.deactivateMaskTool(viewportId); }; export const clearMask = (viewportId: string): boolean => { return MeasurementToolManager.clearMaskAnnotations(viewportId); }; ``` #### 2. `src/utils/measurementToolManager.ts` - [ ] 导入 MaskTool ```typescript import MaskTool from '@/components/tools/MaskTool'; ``` - [ ] 添加激活方法 ```typescript static activateMaskTool(viewportId: string): boolean ``` - [ ] 添加停用方法 ```typescript static deactivateMaskTool(viewportId: string): boolean ``` - [ ] 添加清除方法 ```typescript static clearMaskAnnotations(viewportId: string): boolean ``` #### 3. `src/pages/view/components/ViewerContainer.tsx` - [ ] 导入激活函数 ```typescript import { activateMask, deactivateMask, clearMask } from './viewers/stack.image.viewer'; ``` - [ ] 在 `useEffect` 的 `switch (action)` 中添加 case ```typescript case 'Add Mask': if (selectedViewportIds.length > 0) { selectedViewportIds.forEach((viewportId) => { activateMask(viewportId); }); } else { const visibleViewportCount = getVisibleViewportCount(); for (let i = 0; i < visibleViewportCount; i++) { const viewportId = getViewportIdByUrl(imageUrls[i]); if (viewportId) { activateMask(viewportId); } } } break; case 'Delete Mask': if (selectedViewportIds.length > 0) { selectedViewportIds.forEach((viewportId) => { clearMask(viewportId); }); } else { const visibleViewportCount = getVisibleViewportCount(); for (let i = 0; i < visibleViewportCount; i++) { const viewportId = getViewportIdByUrl(imageUrls[i]); if (viewportId) { clearMask(viewportId); } } } break; ``` #### 4. `src/pages/view/components/FunctionArea.tsx` - [ ] 移除 'Delete Mask' 从未实现列表 ```typescript // 修改前 ['Delete Mask', 'Image Comparison', ...].includes(action) // 修改后 ['Image Comparison', ...].includes(action) ``` --- ## 🧪 七、测试方案 ### 7.1 单元测试场景 | 测试场景 | 预期结果 | |---------|---------| | 工具激活 | MaskTool 成功激活,光标变为十字准星 | | 添加顶点(少于3个) | 可以添加顶点,但不能应用掩码 | | 添加顶点(3个及以上) | 双击后成功应用掩码效果 | | 拖拽顶点 | 顶点位置更新,掩码效果实时更新 | | 移动整个掩码 | 掩码整体移动,效果保持 | | 删除掩码 | 掩码效果消失,图像恢复正常 | | 多分格独立掩码 | 每个分格可以独立设置掩码 | | Esc 取消绘制 | 绘制被取消,工具停用 | ### 7.2 手动测试步骤 #### 测试用例 1:基本掩码绘制 **步骤**: 1. 启动应用,进入图像查看界面 2. 确保有图像加载在 viewport 中 3. 点击 FunctionArea 中的"Add Mask"按钮 4. 观察光标变为十字准星 5. 在图像上点击 4 个点形成一个矩形区域 6. 双击完成绘制 **预期结果**: - 矩形内的图像区域保持可见 - 矩形外的图像区域变为黑色 - 可以看到多边形边框线(绿色) - 可以看到顶点手柄(白色圆点) #### 测试用例 2:掩码修改 **前置条件**:已有一个掩码 **步骤**: 1. 鼠标悬停在某个顶点上 2. 按住鼠标左键拖拽顶点 3. 释放鼠标 **预期结果**: - 顶点跟随鼠标移动 - 掩码形状实时更新 - 掩码效果实时更新 #### 测试用例 3:掩码删除 **前置条件**:已有一个掩码 **步骤**: 1. 点击"Delete Mask"按钮 **预期结果**: - 掩码图形消失 - 图像完全恢复可见 - 不再有黑色区域 #### 测试用例 4:多分格模式 **步骤**: 1. 切换到 2x2 分格模式 2. 选中第一个 viewport 3. 点击"Add Mask"按钮 4. 为第一个 viewport 绘制掩码 5. 选中第二个 viewport 6. 重复绘制掩码 **预期结果**: - 每个 viewport 可以独立设置掩码 - 掩码效果不会相互影响 #### 测试用例 5:从矩形裁剪面板启动 **步骤**: 1. 点击"矩形裁剪"按钮,打开 RectCropPanel 2. 点击面板中的"添加Mask"按钮 3. 绘制掩码 **预期结果**: - 功能与从 FunctionArea 启动完全一致 ### 7.3 边界测试 | 测试场景 | 输入 | 预期结果 | |---------|------|---------| | 最少顶点数 | 只点击 2 个点后双击 | 掩码不创建,提示至少需要3个点 | | 大量顶点 | 点击 100 个点 | 掩码正常创建,性能可接受 | | 超出图像边界 | 某些顶点在图像外 | 掩码仍然创建,但效果仅应用于图像内 | | 没有选中 viewport | 未选中任何 viewport 时点击按钮 | 为所有可见 viewport 激活工具 | | 工具冲突 | 掩码工具激活时点击其他测量工具 | 掩码工具停用,新工具激活 | ### 7.4 性能测试 - **渲染性能**:掩码效果应用后,帧率不低于 30 FPS - **响应性能**:点击按钮到工具激活,延迟 < 100ms - **内存占用**:创建/删除掩码不应有明显内存泄漏 --- ## 🐛 八、潜在问题分析与解决方案 ### 8.1 问题:掩码效果如何实现? **分析**: PolygonLengthMeasurementTool 只是绘制 SVG 图形,但掩码需要实际影响图像渲染。 **解决方案**: 使用 Canvas API 的 `clip()` 方法: ```typescript applyMaskEffect(viewport: Viewport, points: Point3[]) { const canvas = viewport.canvas; const ctx = canvas.getContext('2d'); // 创建裁剪路径 ctx.beginPath(); const canvasPoints = points.map(p => viewport.worldToCanvas(p)); ctx.moveTo(canvasPoints[0][0], canvasPoints[0][1]); for (let i = 1; i < canvasPoints.length; i++) { ctx.lineTo(canvasPoints[i][0], canvasPoints[i][1]); } ctx.closePath(); // 应用裁剪 ctx.save(); ctx.clip(); // 填充外部区域为黑色(使用反向裁剪) ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.restore(); } ``` **备选方案**: 如果 Canvas clip 方案不可行,可以考虑: 1. 使用 WebGL shader 实现掩码效果 2. 修改图像数据本身(pixel manipulation) ### 8.2 问题:多个掩码冲突 **分析**: 如果允许多个掩码,它们的效果如何叠加? **解决方案**: - **方案 1**:限制每个 viewport 只能有一个掩码(推荐) - 新建掩码时自动删除旧掩码 - 实现简单,避免复杂逻辑 - **方案 2**:支持多个掩码,使用交集 - 多个掩码的交集区域可见 - 需要复杂的几何计算 **推荐**:方案 1,简单可靠 ### 8.3 问题:掩码效果实现方案 **分析**: 如何在 Cornerstone3D 中实现多边形区域可见,外部变黑的遮罩效果? **解决方案**: 基于项目现有 `overlayRedRectangle` 函数,采用 **Canvas 覆盖层 + 动态更新** 方案: #### **核心思路** 1. **遮罩 Canvas 管理**:为每个 viewport 创建/管理一个专用遮罩 Canvas 2. **实时更新**:在多边形变化时立即更新遮罩效果 3. **渲染同步**:与 Cornerstone 渲染循环同步更新 4. **生命周期管理**:正确处理遮罩的创建、更新和销毁 #### **技术实现** - 使用 Canvas 2D API 的 `evenodd` 填充规则 - 在 viewport 元素上覆盖半透明遮罩 Canvas - 多边形内部区域透明,外部区域黑色遮罩 - 监听窗口大小变化,自动调整遮罩尺寸 #### **优势** ✅ **实时效果**:用户可以立即看到遮罩效果变化 ✅ **性能良好**:Canvas 2D 操作轻量级 ✅ **兼容性好**:不依赖复杂 WebGL 特性 ✅ **易于管理**:遮罩生命周期与工具生命周期绑定 ✅ **响应式**:自动适应窗口大小变化 ### 8.3 问题:掩码效果的持久化 **分析**: 用户刷新页面或切换图像后,掩码效果是否保留? **解决方案**: - 掩码数据已存储在 `annotation.state` 中 - 需要在工具初始化时检查是否有已存在的掩码注解 - 如果有,自动应用掩码效果 ```typescript onSetToolActive(): void { // 检查是否有已存在的掩码 const annotations = annotation.state.getAnnotations(MaskTool.toolName, element); if (annotations && annotations.length > 0) { // 重新应用掩码效果 const maskAnnotation = annotations[0] as MaskAnnotation; if (maskAnnotation.data.maskApplied) { this.applyMaskEffect(viewport, maskAnnotation.data.handles.points); } } } ``` ### 8.4 问题:与其他工具的冲突 **分析**: 掩码工具激活时,其他工具(如测量、标记)是否受影响? **解决方案**: - 掩码工具激活时,停用所有可能冲突的工具 - 参考 `MeasurementToolManager` 中的模式: ```typescript toolGroup.setToolPassive(WindowLevelTool.toolName, { removeAllBindings: true }); toolGroup.setToolPassive(MagnifyTool.toolName, { removeAllBindings: true }); // ... 停用其他工具 ``` ### 8.5 问题:掩码区域的像素精度 **分析**: 多边形边缘可能产生锯齿或不精确的裁剪。 **解决方案**: - 使用 Canvas 的抗锯齿功能 - 设置 `ctx.imageSmoothingEnabled = true` - 使用高精度的坐标计算 ### 8.6 问题:性能优化 **分析**: 频繁的掩码效果应用可能导致性能下降。 **解决方案**: 1. **节流渲染**:使用 `requestAnimationFrame` 控制渲染频率 2. **缓存计算结果**:缓存 canvas 点坐标转换 3. **延迟应用**:只在双击完成时应用掩码,绘制过程中只显示轮廓 ### 8.7 问题:跨浏览器兼容性 **分析**: Canvas clip 行为在不同浏览器可能有差异。 **解决方案**: - 针对主流浏览器(Chrome, Firefox, Safari, Edge)进行测试 - 如有问题,使用 polyfill 或备选实现 --- ## 📚 九、参考代码示例 ### 9.1 MaskTool 核心结构(伪代码) ```typescript export default class MaskTool extends AnnotationTool { static toolName = 'MaskTool'; currentAnnotation: MaskAnnotation | null = null; isDrawing: boolean = false; drawingElement: HTMLDivElement | null = null; previewMousePosition: Point2 | null = null; // 创建默认注解 static createDefaultAnnotation(element, viewport): MaskAnnotation { // 类似 PolygonLengthMeasurementTool.createDefaultAnnotation } // 添加新注解(绘制模式入口) addNewAnnotation(evt): MaskAnnotation { // 创建空注解 // 激活绘制模式 } // 激活绘制模式 _activateDraw(element: HTMLDivElement): void { this.drawingElement = element; this.isDrawing = true; element.addEventListener('CORNERSTONE_TOOLS_MOUSE_CLICK', this._mouseClickDrawCallback); element.addEventListener('dblclick', this._mouseDoubleClickDrawCallback); element.addEventListener('CORNERSTONE_TOOLS_MOUSE_MOVE', this._mouseMoveDrawCallback); } // 鼠标点击添加顶点 _mouseClickDrawCallback = (evt): void => { if (!this.isDrawing) return; const worldPos = evt.detail.currentPoints.world; if (!this.currentAnnotation) { // 创建第一个注解 this.currentAnnotation = this.createDefaultAnnotation(...); this.currentAnnotation.data.handles.points = [worldPos]; annotation.state.addAnnotation(this.currentAnnotation, element); } else { // 添加新顶点 this.currentAnnotation.data.handles.points.push(worldPos); } // 触发渲染 viewport.render(); }; // 双击完成绘制 _mouseDoubleClickDrawCallback = (evt): void => { if (this.currentAnnotation.data.handles.points.length >= 3) { // 完成绘制 this._deactivateDraw(element); this._activateModify(element); // 应用掩码效果 this.applyMaskEffect(viewport, this.currentAnnotation.data.handles.points); this.currentAnnotation.data.maskApplied = true; // 触发渲染 viewport.render(); } else { // 顶点不足,删除注解 annotation.state.removeAnnotation(this.currentAnnotation.annotationUID); this.currentAnnotation = null; } }; // 应用掩码效果 applyMaskEffect(viewport, points: Point3[]): void { const canvas = viewport.canvas; const ctx = canvas.getContext('2d'); // 转换为 canvas 坐标 const canvasPoints = points.map(p => viewport.worldToCanvas(p)); // 创建裁剪路径 ctx.save(); ctx.beginPath(); ctx.moveTo(canvasPoints[0][0], canvasPoints[0][1]); for (let i = 1; i < canvasPoints.length; i++) { ctx.lineTo(canvasPoints[i][0], canvasPoints[i][1]); } ctx.closePath(); // 反向裁剪:填充外部区域为黑色 ctx.globalCompositeOperation = 'destination-out'; ctx.clip('evenodd'); ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.restore(); } // 渲染注解(绘制多边形轮廓和手柄) renderAnnotation = (enabledElement, svgDrawingHelper): boolean => { // 类似 PolygonLengthMeasurementTool.renderAnnotation // 绘制多边形线段 // 绘制手柄点 // 绘制预览线(如果在绘制模式) }; } ``` ### 9.2 MeasurementToolManager 扩展(伪代码) ```typescript // measurementToolManager.ts import MaskTool from '@/components/tools/MaskTool'; export class MeasurementToolManager { // ... 其他方法 /** * 激活掩码工具 */ static activateMaskTool(viewportId: string): boolean { const toolGroup = MeasurementToolManager.getToolGroup(viewportId); if (!toolGroup) return false; try { // 停用其他可能冲突的工具 toolGroup.setToolPassive(WindowLevelTool.toolName, { removeAllBindings: true }); toolGroup.setToolPassive(MagnifyTool.toolName, { removeAllBindings: true }); toolGroup.setToolPassive(LengthTool.toolName, { removeAllBindings: true }); // ... 停用其他工具 // 激活掩码工具 toolGroup.setToolActive(MaskTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); // 获取工具实例并激活绘制模式 const toolInstance = toolGroup.getToolInstance(MaskTool.toolName) as MaskTool; const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport; if (toolInstance && viewport.element) { toolInstance._activateDraw(viewport.element); } console.log(`[MeasurementToolManager] MaskTool activated for viewport: ${viewportId}`); return true; } catch (error) { console.error(`[MeasurementToolManager] Error activating MaskTool:`, error); return false; } } /** * 停用掩码工具 */ static deactivateMaskTool(viewportId: string): boolean { const toolGroup = MeasurementToolManager.getToolGroup(viewportId); if (!toolGroup) return false; try { toolGroup.setToolPassive(MaskTool.toolName, { removeAllBindings: true }); console.log(`[MeasurementToolManager] MaskTool deactivated for viewport: ${viewportId}`); return true; } catch (error) { console.error(`[MeasurementToolManager] Error deactivating MaskTool:`, error); return false; } } /** * 清除指定 viewport 的所有掩码注解 */ static clearMaskAnnotations(viewportId: string): boolean { try { const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport; if (!viewport) return false; const annotations = cornerstoneTools.annotation.state.getAnnotations( MaskTool.toolName, viewport.element ); let removedCount = 0; annotations.forEach((annotation) => { if (annotation.annotationUID) { cornerstoneTools.annotation.state.removeAnnotation(annotation.annotationUID); removedCount++; } }); // 清除掩码效果 const toolGroup = MeasurementToolManager.getToolGroup(viewportId); const toolInstance = toolGroup?.getToolInstance(MaskTool.toolName) as MaskTool; if (toolInstance) { toolInstance.clearMaskEffect(viewport); } viewport.render(); console.log(`[MeasurementToolManager] Cleared ${removedCount} mask annotations for viewport: ${viewportId}`); return true; } catch (error) { console.error(`[MeasurementToolManager] Error clearing mask annotations:`, error); return false; } } } ``` --- ## ✅ 十、实施建议 ### 10.1 开发优先级 1. **高优先级**(核心功能) - ✅ 创建 MaskTool 类基本结构 - ✅ 实现绘制模式(点击添加顶点) - ✅ 实现完成绘制(双击) - ✅ 实现基本掩码效果(内部可见,外部变黑) 2. **中优先级**(交互优化) - ✅ 实现修改模式(拖拽顶点) - ✅ 实现删除功能 - ✅ 添加预览线(鼠标移动时) 3. **低优先级**(增强功能) - ⭕ 掩码效果动画 - ⭕ 掩码形状验证(自相交检测) - ⭕ 撤销/重做功能 ### 10.2 开发步骤建议 **第一阶段:核心工具开发** 1. 复制 `PolygonLengthMeasurementTool.ts` 作为模板 2. 重命名为 `MaskTool.ts` 3. 修改类名和 toolName 4. 移除长度测量相关代码 5. 添加 `applyMaskEffect()` 和 `clearMaskEffect()` 方法 **第二阶段:集成到系统** 1. 在 `stack.image.viewer.tsx` 中注册工具 2. 在 `MeasurementToolManager.ts` 中添加管理方法 3. 在 `ViewerContainer.tsx` 中添加 action 处理 **第三阶段:测试和优化** 1. 手动测试基本功能 2. 测试边界情况 3. 性能优化 4. 修复 bug ### 10.3 技术难点预警 ⚠️ **掩码效果实现**:Canvas clip 可能与 Cornerstone 的渲染流程冲突 - 建议先做原型验证可行性 - 如不可行,考虑备选方案(WebGL shader) ⚠️ **渲染性能**:掩码效果可能影响渲染帧率 - 需要性能测试 - 考虑使用 OffscreenCanvas 或 Web Worker ⚠️ **跨 viewport 同步**:多分格模式下的掩码独立性 - 确保每个 viewport 的掩码互不干扰 --- ## 📖 十一、总结 ### 核心要点 1. **工具设计**:MaskTool 继承自 AnnotationTool,复用 Cornerstone 的注解系统 2. **掩码效果**:使用 Canvas API 的 clip() 方法实现 3. **交互模式**:绘制模式(添加顶点)→ 修改模式(编辑形状) 4. **集成方式**:通过 Redux + ViewerContainer 统一管理 5. **数据持久化**:利用 annotation.state 自动持久化 ### 关键优势 - ✅ 复用现有架构,无需大规模重构 - ✅ 与现有测量工具保持一致的交互体验 - ✅ 支持多分格独立掩码 - ✅ 数据自动持久化 ### 风险提示 - ⚠️ Canvas clip 方案需验证可行性 - ⚠️ 性能优化可能需要额外工作 - ⚠️ 跨浏览器兼容性需充分测试 --- ## 📅 附录 ### A. 相关文件清单 | 文件路径 | 作用 | |---------|------| | `src/components/tools/MaskTool.ts` | 核心工具类(新建) | | `src/utils/measurementToolManager.ts` | 工具管理器(修改) | | `src/pages/view/components/viewers/stack.image.viewer.tsx` | 工具注册(修改) | | `src/pages/view/components/ViewerContainer.tsx` | 用户操作处理(修改) | | `src/pages/view/components/FunctionArea.tsx` | 主功能按钮(修改) | | `src/pages/view/components/RectCropPanel.tsx` | 裁剪面板按钮(无需修改) | | `src/states/view/rectCropPanelSlice.ts` | 状态管理(无需修改) | ### B. 参考资料 - [Cornerstone3D Documentation](https://www.cornerstonejs.org/) - [Canvas API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) - [PolygonLengthMeasurementTool.ts](src/components/measures/PolygonLengthMeasurementTool.ts) --- **文档版本**:v1.0 **创建日期**:2025-12-20 **最后更新**:2025-12-20 **作者**:Cline AI **审核状态**:待审核