添加掩码功能实现方案.md 39 KB

添加掩码功能实现方案

📋 一、需求详细描述

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

状态管理层

  1. rectCropPanelSlice.ts

    • 职责:管理裁剪面板的 action 状态
    • 数据:currentAction: CropAction | null
  2. functionAreaSlice.ts

    • 职责:管理功能区的 action 状态
    • 数据:action: string | null

控制层

  1. ViewerContainer.tsx
    • 职责:监听 Redux 状态,调度工具激活
    • 方法:
      • 监听 rectCropPanel.currentAction
      • 监听 functionArea.action
      • 调用工具激活函数

工具管理层

  1. MeasurementToolManager.ts

    • 职责:统一管理掩码工具的生命周期
    • 方法:
      • activateMaskTool(viewportId: string): boolean
      • deactivateMaskTool(viewportId: string): boolean
      • clearMaskAnnotations(viewportId: string): boolean
  2. stack.image.viewer.tsx

    • 职责:注册工具、导出便捷函数
    • 方法:
      • activateMask(viewportId: string): boolean
      • deactivateMask(viewportId: string): boolean
      • clearMask(viewportId: string): boolean

核心工具层

  1. MaskTool.ts (新建)
    • 职责:核心掩码工具实现
    • 继承:AnnotationTool
    • 主要方法:
      • _activateDraw() - 激活绘制模式
      • _deactivateDraw() - 停用绘制模式
      • _activateModify() - 激活修改模式
      • _deactivateModify() - 停用修改模式
      • renderAnnotation() - 渲染掩码
      • applyMaskEffect() - 应用掩码效果到图像
      • _mouseClickDrawCallback() - 鼠标点击添加顶点
      • _mouseDoubleClickDrawCallback() - 双击完成绘制
      • _mouseMoveDrawCallback() - 鼠标移动预览
      • _mouseDragModifyCallback() - 拖拽修改

Cornerstone3D 框架

  1. AnnotationTool (基类)
  2. annotation.state (注解管理)
  3. viewport.render() (渲染引擎)

📊 三、Mermaid 图表

3.1 序列图 - 用户操作流程

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 流程图 - 掩码工具激活流程

flowchart TD
    Start([用户点击按钮]) --> Dispatch[Dispatch Redux Action]
    Dispatch --> Listen{ViewerContainer<br/>监听到变化?}
    Listen -->|是| GetViewports[获取选中的 viewportIds]
    Listen -->|否| End([结束])
    
    GetViewports --> HasSelection{有选中的<br/>viewport?}
    HasSelection -->|是| LoopSelected[遍历选中的 viewportIds]
    HasSelection -->|否| LoopAll[遍历所有可见 viewportIds]
    
    LoopSelected --> CallActivate[调用 activateMask(viewportId)]
    LoopAll --> CallActivate
    
    CallActivate --> GetToolGroup[获取 ToolGroup]
    GetToolGroup --> GroupExists{ToolGroup<br/>存在?}
    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 类设计

classDiagram
    class AnnotationTool {
        <<abstract>>
        +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 状态机图 - 工具状态转换

stateDiagram-v2
    [*] --> Inactive: 初始状态
    
    Inactive --> Drawing: 用户点击"添加Mask"
    Drawing --> Drawing: 单击添加顶点
    Drawing --> Modifying: 双击完成绘制<br/>(点数≥3)
    Drawing --> Inactive: 双击取消<br/>(点数<3)
    Drawing --> Inactive: 按 Esc 取消
    
    Modifying --> Modifying: 拖拽顶点
    Modifying --> Modifying: 移动整体
    Modifying --> Inactive: 点击"删除Mask"
    Modifying --> Inactive: 按 Delete 键
    
    Inactive --> [*]: 工具停用

📝 四、数据结构设计

4.1 MaskAnnotation 类型定义

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 状态结构(无需修改)

// 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 类

    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';
  • [ ] 添加激活方法

    static activateMaskTool(viewportId: string): boolean
    
    • 添加停用方法 typescript static deactivateMaskTool(viewportId: string): boolean
  • [ ] 添加清除方法

    static clearMaskAnnotations(viewportId: string): boolean
    

    3. src/pages/view/components/ViewerContainer.tsx

    • 导入激活函数 typescript import { activateMask, deactivateMask, clearMask } from './viewers/stack.image.viewer';
  • [ ] 在 useEffectswitch (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() 方法:

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
  • 需要在工具初始化时检查是否有已存在的掩码注解
  • 如果有,自动应用掩码效果
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 中的模式:

    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 核心结构(伪代码)

    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 扩展(伪代码)

// 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. 参考资料


文档版本:v1.0
创建日期:2025-12-20
最后更新:2025-12-20
作者:Cline AI
审核状态:待审核