# 添加掩码功能实现方案
## 📋 一、需求详细描述
### 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
**审核状态**:待审核