Skip to content

Segmentation Module Documentation ​

Source: Utils/segmentation/

⚠️ 注意:文档中所有行号引用均来自历史版本,经过多轮重构(State Management Refactor + NrrdTools God Class Split + 继承→组合重构)后已过时,仅作结构参考,请以实际代码为准。

1. Architecture Overview ​

1.1 Class Composition(组合模式) ​

NrrdTools (Facade)
  ├── CanvasState              ← 纯状态容器(nrrd_states, gui_states, protectedData 等)
  ├── DrawToolCore             ← 事件编排、Undo/Redo、Tool 初始化与委托
  │     ├── CanvasState (共享)  ← 引用同一 CanvasState 实例
  │     └── RenderingUtils     ← 渲染/切片缓冲区工具
  ├── LayerChannelManager      ← 图层/通道/SphereType 管理(211 行)
  ├── SliceRenderPipeline      ← 切片渲染管线(453 行)
  └── DataLoader               ← 数据加载(222 行)
NrrdTools (Facade)
  ├── CanvasState              ← 纯状态容器(nrrd_states, gui_states, protectedData 等)
  ├── DrawToolCore             ← 事件编排、Undo/Redo、Tool 初始化与委托
  │     ├── CanvasState (共享)  ← 引用同一 CanvasState 实例
  │     └── RenderingUtils     ← 渲染/切片缓冲区工具
  ├── LayerChannelManager      ← 图层/通道/SphereType 管理(211 行)
  ├── SliceRenderPipeline      ← 切片渲染管线(453 行)
  └── DataLoader               ← 数据加载(222 行)

继承→组合重构(已完成): 原先的三级继承链 NrrdTools → DrawToolCore → CommToolsData 已完全替换为组合关系。CommToolsData 已删除。状态提取到 CanvasState,渲染方法提取到 RenderingUtils。

DrawToolCore 现在是纯编排层 — 所有工具逻辑已提取到各 Tool 类中。 DrawToolCore 通过 EventRouter 永久路由所有 pointer/wheel 事件,并在 start() render loop 中调度各 Tool 的渲染方法。 不再有手动 addEventListener/removeEventListener 调用(wheel 行为通过 activeWheelMode 状态派发)。

NrrdTools God Class Split(已完成): NrrdTools 经过 4 个 Phase 的重构,从 2007 行 God Class 拆分为 Facade + 3 个功能模块。公开 API 不变,内部通过 ToolContext + ToolHost Pick<> 类型解耦。

Callback 接口统一(已完成): 原先 10 个 *Callbacks 接口已统一为单一 ToolHost 接口(tools/ToolHost.ts),每个 Tool 通过 Pick<ToolHost, ...> 选择所需的宿主方法子集。

1.2 Canvas 层级结构 ​

共有 5 个系统 Canvas + N 个 Layer Canvas(默认 3 个 Layer)。

┌──────────────────────────────────┐
│ drawingCanvas (顶层交互层)         │  ← 捕获鼠标/笔事件,实时绘制笔画
├──────────────────────────────────┤
│ drawingSphereCanvas              │  ← 3D Sphere 工具的覆盖层
├──────────────────────────────────┤
│ drawingCanvasLayerMaster (合成层)  │  ← 所有可见 Layer 合成后的结果
│   ├─ layerTargets[layer1].canvas │  ← 隐藏的 per-layer canvas
│   ├─ layerTargets[layer2].canvas │
│   └─ layerTargets[layer3].canvas │
├──────────────────────────────────┤
│ displayCanvas (背景医学图像)       │  ← CT/MRI 切片图像
├──────────────────────────────────┤
│ originCanvas (从 Three.js 获取)   │  ← 缓存 Three.js 渲染的原始切片
├──────────────────────────────────┤
│ emptyCanvas (临时处理用)           │  ← 离屏画布,用于图像处理和格式转换
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ drawingCanvas (顶层交互层)         │  ← 捕获鼠标/笔事件,实时绘制笔画
├──────────────────────────────────┤
│ drawingSphereCanvas              │  ← 3D Sphere 工具的覆盖层
├──────────────────────────────────┤
│ drawingCanvasLayerMaster (合成层)  │  ← 所有可见 Layer 合成后的结果
│   ├─ layerTargets[layer1].canvas │  ← 隐藏的 per-layer canvas
│   ├─ layerTargets[layer2].canvas │
│   └─ layerTargets[layer3].canvas │
├──────────────────────────────────┤
│ displayCanvas (背景医学图像)       │  ← CT/MRI 切片图像
├──────────────────────────────────┤
│ originCanvas (从 Three.js 获取)   │  ← 缓存 Three.js 渲染的原始切片
├──────────────────────────────────┤
│ emptyCanvas (临时处理用)           │  ← 离屏画布,用于图像处理和格式转换
└──────────────────────────────────┘

Canvas 创建位置:

1.3 NrrdTools Facade 内部模块 ​

NrrdTools 通过 ToolContext 将共享状态传递给各模块,通过 Pick<ToolHost, ...> 类型别名声明宿主方法依赖:

ToolContext = {
  nrrd_states: NrrdState,
  gui_states: GuiState,
  protectedData: IProtected,
  cursorPage: ICursorPage,
  callbacks: IAnnotationCallbacks,
}
ToolContext = {
  nrrd_states: NrrdState,
  gui_states: GuiState,
  protectedData: IProtected,
  cursorPage: ICursorPage,
  callbacks: IAnnotationCallbacks,
}
模块文件职责HostDeps 类型
LayerChannelManagertools/LayerChannelManager.tssetActiveLayer/Channel/SphereType、可见性控制、自定义通道颜色LayerChannelHostDeps (3 methods)
SliceRenderPipelinetools/SliceRenderPipeline.ts切片轴配置、canvas 渲染、mask 重载、canvas 翻转、view/canvas 辅助SliceRenderHostDeps (10 methods)
DataLoadertools/DataLoader.tsNRRD 切片加载、legacy mask 加载、NIfTI voxel 加载DataLoaderHostDeps (7 methods)

NrrdTools 中的委托方法均为一行调用 (this.layerChannelManager.xxx()),不包含任何业务逻辑。

1.4 Layer 与 MaskVolume 对应关系 ​

每个 Layer 对应一个独立的 MaskVolume 实例:

protectedData.maskData.volumes = {
  "layer1": MaskVolume(width, height, depth, 1),
  "layer2": MaskVolume(width, height, depth, 1),
  "layer3": MaskVolume(width, height, depth, 1),
}
protectedData.maskData.volumes = {
  "layer1": MaskVolume(width, height, depth, 1),
  "layer2": MaskVolume(width, height, depth, 1),
  "layer3": MaskVolume(width, height, depth, 1),
}
  • 初始化(1x1x1 占位): CanvasState.ts constructor
  • 用实际 NRRD 尺寸重新初始化: DataLoader.setAllSlices() → tools/DataLoader.ts(已从 NrrdTools 提取)

2. NrrdTools 暴露的 API ​

⚠️ 行号均已过时。NrrdTools 经过 God Class Split 重构后(1300 行,13 个分区),方法实现已迁移到各提取模块(LayerChannelManager、SliceRenderPipeline、DataLoader),NrrdTools 中仅保留一行委托。行号仅供历史参考,请以实际代码为准。

实现位置:Layer/Channel 方法 → tools/LayerChannelManager.ts,渲染方法 → tools/SliceRenderPipeline.ts,数据加载 → tools/DataLoader.ts。

2.1 Layer & Channel 管理 ​

实现: tools/LayerChannelManager.ts,NrrdTools 中一行委托。

方法签名说明
setActiveLayer(layerId: string): void设置当前活跃 Layer,同时更新 fillColor/brushColor
setActiveChannel(channel: ChannelValue): void设置当前活跃 Channel (1–MAX_ENGINE_CHANNEL),更新画笔颜色
clearChannel(layerId: string, channel: number): void跨整个图层擦除单个 label,可撤销。图层不存在时抛错(刻意不走 getVolumeForLayer,否则它的回退会擦掉另一个图层),通道超出 [1, 255] 时抛 RangeError
setCurrentLayer(): { ctx, canvas } | null (ToolHost)绘制操作应当作用到的图层画布。当实例完全没有图层时返回 null —— 即 new NrrdTools(el, { layers: [] }) 这种只读参考视图。每个调用方都必须在 null 时跳过自己的工作,而不是直接解引用
getLayerVolume(layerId: string): Uint8Array | null该图层体素缓冲区的一份拷贝 —— 不是活的数组,否则调用方可以直接改动它,而没有任何东西会去让切片缓存失效。委托给 DrawToolCore.getLayerVolume
replaceLayerVolume(layerId, data, opts?): void替换整个缓冲区。{ undoable: true } 会压入一个 VolumeSnapshot。它自身不会触发 onLayerVolumeReplaced —— 该回调只在之后的 undo/redo 时触发,因为那时后端手里是更新的那一版,需要被告知回退
copyLayerData(sourceLayerId, targetLayerId): void图层之间的直接缓冲区拷贝,不可撤销。任一 volume 解析不到、或长度不一致时会 warn 并返回;保留目标图层的颜色映射
getActiveLayer(): string获取当前 Layer ID
getActiveChannel(): number获取当前 Channel 值
setLayerVisible(layerId, visible): void设置 Layer 可见性,触发 reloadMasksFromVolume()
isLayerVisible(layerId): boolean检查 Layer 是否可见
setChannelVisible(layerId, channel, visible): void设置某 Layer 下某 Channel 可见性,触发重渲染
isChannelVisible(layerId, channel): boolean检查 Channel 是否可见
getLayerVisibility(): Record<string, boolean>获取所有 Layer 可见性副本
getChannelVisibility(): Record<string, Record<number, boolean>>获取所有 Channel 可见性副本
hasLayerData(layerId): boolean检查 Layer 是否有非零数据
setLayerOpacity(layerId: string, opacity: number): void设置 per-layer 透明度 (0.1–1.0),触发 reloadMasksFromVolume()
getLayerOpacity(layerId: string): number获取指定 layer 的透明度(默认 1.0)
getLayerOpacityMap(): Record<string, number>获取所有 layer 的透明度值

2.2 Custom Channel Color API(Phase B) ​

Per-layer 自定义 channel 颜色。每个 layer 的 MaskVolume 有独立的 colorMap,互不影响。

方法签名说明
setChannelColor(layerId: string, channel: number, color: RGBAColor): void设置指定 layer 指定 channel 的颜色,触发重渲染和 onChannelColorChanged 回调
getChannelColor(layerId: string, channel: number): RGBAColor获取 RGBA 颜色对象
getChannelHexColor(layerId: string, channel: number): string获取 Hex 字符串(如 #ff8000)
getChannelCssColor(layerId: string, channel: number): string获取 CSS rgba() 字符串(如 rgba(255,128,0,1.00))
setChannelColors(layerId: string, colorMap: Partial<ChannelColorMap>): void批量设置一个 layer 的多个 channel 颜色(一次 reload)
setAllLayersChannelColor(channel: number, color: RGBAColor): void所有 layer 的同一 channel 设为相同颜色
resetChannelColors(layerId?: string, channel?: number): void重置为 MASK_CHANNEL_COLORS 默认颜色

内部机制:

  • syncBrushColor() — 私有方法,从当前 layer 的 volume 动态获取颜色更新 fillColor/brushColor
  • 在 setActiveLayer()、setActiveChannel()、setChannelColor() 等方法中自动调用

外部使用方式 ​

前提: nrrdTools 实例已创建,且 setAllSlices() 已调用完毕(即图像已加载,MaskVolume 已初始化)。

⚠️ 重要: 必须在图像加载完成(setAllSlices() 调用后)才能设置颜色,否则 protectedData.maskData.volumes[layerId] 为 undefined,调用会在方法内部触发 console.warn 后直接 return,静默失败,无任何视觉变化。

常见错误:在 onFinishedCopperInit 回调中调用 setChannelColor。该回调仅表示 Copper3D 渲染器初始化完毕,此时还没有加载任何影像文件,volumes["layer1"] 不存在。

typescript
// ❌ 错误:调用时机过早,MaskVolume 尚未创建
const onFinishedCopperInit = (data) => {
  nrrdTools.value = data.nrrdTools;
  nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // 静默失败
};

// ✅ 正确:在图像加载完成后调用(setAllSlices() 已在此之前执行)
const handleAllImagesLoaded = (res) => {
  nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // 生效
};
// ❌ 错误:调用时机过早,MaskVolume 尚未创建
const onFinishedCopperInit = (data) => {
  nrrdTools.value = data.nrrdTools;
  nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // 静默失败
};

// ✅ 正确:在图像加载完成后调用(setAllSlices() 已在此之前执行)
const handleAllImagesLoaded = (res) => {
  nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // 生效
};

场景 1:给某个 Layer 的某个 Channel 设置自定义颜色

typescript
// 将 layer2 的 channel 3 设为橙色
nrrdTools.setChannelColor('layer2', 3, { r: 255, g: 128, b: 0, a: 255 });
// 效果:layer2 上所有用 channel 3 画的 mask 变为橙色
// layer1、layer3 的 channel 3 颜色不受影响
// 将 layer2 的 channel 3 设为橙色
nrrdTools.setChannelColor('layer2', 3, { r: 255, g: 128, b: 0, a: 255 });
// 效果:layer2 上所有用 channel 3 画的 mask 变为橙色
// layer1、layer3 的 channel 3 颜色不受影响

场景 2:批量设置一个 Layer 的多个 Channel 颜色(推荐,只触发一次重渲染)

typescript
nrrdTools.setChannelColors('layer1', {
  1: { r: 255, g: 0,   b: 0,   a: 255 },   // channel 1 → 红色
  2: { r: 0,   g: 0,   b: 255, a: 255 },   // channel 2 → 蓝色
  3: { r: 255, g: 255, b: 0,   a: 255 },   // channel 3 → 黄色
});
// 只触发一次 reloadMasksFromVolume(),性能优于多次调用 setChannelColor()
nrrdTools.setChannelColors('layer1', {
  1: { r: 255, g: 0,   b: 0,   a: 255 },   // channel 1 → 红色
  2: { r: 0,   g: 0,   b: 255, a: 255 },   // channel 2 → 蓝色
  3: { r: 255, g: 255, b: 0,   a: 255 },   // channel 3 → 黄色
});
// 只触发一次 reloadMasksFromVolume(),性能优于多次调用 setChannelColor()

场景 3:所有 Layer 的同一 Channel 使用相同颜色

typescript
// 把所有 layer 的 channel 1 统一改为红色
nrrdTools.setAllLayersChannelColor(1, { r: 255, g: 0, b: 0, a: 255 });
// 把所有 layer 的 channel 1 统一改为红色
nrrdTools.setAllLayersChannelColor(1, { r: 255, g: 0, b: 0, a: 255 });

场景 4:读取当前颜色

typescript
// 读取 layer2 的 channel 3 当前颜色
const rgba = nrrdTools.getChannelColor('layer2', 3);
// → { r: 255, g: 128, b: 0, a: 255 }

const hex = nrrdTools.getChannelHexColor('layer2', 3);
// → "#ff8000"  (用于 canvas fillStyle 或 CSS color)

const css = nrrdTools.getChannelCssColor('layer2', 3);
// → "rgba(255,128,0,1.00)"  (用于 Vue style binding)
// 读取 layer2 的 channel 3 当前颜色
const rgba = nrrdTools.getChannelColor('layer2', 3);
// → { r: 255, g: 128, b: 0, a: 255 }

const hex = nrrdTools.getChannelHexColor('layer2', 3);
// → "#ff8000"  (用于 canvas fillStyle 或 CSS color)

const css = nrrdTools.getChannelCssColor('layer2', 3);
// → "rgba(255,128,0,1.00)"  (用于 Vue style binding)

场景 5:重置颜色

typescript
// 重置 layer2 的 channel 3 为默认颜色
nrrdTools.resetChannelColors('layer2', 3);

// 重置 layer2 的所有 channel 为默认颜色
nrrdTools.resetChannelColors('layer2');

// 重置所有 layer 的所有 channel 为默认颜色
nrrdTools.resetChannelColors();
// 重置 layer2 的 channel 3 为默认颜色
nrrdTools.resetChannelColors('layer2', 3);

// 重置 layer2 的所有 channel 为默认颜色
nrrdTools.resetChannelColors('layer2');

// 重置所有 layer 的所有 channel 为默认颜色
nrrdTools.resetChannelColors();

场景 6:设置颜色后通知 Vue UI 刷新

颜色修改后,canvas 会立即重渲染(reloadMasksFromVolume() 自动调用)。 但 Vue UI 中的 channel 颜色卡片(LayerChannelSelector.vue)需要手动触发刷新:

typescript
// 在 Vue 组件中,拿到 composable 的 refreshChannelColors
const { refreshChannelColors } = useLayerChannel({ nrrdTools });

// 设置颜色后调用 refresh,让 Vue UI 同步更新颜色显示
nrrdTools.setChannelColor('layer2', 3, { r: 255, g: 128, b: 0, a: 255 });
refreshChannelColors();   // 递增 colorVersion → 触发 dynamicChannelConfigs 重计算
// 在 Vue 组件中,拿到 composable 的 refreshChannelColors
const { refreshChannelColors } = useLayerChannel({ nrrdTools });

// 设置颜色后调用 refresh,让 Vue UI 同步更新颜色显示
nrrdTools.setChannelColor('layer2', 3, { r: 255, g: 128, b: 0, a: 255 });
refreshChannelColors();   // 递增 colorVersion → 触发 dynamicChannelConfigs 重计算

或者监听 onChannelColorChanged 回调来自动刷新:

typescript
// 在初始化时注册回调(nrrd_states 是 NrrdTools 内部状态,需通过 draw() 选项设置)
// ⚠️ 目前 onChannelColorChanged 挂载在 nrrd_states 上,暂不支持直接从外部设置
// 推荐方式:手动在 setChannelColor() 后调用 refreshChannelColors()
// 在初始化时注册回调(nrrd_states 是 NrrdTools 内部状态,需通过 draw() 选项设置)
// ⚠️ 目前 onChannelColorChanged 挂载在 nrrd_states 上,暂不支持直接从外部设置
// 推荐方式:手动在 setChannelColor() 后调用 refreshChannelColors()

场景 7:完整的初始化+颜色设置示例(Vue 组件中)

typescript
// LeftPanelCore.vue 或其他父组件
import emitter from '@/plugins/custom-emitter';

// 图像加载完成后(onFinishLoadAllCaseImages 事件)
const nrrdTools = ref<Copper.NrrdTools>();

emitter.on('Core:NrrdTools', (tools) => {
  nrrdTools.value = tools;
});

emitter.on('Segmentation:FinishLoadAllCaseImages', () => {
  // 此时 setAllSlices() 已调用完毕,MaskVolume 已初始化,可以安全设置颜色
  if (!nrrdTools.value) return;

  // 为 layer1 设置自定义颜色方案
  nrrdTools.value.setChannelColors('layer1', {
    1: { r: 255, g: 80,  b: 80,  a: 255 },   // 浅红
    2: { r: 80,  g: 180, b: 255, a: 255 },   // 浅蓝
  });

  // layer2 保持默认颜色,无需操作
});
// LeftPanelCore.vue 或其他父组件
import emitter from '@/plugins/custom-emitter';

// 图像加载完成后(onFinishLoadAllCaseImages 事件)
const nrrdTools = ref<Copper.NrrdTools>();

emitter.on('Core:NrrdTools', (tools) => {
  nrrdTools.value = tools;
});

emitter.on('Segmentation:FinishLoadAllCaseImages', () => {
  // 此时 setAllSlices() 已调用完毕,MaskVolume 已初始化,可以安全设置颜色
  if (!nrrdTools.value) return;

  // 为 layer1 设置自定义颜色方案
  nrrdTools.value.setChannelColors('layer1', {
    1: { r: 255, g: 80,  b: 80,  a: 255 },   // 浅红
    2: { r: 80,  g: 180, b: 255, a: 255 },   // 浅蓝
  });

  // layer2 保持默认颜色,无需操作
});

颜色值范围

RGBAColor 各字段取值 0-255(整数):

typescript
interface RGBAColor {
  r: number;  // 0-255
  g: number;  // 0-255
  b: number;  // 0-255
  a: number;  // 0-255,255 = 完全不透明,0 = 完全透明
}
interface RGBAColor {
  r: number;  // 0-255
  g: number;  // 0-255
  b: number;  // 0-255
  a: number;  // 0-255,255 = 完全不透明,0 = 完全透明
}

Channel a(alpha)决定 mask 的不透明度基准值。通常设为 255,实际渲染时还会乘以 gui_states.drawing.globalAlpha(默认 0.6)和 gui_states.layerChannel.layerOpacity[layerId](默认 1.0)。

Per-Layer Alpha(逐层透明度): 最终渲染透明度 = globalAlpha × layerOpacity[layerId]。全局 alpha 统一控制所有 layer,而 per-layer opacity 提供对每个 layer 的独立控制。

2.3 Keyboard & History ​

实现: 直接在 NrrdTools Facade 中(分区 4)。

方法签名说明
undo(): void撤销上一次绘画操作
redo(): void重做上一次撤销的操作
enterKeyboardConfig(): void进入键盘配置模式(抑制所有快捷键)
exitKeyboardConfig(): void退出键盘配置模式
setContrastShortcutEnabled(enabled: boolean): void启用/禁用 Contrast 快捷键
isContrastShortcutEnabled(): booleanContrast 快捷键是否启用
setKeyboardSettings(settings: Partial<IKeyBoardSettings>): void更新键盘快捷键绑定
getKeyboardSettings(): IKeyBoardSettings获取当前键盘设置快照

2.4 Data Loading ​

实现: tools/DataLoader.ts,NrrdTools 中一行委托。

方法签名说明
setAllSlices(allSlices: Array<nrrdSliceType>): void入口函数:加载 NRRD 切片,初始化所有 MaskVolume 到正确尺寸
initFromHeader(header: NrrdHeaderLike): void仅凭体数据几何信息就把图像元数据和 MaskVolume 尺寸定下来,此时还没有任何像素数据。不动 allSlicesArray
appendSlice(slice: nrrdSliceType, order: number): void把一个已加载的切片追加进对比度序列,并刷新 display slices / undo 状态
showPreviewSlice(header, slice, index, onSliceMove): void (NrrdTools)把一张 depth-1 切片显示为尚未加载完的序列中的第 index 张,并在真正的体数据接管之前把切片移动转交给 onSliceMove
isPreviewing(): boolean (NrrdTools)当前是否显示着预览切片
setMasksData(masksData, loadingBar?): void旧版加载方法(Legacy,待移除)
setMasksFromNIfTI(layerVoxels: Map<string, Uint8Array>, loadingBar?): void从 NIfTI 文件加载 mask 到 MaskVolume,并校验每个 buffer 的网格
registerNiftiMaskGrid(data: Uint8Array, dims: number[]): void (模块导出)以 buffer 身份为键记录其 NIfTI 体素网格,供 setMasksFromNIfTI 校验

批量加载 vs. 渐进式加载 ​

setAllSlices 是批量路径,仍然是绝大多数调用方要用的那个。它内部现在由两块更小的部分组合而成, 这两块也可以单独使用:

setAllSlices(allSlices)
  │
  ├─ initFromHeader(headerFromSlice(allSlices[0]))   → 图像元数据 + MaskVolume 尺寸
  │    └─ headerFromSlice 从共享 Volume 上读 dimensions/spacing/space_origin
  │
  ├─ allSlicesArray.length = 0                       → 替换,而不是追加
  └─ 对每个 slice 调 setContrastOrder(slice, i)      → 给 x/y/z 打上 contrastOrder
setAllSlices(allSlices)
  │
  ├─ initFromHeader(headerFromSlice(allSlices[0]))   → 图像元数据 + MaskVolume 尺寸
  │    └─ headerFromSlice 从共享 Volume 上读 dimensions/spacing/space_origin
  │
  ├─ allSlicesArray.length = 0                       → 替换,而不是追加
  └─ 对每个 slice 调 setContrastOrder(slice, i)      → 给 x/y/z 打上 contrastOrder

拆开是为了让渐进式加载可以先用后端 headers 响应(JSON 形式的 { dimensions, spacing, space_origin },即同一个 NrrdHeaderLike 形状)把尺寸定下来, 再用 appendSlice 随着切片陆续到达一个一个喂进去。

预览切片 3.11.6 ​

NrrdTools.showPreviewSlice(header, slice, index, onSliceMove) 是渐进式路径的门面, 而它正是由上面那两块拼出来的:

showPreviewSlice(header, slice, index, onSliceMove)
  │
  ├─ 几何信息和当前的不一致? → dataLoader.initFromHeader(header)
  │     (逐字段比较 dimensions / spacing / space_origin,因此翻片过程中反复调用
  │       不会重复初始化)
  │
  ├─ slice.z.initIndex = index          ◀ 完整序列中的索引放在这里,**不是**写进
  │                                        depth-1 切片自己的 `index`,那样会越界
  ├─ allSlicesArray.length = 0
  ├─ sliceRenderPipeline.resetInitState()
  ├─ dataLoader.appendSlice(slice, 0)   ◀ 只有一张切片,所以这里用 appendSlice 是对的
  └─ _previewSliceMove = onSliceMove
showPreviewSlice(header, slice, index, onSliceMove)
  │
  ├─ 几何信息和当前的不一致? → dataLoader.initFromHeader(header)
  │     (逐字段比较 dimensions / spacing / space_origin,因此翻片过程中反复调用
  │       不会重复初始化)
  │
  ├─ slice.z.initIndex = index          ◀ 完整序列中的索引放在这里,**不是**写进
  │                                        depth-1 切片自己的 `index`,那样会越界
  ├─ allSlicesArray.length = 0
  ├─ sliceRenderPipeline.resetInitState()
  ├─ dataLoader.appendSlice(slice, 0)   ◀ 只有一张切片,所以这里用 appendSlice 是对的
  └─ _previewSliceMove = onSliceMove

切片移动的拦截。 只要 _previewSliceMove 有值,DragSliceTool 就会把请求的索引夹取到 [minIndex, maxIndex],再交给 previewSliceMove(target);返回 true 表示这次移动已被消费, 工具随即返回、不去碰显示内容。当前显示的切片深度为 1,移动它只会画出空白 —— 宿主应当去取回那一张 平面,然后再调一次 showPreviewSlice。

这个钩子是作为 ToolHost 依赖到达 DragSliceTool 的(DragSliceHostDeps 新增了 previewSliceMove),经由 DragOperator.setPreviewSliceMove 接线。在 NrrdTools 设置它之前, 默认实现返回 false,因此所有移动都照常进行。

_previewSliceMove 会被 setAllSlices、switchSlicesPreservingView 和 reset 清空 —— 这正是真正的体数据接管的三条路径。isPreviewing() 报告它当前是否有值。

appendSlice 不是 setAllSlices 的循环替代品

它每个切片都会调一次 setDisplaySlicesBaseOnAxis(),而后者是从一个还在增长的 allSlicesArray 上重建 display slice 列表的,它的 skipSlicesDic 记账逻辑假定自己看到的是最终数组。 如果 N 个切片已经全在手上,请用 setAllSlices。

为什么 nrrd_x_mm 不需要 canvas 也能算出来。 这几个 mm 尺寸原来是从 slice.z.canvas.width/height 和 slice.x.canvas.width 上读的 —— 也就是 VolumeSlice 抽出来 那个平面的物理尺寸。这些 canvas 的尺寸恒等于 dimensions[axis] * spacing[axis] (Volume.extractPerpendicularPlane 把 planeWidth/planeHeight 设成体素数乘 spacing), 所以同样的值仅凭 dimensions 和 spacing 就能推出来。canvas.width 是 HTML 的 unsigned long, 非整数乘积会被截断(44.8 → 44);initFromHeader 用 Math.floor 复现了这一点 —— 因为 spacing 是一个模长、永远非负,所以 floor 和 DOM 的截断结果一致。

对"只抽部分轴"的容忍。 headerFromSlice 和 setContrastOrder 读/写的是 slice.x ?? slice.y ?? slice.z,而不是直接假定 .x 存在 —— 因为 只抽部分轴的加载在这一步可能只抽了 z。之后由 ensureAxisExtracted 补抽出来的面会从兄弟面继承 contrastOrder,因此和加载时就抽出来的面没有区别。

Mask 网格校验 ​

setMasksFromNIfTI 以前是长的截断、短的补零,结果是错误网格的 mask 被静默地画到了错误的体素上。 现在它会把 buffer 注册的网格和 nrrd_states.image.dimensions 比对,不匹配就拒绝:

对 layerVoxels 中每个 [layerId, rawData]
  ├─ volume 不存在?               → console.warn,跳过该层
  ├─ maskGridByBuffer.get(rawData) → undefined,或维度与图像维度不一致?
  │     → console.error + notifyUser,跳过该层   (绝不截断,绝不补零)
  └─ volume.setRawData(rawData)
对 layerVoxels 中每个 [layerId, rawData]
  ├─ volume 不存在?               → console.warn,跳过该层
  ├─ maskGridByBuffer.get(rawData) → undefined,或维度与图像维度不一致?
  │     → console.error + notifyUser,跳过该层   (绝不截断,绝不补零)
  └─ volume.setRawData(rawData)

notifyUser(message, level) 是一个 ToolHost 依赖,背后是公开的 NrrdTools.notifyUser 属性。 它默认写 console:本引擎自身不带 UI,而去 import 宿主应用的 UI 正是让这个包无法独立构建的原因。 宿主如果有 toast 或 banner,在构造之后把自己的实现赋上去即可。

没有注册过网格的 buffer 一律拒绝,而不是假定它匹配。这个注册表是一个按 buffer 身份索引的 WeakMap —— 不是按 layer id —— 所以调用方可以先解码好几个图层再调 setMasksFromNIfTI, 而且表项会随 buffer 一起被回收。

2.5 Display & Rendering ​

实现: tools/SliceRenderPipeline.ts,NrrdTools 中一行委托。

方法签名说明
resizePaintArea(factor: number): void调整画布缩放
reloadMasksFromVolume(): void (private)核心重渲染:从 MaskVolume 重新渲染所有 Layer 到 Canvas
flipDisplayImageByAxis(): void翻转 CT 图像以正确显示
redrawDisplayCanvas(): void重绘 contrast 图像到 displayCanvas
setEmptyCanvasSize(axis?): void根据 axis 设置 emptyCanvas 尺寸
setMaskRenderMode(mode: MaskRenderMode): void全局设置 "fill" / "outline",然后 reloadMasksFromVolume()。立即重绘,因为没有别的事件会把这个变化带到屏幕上
getMaskRenderMode(): MaskRenderMode读取当前模式

填充 vs. 描边渲染 ​

两条渲染路径,同一个轮廓。extractLabelPolygons / extractLabelContours 覆盖区域本身; extractLabelBoundarySegments / extractLabelOutline 描出它的边缘。两者走同样的 cell、同样的 采样、同样的越界规则、同样的 +0.5 位移,所以不管画哪一种,mask 的边落在完全相同的位置 —— 切换模式时边缘不会"看起来移动了"。

RenderingUtils
  │
  ├─ renderSliceToCanvas(...)   → drawSlice(..., outline = maskRenderMode === "outline")
  └─ renderSliceForBake(...)    → drawSlice(..., outline = false)      ◀ 永远是实心
RenderingUtils
  │
  ├─ renderSliceToCanvas(...)   → drawSlice(..., outline = maskRenderMode === "outline")
  └─ renderSliceForBake(...)    → drawSlice(..., outline = false)      ◀ 永远是实心

renderSliceForBake 不是优化,而是正确性要求。 对于要烘焙的工具(syncLayerSliceData —— 铅笔和橡皮擦)来说,图层画布不是 mask 的一张图片,而是重建 mask 所依据的输入:烘焙会用画布 上找到的像素替换掉 MaskVolume 里的整个切片。而 outline 是一张有损的图片 —— 它说明 mask 在 哪里结束,而不是它包含什么 —— 所以把它烘焙回去写入的是一圈圈环,并抹掉该切片上每一个 mask 的 内部,连带该图层上所有其他 finding 一起。一笔铅笔就够了。

它被做成独立入口,而不是在显示路径上穿一个 flag,是因为"即将被读回去的渲染"和"即将被人看的渲染" 需求本来就不同;这两者只在"画 mask 只有一种方式"的年代恰好重合。

DrawingTool 在两个地方用到它:

方法时机原因
redrawPreviousImageToLayerCtx铅笔填充之前落在这里的东西马上会被 syncLayerSliceData 原样读回去
solidifyLayerForErasepointer-down 时,若橡皮擦处于激活状态橡皮擦擦除的是与当前通道颜色匹配的像素;对着一个 outline,mask 内部没有任何东西可匹配,所以在病灶上拖一遍什么都擦不掉 —— 而随后的烘焙还会把那圈环写回去

solidifyLayerForErase 是无条件执行的,而不是去判断当前显示模式:在 fill 模式下它重绘的就是屏幕 上已有的东西,而在这里加一个依赖模式的分支,就等于多一个可能和渲染器脱节的地方。显示模式会在 pointer-up 时经由 refreshLayerFromVolume 恢复。

两者都在 contourPaint.ts 里 3.11.5

缓存条目、路径构建和绘制都已从 RenderingUtils 抽到 contourPaint.ts,这样 MaskMirror(§2.7) 就能用完全相同的代码去绘制源视图的 mask —— 同样的缓存结构、同样的填充/描边选择、同样的描边几何。 它导出的是 ContourEntry、LabelSlice、contourEntry(...)、paintContours(...) 和 OUTLINE_WIDTH_PX。RenderingUtils 保留切片 buffer、合成逻辑,以及那张按图层索引的缓存表。

路径缓存 ​

渲染模式不在缓存 key 里。每个条目持有两张惰性构建的 map:

ts
{
  key: `${axis}:${sliceIndex}:${volume.getVersion()}`,
  W, H,
  labels:   number[],
  fills:    Map<number, Path2D>,   // 该切片第一次用 fill 模式时构建
  outlines: Map<number, Path2D>,   // 该切片第一次用 outline 模式时构建
}
{
  key: `${axis}:${sliceIndex}:${volume.getVersion()}`,
  W, H,
  labels:   number[],
  fills:    Map<number, Path2D>,   // 该切片第一次用 fill 模式时构建
  outlines: Map<number, Path2D>,   // 该切片第一次用 outline 模式时构建
}

命中缓存时,drawSlice 会检查它需要的那张 map 是否已填满(size !== labels.length),没满就 构建。因此切换模式时每个可见 label 只在第一次付一次提取,之后都是纯缓存命中;而一个从不离开 fill 模式的会话永远不会构建 outline。两种都预先构建的话,会让每一次切片滚动都为一个大多数会话 根本不会打开的模式买单。

在未变换的 context 上描边 ​

outline 模式不在 ctx.scale(sx, sy) 之下描边。体素→显示的映射被放进一个 DOMMatrix、 作用在路径上(display.addPath(path, matrix)),context 保持未变换,于是 lineWidth 是按 屏幕像素度量的(OUTLINE_WIDTH_PX = 1.5)。

在缩放之下描边会错两次:线宽会随放大而变粗 —— 而放大恰恰是读片者为了看清边界才做的;并且由于体素 很少是各向同性的(sx !== sy),线在一个轴上还会比另一个轴更粗。

冠状面(axis === 'y')的 Z 翻转也被折进同一个矩阵,而不是作用在 context 上。

为什么线段不做连接

extractLabelBoundarySegments 按 cell 逐个吐出开放的两点线段,并不把它们串成折线。Canvas 会在 一个合成 pass 里栅格化整个 Path2D,所以共享端点相接的线段既不会出现接缝,圆头端帽重叠处也不会 出现 alpha 叠加 —— 串成折线什么也换不来,还要多一趟 chaining。lineJoin / lineCap 设为 'round' 来闭合这些接点。

2.6 Programmatic Sphere Placement ​

方法签名说明
setCalculateDistanceSphere(x: number, y: number, sliceIndex: number, cal_position: SphereType): void编程式放置 calculator sphere,模拟完整的鼠标点击流程

参数:

  • x, y — 未缩放的图像空间坐标(方法内部会自动乘以 sizeFactor)
  • sliceIndex — 目标切片索引
  • cal_position — Sphere 类型: "tumour" / "skin" / "nipple" / "ribcage"

内部流程 (模拟 DrawToolCore.handleSphereClick + pointerup):

setCalculateDistanceSphere(x, y, sliceIndex, cal_position)
  │
  ├─ sphereRadius = 5
  ├─ setSliceMoving(...)                          → 导航到目标切片
  │
  ├─ --- simulate mouse-down ---
  │  ├─ mouseX = x * sizeFactor
  │  ├─ sphereOrigin[axis] = [mouseX, mouseY, sliceIndex]
  │  ├─ crosshairTool.setUpSphereOrigins(...)     → 计算三轴 origin
  │  ├─ tumourSphereOrigin = deepCopy(sphereOrigin)  → 按 cal_position 存储
  │  └─ drawCalculatorSphere(radius)              → 绘制预览
  │
  └─ --- simulate mouse-up ---
     ├─ sphereTool.writeAllCalculatorSpheresToVolume()  → 写入 sphereMaskVolume
     └─ sphereTool.refreshSphereCanvas()               → 重渲染 overlay
setCalculateDistanceSphere(x, y, sliceIndex, cal_position)
  │
  ├─ sphereRadius = 5
  ├─ setSliceMoving(...)                          → 导航到目标切片
  │
  ├─ --- simulate mouse-down ---
  │  ├─ mouseX = x * sizeFactor
  │  ├─ sphereOrigin[axis] = [mouseX, mouseY, sliceIndex]
  │  ├─ crosshairTool.setUpSphereOrigins(...)     → 计算三轴 origin
  │  ├─ tumourSphereOrigin = deepCopy(sphereOrigin)  → 按 cal_position 存储
  │  └─ drawCalculatorSphere(radius)              → 绘制预览
  │
  └─ --- simulate mouse-up ---
     ├─ sphereTool.writeAllCalculatorSpheresToVolume()  → 写入 sphereMaskVolume
     └─ sphereTool.refreshSphereCanvas()               → 重渲染 overlay

典型用法 (后端返回 sphere 坐标后调用):

typescript
// 后端 AI 检测到 tumour 位置后,自动放置 sphere
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');
nrrdTools.setCalculateDistanceSphere(200, 150, 42, 'skin');
// 后端 AI 检测到 tumour 位置后,自动放置 sphere
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');
nrrdTools.setCalculateDistanceSphere(200, 150, 42, 'skin');

2.7 Mask 镜像 3.11.5 ​

实现: tools/MaskMirror.ts,绘制走 contourPaint.ts。

方法签名说明
setMaskMirror(source: NrrdTools | null, mirrorToSource?: ArrayLike<number>): void在当前视图上绘制 source 的 mask。传 null —— 或者传入当前视图自身 —— 表示解除
getMaskMirrorSource(): NrrdTools | null当前被镜像的那个视图

MaskMirror 不持有任何 mask 数据。 每一次绘制都实时读取源视图的 CanvasState:volume、 通道颜色、图层/通道可见性、per-layer 透明度,以及 maskRenderMode。因为不存在副本,所以既没有 需要同步的东西,编辑发生时也没有需要失效的东西。

NrrdTools A(源)                          NrrdTools B(镜像)
  renderer.onComposited ──┐                 renderer.mirror = MaskMirror(B.state, A.state, m)
                          │                            │
  A.scheduleMirrorRefresh()                            │
   └─ rAF,已合并 ────────┴──► B.compositeAllLayers() ─┘
                                 └─ …先画 B 自己的图层,再 mirror.paint(masterCtx, w, h)
NrrdTools A(源)                          NrrdTools B(镜像)
  renderer.onComposited ──┐                 renderer.mirror = MaskMirror(B.state, A.state, m)
                          │                            │
  A.scheduleMirrorRefresh()                            │
   └─ rAF,已合并 ────────┴──► B.compositeAllLayers() ─┘
                                 └─ …先画 B 自己的图层,再 mirror.paint(masterCtx, w, h)

compositeAllLayers 在画完本视图自己的图层之后调用 this.mirror?.paint(...),然后触发 onComposited。所以镜像是画在做镜像的那个视图自己的内容之上的 —— 通常它什么内容都没有, 因为这类视图一般就是用 layers: [] 构造的。

当 hasMaskMirror() 为真时,SliceRenderPipeline 会短路成一次普通的 compositeAllLayers(): 做镜像的视图没有属于自己的图层需要渲染。

采样 —— mirrorSliceLabels ​

变换是一个行主序 4×4 矩阵,从当前视图的体素 (x, y, z, 1) 映射到源视图的体素。切片是按 MaskVolume.getSliceUint8 的布局、以当前视图自己的网格构建的,所以共享的绘制代码可以原样绘制, 翻转逻辑也一并适用。

情形走哪条路
isIdentity(m) 且维度一致直接返回源自己的切片 —— 不做重采样
isDiagonal(m)(每个轴映射到它自己的轴)coveredRanges 给出每个镜像索引所跨越的源索引闭区间。当镜像体素比源体素粗时,取它覆盖的所有源体素中的第一个 label
其他情况取最近的源体素

粗体素那条规则最值得知道:它意味着比镜像切片还薄的 finding 仍会出现在包含它的那张切片上, 而不是在采样之间漏掉。映射到源之外的格子读作 0。

缓存与失败处理 ​

每个源图层一个 ContourEntry,键为 axis:index:volume.getVersion()。镜像还会记录每个条目是从 哪个 MaskVolume 对象构建的,一旦该对象变了就丢弃条目 —— 病例加载会换掉这个对象,而单看版本号 是发现不了的。

换一个变换就意味着换一个新的 MaskMirror 实例,所以缓存永远不可能留着用另一个变换算出来的 contour。

paint 在两个层面上做了防御:每个图层的 try 会吞掉"源视图对这一张切片还没准备好"的情况 (该图层什么都不画),而 finally 会恢复画布,使得抛异常不会把某个图层的 globalAlpha 或变换 残留在 master canvas 上。此外,当源视图处于球体模式时它会提前返回 —— 该模式下源视图会隐藏自己的 图层,所以镜像显示的就是源视图所显示的。

2.8 其他 API ​

实现: 直接在 NrrdTools Facade 中(分区 5 View Control、分区 6 Data Getters)。

方法说明
drag(opts?)启用拖拽切片功能
dispose()拆除:eventRouter.unbindAll()、取消待处理的切片步进 requestAnimationFrame、清掉 setIsDrawFalse 定时器,并从两端断开 mask 镜像链接(销毁源视图会解除它的所有镜像并把它们重绘为空)。只释放实例挂到自己 DOM 子树之外的东西 —— 其中 window 的 blur 监听器才是钉住整张引擎对象图的那一个。调用后实例不可再用;幂等
setAnnotationSuspended(bool)在 DrawToolCore.onCanvasPointerDown 处拦截所有会写入 mask 的输入。切片浏览、缩放、平移和十字准线仍然可用(见 7.1)
isAnnotationSuspended()查询当前是否处于暂停标注状态
setSliceOrientation(axis)切换观察轴。会先为每个已加载的对比度调用 ensureAxisExtracted,所以只抽了部分轴的加载仍然可以切面
addSkip(index) / removeSkip(index)隐藏 / 显示一个对比度。index 指向完整对比度列表
setSkips(entries)addSkip/removeSkip 的批量形式:写完所有 skipSlicesDic 条目后,只调一次 resetDisplaySlicesStatus()
setContrastIndex(index)在 displaySlices(被选中的子集)内切换对比度,并夹取到其边界内
commitSkipsAndContrast(entries, i)setSkips + setContrastIndex 合并为一次刷新。contrastNum 在刷新之前写入,使这次刷新的回调直接画出目标对比度;夹取在之后针对重建好的 displaySlices 执行
commitSeriesLoad(slices, entries, i)病例加载收尾:替换 allSlicesArray,再委托给 commitSkipsAndContrast —— 一次刷新代替三次
switchAllSlicesArrayData(slices)替换已加载的序列并重建显示(会重置视图状态)
switchSlicesPreservingView(slices)通过 switchPreservingView() 替换序列,保留切片索引、缩放和平移
setSliceMoving(step)移动当前切片;多次步进会被累加并在一次 requestAnimationFrame 中应用
setMainAreaSize(factor)把缩放系数夹取到 [view.minSizeFactor, 8],调整绘制区域大小并重置其 UI 位置
setMinSizeFactor(f)sizeFactor 的下限,夹取到 [0.05, 1](非有限值 → 1)。同时更新 guiParameterSettings.advance.mainAreaSize.min;若当前系数已低于新下限,会抬上去并重绘。引擎从不替你重算 —— 它同时取决于图像/轴的尺寸和宿主面板的尺寸
getMinSizeFactor()读取当前下限
setBaseDrawDisplayCanvasesSize(size)设置 Canvas 基础尺寸 (1-8)
setupGUI(gui)设置 dat.gui 面板
enableContrastDragEvents(callback)启用 contrast 拖拽事件
getCurrentImageDimension()获取图像体素维度 [w, h, d]
getVoxelSpacing()获取体素间距 (mm)
getSpaceOrigin()获取世界坐标系原点
getMaxSliceNum()获取各轴最大切片数
getCurrentSlicesNumAndContrastNum()获取当前切片索引和 contrast 索引
getMaskData()获取 IMaskData 原始数据结构
getContainer()获取内部主区域容器
getDrawingCanvas()获取顶层交互 Canvas
getNrrdToolsSettings()获取完整 NrrdState 快照(5 个子对象)
executeAction(action)执行预定义动作:"undo", "redo", "clearActiveSliceMask", "clearActiveLayerMask", "resetZoom", "downloadCurrentMask", "gaussianSmooth"。其中 "gaussianSmooth" 接受可选参数 opts?: { sigma?: number }

3. States(状态) ​

3.1 nrrd_states (NrrdState) ​

类型: NrrdState class(定义: coreTools/NrrdState.ts) 接口: INrrdStates extends IImageMetadata, IViewState, IInteractionState, ISphereState, IInternalFlags(定义: core/types.ts)

NrrdState 将 44 个属性分组为 5 个语义子对象:

nrrd_states.image (IImageMetadata) ​

字段类型说明
dimensions[width, height, depth]体素维度
nrrd_x_pixel / y / znumber各轴像素数
voxelSpacingnumber[]体素间距
spaceOriginnumber[]空间原点
layersstring[]Layer ID 列表

nrrd_states.view (IViewState) ​

字段类型说明
currentSliceIndexnumber当前切片索引
maxIndex / minIndexnumber切片索引范围
changedWidth / changedHeightnumberCanvas 显示尺寸
sizeFactornumber缩放因子。所有写入它的地方都会夹取到 [minSizeFactor, 8] —— setMainAreaSize、ZoomTool 的滚轮、NrrdState.setZoomFactor
minSizeFactornumbersizeFactor 的下限,取值 [0.05, 1]。默认 1(即不小于 1:1)。在 reset() 和病例加载之后保留
originWidth / originHeightnumber原始图像尺寸

nrrd_states.interaction (IInteractionState) ​

字段类型说明
mouseOverX / mouseOverYnumber鼠标位置
mouseOverboolean鼠标是否在画布上
cursorPageX / cursorPageYnumber光标页面坐标
drawStartPosICommXY绘画起始点

nrrd_states.sphere (ISphereState) ​

字段类型说明
sphereOrigin / skinSphereOrigin 等ICommXYZ | null各类型 sphere 原点
sphereRadiusnumbersphere 半径
sphereBrushRadiusnumberSphereBrush/SphereEraser 半径 (1-50)
sphereMaskVolumeMaskVolume | nullsphere 体积数据

nrrd_states.flags (IInternalFlags) ​

字段类型说明
stepClearnumber清除步长(内部用)
clearAllFlagboolean当前是否为全层清除操作
loadingMaskDataboolean是否正在加载 mask 数据

⚠️ loadMaskByDefault 和 isCalcContrastByDrag 字段已不存在,文档之前有误。

⚠️ INrrdStates 扁平接口保留用于向后兼容(extends 所有 5 个子接口),但运行时使用 NrrdState 类实例,属性通过 nrrd_states.image.xxx、nrrd_states.view.xxx 等访问。

3.2 gui_states (GuiState) ​

类型: GuiState class(定义: coreTools/GuiState.ts) 接口: IGUIStates extends IToolModeState, IDrawingConfig, IViewConfig, ILayerChannelState(定义: core/types.ts)

GuiState 将 20 个属性分组为 4 个语义子对象:

gui_states.mode (IToolModeState) ​

字段类型说明
pencilboolean铅笔工具激活
eraserboolean橡皮擦工具激活
sphereboolean球体工具激活
sphereBrushboolean球形画笔工具激活
sphereEraserboolean球形橡皮擦工具激活
activeSphereType"tumour" | "skin" | "nipple" | "ribcage"当前 sphere 类型

gui_states.drawing (IDrawingConfig) ​

字段类型说明
globalAlphanumber全局透明度 (0.6)
lineWidthnumber线宽
color / fillColor / brushColorstring画笔颜色 (Hex)
brushAndEraserSizenumber画笔/橡皮擦大小
maskRenderModeMaskRenderMode"fill"(默认)或 "outline"。对所有图层和通道同时生效;只被渲染路径读取,写入路径从不读它

gui_states.viewConfig (IViewConfig) ​

字段类型说明
mainAreaSizenumber主区域大小
dragSensitivitynumber拖拽灵敏度
cursor / defaultPaintCursorstring光标样式
readyToUpdateboolean准备更新标志

gui_states.layerChannel (ILayerChannelState) ​

字段类型说明
layerstring当前活跃 Layer (默认 "layer1")
activeChannelnumber当前活跃 Channel (1-255;0 = 空/已擦除)
layerVisibilityRecord<string, boolean>Layer 可见性
channelVisibilityRecord<string, Record<number, boolean>>Channel 可见性
layerOpacityRecord<string, number>Per-layer 透明度 (0.1–1.0,默认 1.0)

3.3 protectedData (IProtected) ​

定义位置: CanvasState.ts constructor

字段说明
axis当前视图轴 "x" / "y" / "z"
maskData.volumesRecord<string, MaskVolume> — 每个 Layer 对应的 3D 体积
layerTargetsMap<string, ILayerRenderTarget> — 每个 Layer 的 canvas+ctx
canvases5 个系统 Canvas
ctxes对应的 2D Context
isDrawing当前是否正在绘画

⚠️ Is_Shift_Pressed / Is_Ctrl_Pressed 已删除,键盘修饰键状态现在由 EventRouter 内部管理,不再暴露到 protectedData。


4. Callbacks ​

4.1 onMaskChanged / getMaskData (后端同步) ​

存储位置: CanvasState.annotationCallbacks.onMaskChanged(IAnnotationCallbacks 接口)

⚠️ 注意: 原文档中的 nrrd_states.getMask 字段已不存在。外部通过 nrrdTools.draw({ getMaskData: ... }) 注册,内部映射到 annotationCallbacks.onMaskChanged。

ts
// IAnnotationCallbacks 接口 (core/types.ts)
onMaskChanged: (
  sliceData: Uint8Array,    // 当前切片的原始体素数据
  layerId: string,          // layer 名
  channelId: number,        // active channel
  sliceIndex: number,       // 切片索引
  axis: "x" | "y" | "z",   // 当前轴
  width: number,            // 切片宽度
  height: number,           // 切片高度
  clearFlag: boolean        // 是否为清除操作
) => void
// IAnnotationCallbacks 接口 (core/types.ts)
onMaskChanged: (
  sliceData: Uint8Array,    // 当前切片的原始体素数据
  layerId: string,          // layer 名
  channelId: number,        // active channel
  sliceIndex: number,       // 切片索引
  axis: "x" | "y" | "z",   // 当前轴
  width: number,            // 切片宽度
  height: number,           // 切片高度
  clearFlag: boolean        // 是否为清除操作
) => void

调用时机: 每次绘画结束(mouseup)、undo/redo 之后。

4.2 onLayerVolumeCleared ​

存储位置: CanvasState.annotationCallbacks.onLayerVolumeCleared

ts
onLayerVolumeCleared: (layerId: string) => void
onLayerVolumeCleared: (layerId: string) => void

4.3 onChannelColorChanged(Phase B 新增) ​

存储位置: CanvasState.annotationCallbacks.onChannelColorChanged(IAnnotationCallbacks 接口,core/types.ts)

⚠️ 注意: 原文档说定义在 INrrdStates 上,已不正确。该回调现在属于 IAnnotationCallbacks,存储在 CanvasState.annotationCallbacks 中,不在 nrrd_states 上。

ts
onChannelColorChanged: (layerId: string, channel: number, color: RGBAColor) => void
onChannelColorChanged: (layerId: string, channel: number, color: RGBAColor) => void

调用时机: NrrdTools.setChannelColor() 修改颜色后触发。默认空实现,暂不支持从外部直接注册(推荐通过 setChannelColor() 后手动调用 refreshChannelColors() 刷新 Vue UI)。

4.4 onSphereChanged / onCalculatorPositionsChanged ​

存储位置: CanvasState.annotationCallbacks(IAnnotationCallbacks,外部通过 draw() 注册)

onSphereChanged (getSphereData in IDrawOpts): Sphere 模式下左键松开时调用。

ts
onSphereChanged: (sphereOrigin: number[], sphereRadius: number) => void
// sphereOrigin = [mouseX, mouseY, sliceIndex] — z-axis 坐标
// sphereRadius = 半径 (1-50 像素)
onSphereChanged: (sphereOrigin: number[], sphereRadius: number) => void
// sphereOrigin = [mouseX, mouseY, sliceIndex] — z-axis 坐标
// sphereRadius = 半径 (1-50 像素)

onCalculatorPositionsChanged (getCalculateSpherePositionsData in IDrawOpts): Sphere 模式下放置 sphere 后调用(所有类型通用)。

ts
onCalculatorPositionsChanged: (
  tumourSphereOrigin: ICommXYZ | null,  // channel 1
  skinSphereOrigin: ICommXYZ | null,    // channel 4
  ribSphereOrigin: ICommXYZ | null,     // channel 3
  nippleSphereOrigin: ICommXYZ | null,  // channel 2
  axis: "x" | "y" | "z"
) => void
// 每个 origin 为 { x: [mx, my, slice], y: [...], z: [...] }
// null 表示该类型尚未放置
onCalculatorPositionsChanged: (
  tumourSphereOrigin: ICommXYZ | null,  // channel 1
  skinSphereOrigin: ICommXYZ | null,    // channel 4
  ribSphereOrigin: ICommXYZ | null,     // channel 3
  nippleSphereOrigin: ICommXYZ | null,  // channel 2
  axis: "x" | "y" | "z"
) => void
// 每个 origin 为 { x: [mx, my, slice], y: [...], z: [...] }
// null 表示该类型尚未放置

Channel 映射 (exported as SPHERE_CHANNEL_MAP):

Sphere TypeLayerChannel颜色
tumourlayer11#10b981 (Emerald)
nipplelayer12#f43f5e (Rose)
ribcagelayer13#3b82f6 (Blue)
skinlayer14#fbbf24 (Amber)

⚠️ 当前 sphere 数据不写入 layer MaskVolume,仅作为 overlay 显示。Channel 映射预留供未来使用。


5. MaskVolume 存储与渲染 ​

5.1 内存布局 ​

文件: core/MaskVolume.ts

内存布局: [z][y][x][channel]
index = z * bytesPerSlice + y * width * channels + x * channels + channel
bytesPerSlice = width * height * channels
内存布局: [z][y][x][channel]
index = z * bytesPerSlice + y * width * channels + x * channels + channel
bytesPerSlice = width * height * channels

底层数据结构: 单一连续 Uint8Array

5.2 各轴切片维度 ​

定义: MaskVolume.ts:1117-1126

轴切片宽度切片高度说明
z (Axial)widthheight最常用,连续内存
y (Coronal)widthdepth按行提取
x (Sagittal)heightdepth列沿 y、行沿 z(4.1.0 起改变)

对应 emptyCanvas 尺寸设置: SliceRenderPipeline.setEmptyCanvasSize() → tools/SliceRenderPipeline.ts(已从 NrrdTools 提取)

5.3 切片提取 (读取 Mask) ​

getSliceUint8(sliceIndex, axis) — MaskVolume.ts:1019-1058

返回原始 Uint8Array,用于:

  • 后端同步 (getMask callback)
  • Undo/Redo 快照

各轴实现:

5.4 切片写入 ​

setSliceUint8(sliceIndex, data, axis) — MaskVolume.ts:1072-1108

getSliceUint8 的逆操作,用于 Undo/Redo 恢复。

setSliceLabelsFromImageData(sliceIndex, imageData, axis, activeChannel, channelVisible?) — MaskVolume.ts:575-661

Canvas→Volume 写入,将 RGBA 像素转换为 channel label。

  • 构建 RGB→Channel 映射 L593
  • ALPHA_THRESHOLD = 128 L601 避免抗锯齿边缘

RGB 精确匹配走 buildRgbToChannelMap()。匹配不上的像素(抗锯齿边缘)会落到"最近颜色"搜索, 而这个搜索被限制在候选 label 内:当前切片上已经存在的那些 label,加上 activeChannel。 用一趟廉价的扫描收集出来。

把搜索范围放宽到全部 255 个,不只是每个边缘像素多算 255 次距离,而且是错的:可供猜测的 颜色越多,边缘越容易被判成一个切片上根本不存在的 label,于是体素被塞进了另一个 finding 的 mask,而画面上什么都看不出来。

clearChannel(channel) — 把携带某一个 label 的体素全部清零,其他 label 原样保留。仅适用于 label 型 volume(numChannels === 1,即体素值就是 label);多平面 volume 把 channel 存成 独立平面,需要另一套扫描方式。超出 [1, 255] 抛 RangeError。version 计数器只在确实有体素 被改动时才递增 —— 所有以它为键的派生缓存(首当其冲是 contour 缓存)都会因递增而失效,所以 为一次什么都没找到的清除而递增,等于白白重绘整个图层。

5.5 渲染到 Canvas ​

核心渲染方法: renderLabelSliceInto() — MaskVolume.ts:695-770

ts
renderLabelSliceInto(
  sliceIndex: number,
  axis: 'x' | 'y' | 'z',
  target: ImageData,              // 预分配的 ImageData buffer
  channelVisible?: Record<number, boolean>,  // Channel 可见性
  opacity: number = 1.0
): void
renderLabelSliceInto(
  sliceIndex: number,
  axis: 'x' | 'y' | 'z',
  target: ImageData,              // 预分配的 ImageData buffer
  channelVisible?: Record<number, boolean>,  // Channel 可见性
  opacity: number = 1.0
): void

渲染逻辑:

  1. 读取 label 值 (0-255)
  2. label === 0 → 透明 (RGBA 全 0)
  3. channelVisible?.[label] === false → 隐藏该 Channel → 透明
  4. 否则 → 从 volume 的 colorMap 取颜色(支持 per-layer 自定义颜色),应用 opacity

Phase B 变更: 颜色来源从全局 MASK_CHANNEL_COLORS 改为每个 volume 实例的 this.colorMap。buildRgbToChannelMap() 也改为 instance 方法,确保 canvas→volume 写回时使用正确的自定义颜色映射。

随 255 通道一起改掉的两条读回规则

是 === false,不是 falsy(第 3 步)。channelVisible 记录的是哪些被隐藏了,它从来 不会覆盖全部 label。setSliceLabelsFromImageData 本来就是这么读的;而这里按 falsy 读,会让 任何该映射没提到的 label 渲染成不可见 —— 在只有 8 个通道、且总是被完整列出时这没有危害, 一旦 label 可以是 255 个中的任意一个就不成立了。

buildRgbToChannelMap() 覆盖该实例所有有颜色的 label,而不是固定的 1-8。用 12 号通道的 颜色画下的像素,以前完全匹配不进这张表,只能落到"最近颜色"猜测,而那个猜测只可能回答 1-8 —— 于是高编号通道画得上去、却永远读不回来。体积不是问题:这张表每次读回只构建一次,之后每次查找 都是 O(1)。

5.6 渲染管线完整流程 ​

入口: reloadMasksFromVolume() — SliceRenderPipeline.reloadMasksFromVolume() → tools/SliceRenderPipeline.ts(已从 NrrdTools 提取,NrrdTools 中为 private 委托)

reloadMasksFromVolume()
  │
  ├─ getOrCreateSliceBuffer(axis)          → 获取/创建可复用的 ImageData buffer
  │   [RenderingUtils.ts]
  │
  ├─ FOR EACH layer:
  │   ├─ target.ctx.clearRect(...)         → 清空 layer canvas
  │   └─ renderSliceToCanvas(layerId, axis, sliceIndex, buffer, target.ctx, w, h)
  │       [RenderingUtils.ts] → drawSlice(..., outline = maskRenderMode === "outline")
  │       │
  │       ├─ 缓存未命中?volume.getSliceUint8 → findLabelsInSlice → buildPaths
  │       │   fill:    extractLabelContours(...)  → 每个 label 一个 Path2D
  │       │   outline: extractLabelOutline(...)   → 每个 label 一个 Path2D
  │       │
  │       └─ FOR EACH 可见 label:从 volume.getChannelColor(lbl) 取填充/描边色
  │           fill:    ctx.scale(sw/W, sh/H) 之后 ctx.fill(path, 'nonzero')
  │           outline: DOMMatrix 作用在 PATH 上,ctx 不做变换,ctx.stroke(...)
  │           ⚠️ 注意:冠状面(axis='y')会做 scale(1,-1) 垂直翻转(详见 §6.2)——
  │              fill 模式作用在 context 上,outline 模式折进矩阵里
  │
  └─ compositeAllLayers()                  → 合成到 master canvas
      [RenderingUtils.ts]
      │
      ├─ masterCtx.clearRect(...)
      └─ FOR EACH layer:
          ├─ if !layerVisibility[layerId] → skip
          ├─ masterCtx.save()
          ├─ masterCtx.globalAlpha = layerOpacity[layerId]  ← per-layer alpha
          ├─ masterCtx.drawImage(layerCanvas)
          └─ masterCtx.restore()
reloadMasksFromVolume()
  │
  ├─ getOrCreateSliceBuffer(axis)          → 获取/创建可复用的 ImageData buffer
  │   [RenderingUtils.ts]
  │
  ├─ FOR EACH layer:
  │   ├─ target.ctx.clearRect(...)         → 清空 layer canvas
  │   └─ renderSliceToCanvas(layerId, axis, sliceIndex, buffer, target.ctx, w, h)
  │       [RenderingUtils.ts] → drawSlice(..., outline = maskRenderMode === "outline")
  │       │
  │       ├─ 缓存未命中?volume.getSliceUint8 → findLabelsInSlice → buildPaths
  │       │   fill:    extractLabelContours(...)  → 每个 label 一个 Path2D
  │       │   outline: extractLabelOutline(...)   → 每个 label 一个 Path2D
  │       │
  │       └─ FOR EACH 可见 label:从 volume.getChannelColor(lbl) 取填充/描边色
  │           fill:    ctx.scale(sw/W, sh/H) 之后 ctx.fill(path, 'nonzero')
  │           outline: DOMMatrix 作用在 PATH 上,ctx 不做变换,ctx.stroke(...)
  │           ⚠️ 注意:冠状面(axis='y')会做 scale(1,-1) 垂直翻转(详见 §6.2)——
  │              fill 模式作用在 context 上,outline 模式折进矩阵里
  │
  └─ compositeAllLayers()                  → 合成到 master canvas
      [RenderingUtils.ts]
      │
      ├─ masterCtx.clearRect(...)
      └─ FOR EACH layer:
          ├─ if !layerVisibility[layerId] → skip
          ├─ masterCtx.save()
          ├─ masterCtx.globalAlpha = layerOpacity[layerId]  ← per-layer alpha
          ├─ masterCtx.drawImage(layerCanvas)
          └─ masterCtx.restore()

Per-Layer Alpha 渲染机制: 每个 layer 的 canvas 在合成时通过 masterCtx.globalAlpha 应用其独立的 layerOpacity 值。现有的 globalAlpha(来自 gui_states.drawing)控制整体 mask 透明度,而 layerOpacity 提供逐层独立控制。最终透明度 = globalAlpha × layerOpacity[layerId]。

5.7 Marching Squares (core/MarchingSquares.ts) ​

两种渲染模式背后的矢量提取。四个函数都从包根导出。

函数返回说明
findLabelsInSlice(labels, w, h, stride?, offset?, bbox?)number[]该区域内出现过的所有非零 label
extractLabelPolygons(...)ContourPolygon[]覆盖该区域的逐 cell 多边形
extractLabelContours(...)Path2D上者的 Path2D 形式 —— 用来填充
extractLabelBoundarySegments(...)ContourPolygon[]该区域的边缘,以开放的两点线段表示
extractLabelOutline(...)Path2D上者的 Path2D 形式 —— 用来描边

参数都是 (labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox?)。

不要去描 contour 路径

extractLabelContours 是一组逐 cell 的多边形,它们的并集才是那个轮廓。描它会把每一条内部 cell 边也画出来,整个 mask 会被一层网格盖住。要描边请用 extractLabelOutline。

两张表的关系。 CELL_SEGMENTS 对 16 种 marching-squares 情形各有一项,而每一项正是对应 CELL_POLYGONS 那一项的切割线 —— 即那个多边形中唯一不落在 cell 边界上的那条边。因此填充多边形 和描线段所描述的是同一个轮廓,这正是渲染器可以把填充换成描边、而 mask 的边缘看起来不会移动的原因。 端点都是 cell 各边的中点,处在和 CELL_POLYGONS 相同的 cell 局部 0..1 空间里,所以调用方对两者 加的都是同一个 (i, j) + 0.5。

情形 0 和 15 不吐出任何线段。15 正是 outline 便宜的根本原因:实心内部没有边界,于是线段数量 随周长增长,而不是随面积。

鞍点(5 和 10)沿用 CELL_POLYGONS 的约定 —— 两个对角 in-corner 视为不连通(4-连通)—— 因为另一种读法会让边界直接穿过填充认为是实心的 mask。


6. 翻转 (Flip) 机制 ​

6.1 Display 翻转(仅 CT/MRI 图像) ​

flipDisplayImageByAxis() — SliceRenderPipeline.flipDisplayImageByAxis() → tools/SliceRenderPipeline.ts(已从 NrrdTools 提取)

加载器会把每个体数据放进标准体素序(i → 患者左、j → 后、k → 上), 而 three 是按体素下标铺切片的,并带有固定的行反转。这个逐轴变换把那些反转撤销掉,正是它让每个视图 成为放射显示惯例:

轴屏幕向右屏幕向下变换
z (Axial)+i(患者左侧)+j(后)scale(1, -1) + translate(0, -h)
y (Coronal)+i−k(上方在顶部)scale(1, -1) + translate(0, -h)
x (Sagittal)+j(前侧在左边)−ktransform(0, h/w, -w/h, 0, w, 0)

矢状面是四分之一旋转,不是翻转

three 的矢状面画布列沿 −k、行沿 −j,所以这个视图是一次 90° 旋转外加缩放。它绘制的矩形用的是 显示尺寸 —— 相对 three 的画布宽高互换 —— 也就是 displaySizeOf(axis, canvas) 返回的那个。 其余所有画布(包括 mask 切片)都按同一个显示尺寸布局。

4.1.0 之前矢状面是 scale(-1, -1) + translate(-w, -h),即 180° 翻转;那只对本来就以某一特定顺序 存储的体数据才成立。

Utils/orientation.ts 里的 VIEW_LAYOUT 用同一套布局来确定方位字母,所以两者在同一次提交里一起改。

调用位置: SliceRenderPipeline.redrawDisplayCanvas() → 内部调用 flipDisplayImageByAxis()

6.2 Mask 翻转(冠状面与矢状面) 4.1.0 ​

凡是切片行在屏幕上自下而上的视图,mask 轮廓都按自下而上绘制,这由 contourPaint.rowsRunUp(axis) 决定:

ts
/** 冠状面和矢状面的切片行里 z 是递增存储的,而上方要显示在顶部,
 *  所以它们的行是自下而上绘制的。 */
export function rowsRunUp(axis: "x" | "y" | "z"): boolean {
  return axis !== "z";
}
/** 冠状面和矢状面的切片行里 z 是递增存储的,而上方要显示在顶部,
 *  所以它们的行是自下而上绘制的。 */
export function rowsRunUp(axis: "x" | "y" | "z"): boolean {
  return axis !== "z";
}
轴Mask 翻转原因
z (Axial)无行本来就是自上而下
y (Coronal)scale(1, -1)行里 k 递增存储;上方应当在顶部
x (Sagittal)scale(1, -1)同理 —— 行沿 k

两条绘制路径上都会应用它:outline 模式作用在 DOMMatrix 上,fill 模式作用在 context 上, 并且都在体素→显示的缩放之前,这样翻转发生在显示坐标系里。

4.1.0 的变化

矢状面此前没有 mask 翻转,且矢状面切片的布局是 depth × height。这两点是一起改的:切片现在是 height × depth(列沿 y、行沿 z),同时翻转也适用了。从 4.1.0 起,对矢状轴调用 getSliceUint8 的调用方拿到的是转置后的布局。

6.3 applyMaskFlipForAxis 已移除 4.1.0 移除 ​

RenderingUtils.applyMaskFlipForAxis() 不再存在。它把逐轴变换又复制了第三份,而这一份会 —— 并且 确实曾经 —— 和显示路径、轮廓路径走偏。保留下来的两份是刻意保持同步的:图像用 SliceRenderPipeline.flipDisplayImageByAxis,mask 用 contourPaint.rowsRunUp。


7. Tools(工具) ​

位置: Utils/segmentation/tools/

所有 Tool / 模块继承自 BaseTool:

BaseTool — tools/BaseTool.ts

ts
interface ToolContext {
  nrrd_states: NrrdState;
  gui_states: GuiState;
  protectedData: IProtected;
  cursorPage: ICursorPage;
  callbacks: IAnnotationCallbacks;
}
abstract class BaseTool {
  constructor(ctx: ToolContext)
  setContext(ctx: ToolContext): void
}
interface ToolContext {
  nrrd_states: NrrdState;
  gui_states: GuiState;
  protectedData: IProtected;
  cursorPage: ICursorPage;
  callbacks: IAnnotationCallbacks;
}
abstract class BaseTool {
  constructor(ctx: ToolContext)
  setContext(ctx: ToolContext): void
}

7.1 Tool 列表 ​

ToolHost 统一接口(已完成): 所有 Tool 的宿主方法依赖已统一到 tools/ToolHost.ts 中的 ToolHost 接口。每个 Tool 通过 Pick<ToolHost, ...> 类型别名(如 SphereHostDeps、DrawingHostDeps)声明所需方法子集,构造函数接收该子集类型的 callbacks 参数。原先的 10 个独立 *Callbacks 接口已全部删除。

NrrdTools 提取模块(God Class Split) ​

这三个模块由 NrrdTools.initNrrdToolsModules() 初始化,通过 ToolContext + Pick<ToolHost, ...> 类型与 NrrdTools 解耦:

模块文件行数HostDeps 类型
LayerChannelManagertools/LayerChannelManager.ts211LayerChannelHostDeps (3 methods: reloadMasksFromVolume, getVolumeForLayer, onChannelColorChanged)
SliceRenderPipelinetools/SliceRenderPipeline.ts453SliceRenderHostDeps (10 methods)
DataLoadertools/DataLoader.ts222DataLoaderHostDeps (7 methods)

DrawToolCore 管理的 Tool(事件处理) ​

这些 Tool 由 DrawToolCore.initTools() 初始化,处理用户交互事件:

Tool文件HostDeps 类型说明
SphereTooltools/SphereTool.tsSphereHostDeps3D 球形标注工具,支持 4 种类型 (tumour/skin/ribcage/nipple),包含点击放置 (onSphereClick) 和松开完成 (onSpherePointerUp)
CrosshairTooltools/CrosshairTool.ts—(无 callbacks)十字准星位置标记、坐标转换、crosshair 渲染 (renderCrosshair)
ContrastTooltools/ContrastTool.tsContrastHostDeps窗位/窗宽调节
ZoomTooltools/ZoomTool.tsZoomHostDeps缩放/平移
EraserTooltools/EraserTool.ts—(无 callbacks)橡皮擦
PanTooltools/PanTool.tsPanHostDeps右键拖拽平移画布
DrawingTooltools/DrawingTool.tsDrawingHostDeps铅笔/画笔/橡皮擦绘画逻辑,含笔刷 hover 追踪 (createBrushTrackingHandler) 和圆圈预览 (renderBrushPreview)
SphereBrushTooltools/SphereBrushTool.tsSphereBrushHostDeps3D 球形体积绘画 (sphereBrush) 和擦除 (sphereEraser);拖动擦除;分组多切片 undo
ImageStoreHelpertools/ImageStoreHelper.tsImageStoreHostDepsCanvas↔Volume 同步
DragSliceTooltools/DragSliceTool.tsDragSliceHostDeps拖拽切换切片

Tool 初始化: DrawToolCore.ts initTools() 方法

标注暂停闸门 ​

DrawToolCore.onCanvasPointerDown(e) 是唯一一处决定"这次输入是否允许修改 mask"的地方。它被写成 一个具名方法而不是 draw() 里的内联闭包,就是为了让这个闸门可以在测试里被直接驱动。

onCanvasPointerDown(e)
  │
  ├─ drawingTool.isActive || panTool.isActive? → closePath,return
  ├─ 把 paintSliceIndex 同步到当前切片
  ├─ mode === 'draw'?                          → activeWheelMode = 'none'
  │
  ├─ e.button === 0
  │   ├─ annotationSuspended && !crosshairEnabled? → return  ◀ 闸门
  │   ├─ mode 'aiAssist'  → aiAssistTool.onPointerDown
  │   ├─ mode 'draw'      → drawingTool.onPointerDown
  │   ├─ 十字准线开启      → enableCrosshair            (只读,可穿过闸门)
  │   ├─ sphereBrush      → sphereBrushTool.onSphereBrushClick
  │   ├─ sphereEraser     → sphereBrushTool.onSphereEraserClick
  │   └─ sphere           → handleSphereClick
  │
  └─ e.button === 2       → panTool.onPointerDown      (平移可穿过闸门)
onCanvasPointerDown(e)
  │
  ├─ drawingTool.isActive || panTool.isActive? → closePath,return
  ├─ 把 paintSliceIndex 同步到当前切片
  ├─ mode === 'draw'?                          → activeWheelMode = 'none'
  │
  ├─ e.button === 0
  │   ├─ annotationSuspended && !crosshairEnabled? → return  ◀ 闸门
  │   ├─ mode 'aiAssist'  → aiAssistTool.onPointerDown
  │   ├─ mode 'draw'      → drawingTool.onPointerDown
  │   ├─ 十字准线开启      → enableCrosshair            (只读,可穿过闸门)
  │   ├─ sphereBrush      → sphereBrushTool.onSphereBrushClick
  │   ├─ sphereEraser     → sphereBrushTool.onSphereEraserClick
  │   └─ sphere           → handleSphereClick
  │
  └─ e.button === 2       → panTool.onPointerDown      (平移可穿过闸门)

handleOnDrawingMouseMove 里也有同样的提前返回,所以在暂停之前就已经按下的拖动,无法穿过暂停 继续绘制。

pointer-up 同样被拦截 3.11.1 ​

onCanvasPointerUp(e) 是与之对应的具名方法,抽出来的理由也一样 —— 让闸门可以被直接驱动。 它对左键的判断不是简单的"当前是不是 draw 模式":

ts
if (
  this.drawingTool.painting ||
  (this.eventRouter.getMode() === 'draw' && !this.annotationSuspended)
) { this.drawingTool.onPointerUp(e); ... }
if (
  this.drawingTool.painting ||
  (this.eventRouter.getMode() === 'draw' && !this.annotationSuspended)
) { this.drawingTool.onPointerUp(e); ... }

getMode() === 'draw' 只说明"按住了 Shift 且当前选中的是绘制工具",它并不知道笔画有没有真的 开始。在被暂停的实例上,pointer-down 已经被拦截、笔画根本不可能开始,但按住 Shift 再松开鼠标 仍然会报告 'draw' 并进到 drawingTool.onPointerUp,而后者会在 setCurrentLayer 里因为没有 可用图层而抛异常。

drawingTool.painting 才是可靠信号:只有笔画真正开始之后它才为 true。被暂停的实例永远无法把它 置为 true,因此永远到不了 onPointerUp;而一个跨越了"中途暂停"的进行中笔画,仍然能正常收尾。

在这里统一拦截(而不是每个工具各拦一次)带来两个结果:

  • 以后新增的工具不需要知道"暂停"这回事,也自动被覆盖。
  • !crosshairEnabled 这个放行口只对一个只读探针生效,不会漏出一笔绘制:EventRouter 保证了 十字准线和 draw 互斥 —— applyDrawKeyDown 在十字准线开启时拒绝进入 draw,而 toggleCrosshair 在 mode 为 draw 时拒绝开启十字准线。

在 Facade 上暴露为 NrrdTools.setAnnotationSuspended() / isAnnotationSuspended()。

7.2 ImageStoreHelper(关键工具) ​

storeAllImages(index, layer) — ImageStoreHelper.ts:116-178

Canvas → Volume 同步流程:

  1. 将 layer canvas 绘制到 emptyCanvas [L124]
  2. 从 emptyCanvas 获取 ImageData [L127-132]
  3. 调用 volume.setSliceLabelsFromImageData() [L142-148] 写入 MaskVolume
  4. 提取切片通知后端 [L161]

filterDrawedImage(axis, sliceIndex) — ImageStoreHelper.ts:85-107

Volume → Canvas 读取,调用 volume.renderLabelSliceInto().

7.3 SphereTool(球形标注工具) ​

文件: tools/SphereTool.ts

类型与常量 ​

ts
type SphereType = 'tumour' | 'skin' | 'nipple' | 'ribcage';

const SPHERE_CHANNEL_MAP: Record<SphereType, { layer: string; channel: number }>;
// SPHERE_COLORS 已删除 — 颜色改为从各 volume 的 colorMap 动态获取
const SPHERE_LABELS: Record<SphereType | 'default', number>;
type SphereType = 'tumour' | 'skin' | 'nipple' | 'ribcage';

const SPHERE_CHANNEL_MAP: Record<SphereType, { layer: string; channel: number }>;
// SPHERE_COLORS 已删除 — 颜色改为从各 volume 的 colorMap 动态获取
const SPHERE_LABELS: Record<SphereType | 'default', number>;

辅助方法 ​

方法签名说明
getChannelForSphereType(type: SphereType): number获取 sphere 类型对应的 channel 号
getLayerForSphereType(type: SphereType): string获取 sphere 类型对应的 layer ID
getColorForSphereType(type: SphereType): string获取 sphere 类型对应的预览颜色

交互方法(从 DrawToolCore 提取) ​

方法签名说明
onSphereClick(e: MouseEvent): void左键点击:记录 origin、存储类型 origin、启用 crosshair、绘制预览圆。事件绑定(wheel/pointerup)留在 DrawToolCore
onSpherePointerUp(): void左键松开:写入所有 sphere 到 volume、刷新 overlay、触发 onSphereChanged + onCalculatorPositionsChanged 回调。事件清理留在 DrawToolCore

SphereHostDeps 类型(Pick<ToolHost, ...>) ​

ts
// tools/ToolHost.ts
type SphereHostDeps = Pick<ToolHost,
  'setEmptyCanvasSize' | 'drawImageOnEmptyImage' | 'enableCrosshair' | 'setUpSphereOrigins'
>;
// tools/ToolHost.ts
type SphereHostDeps = Pick<ToolHost,
  'setEmptyCanvasSize' | 'drawImageOnEmptyImage' | 'enableCrosshair' | 'setUpSphereOrigins'
>;

使用边界 ​

Sphere 模式激活时:

  • ❌ Shift 键被禁用 — 不能进入 draw 模式
  • ✅ Crosshair 切换可用 (S 键)
  • ❌ clearPaint 不通知后端
  • ❌ Contrast 模式被阻止

交互流程 ​

左键按下 → 根据 activeSphereType 记录 origin → activeWheelMode = 'sphere' → 绘制预览圆
           (tumour/skin/nipple/ribcage 各自存储 origin)
滚轮 (左键按住) → EventRouter wheel 派发到 handleSphereWheel → sphereRadius ±1 [1, 50] → 重绘
左键松开 → 写入所有已放置 sphere 到 volume → 触发 getSphere + getCalculateSpherePositions → activeWheelMode = 'zoom'
左键按下 → 根据 activeSphereType 记录 origin → activeWheelMode = 'sphere' → 绘制预览圆
           (tumour/skin/nipple/ribcage 各自存储 origin)
滚轮 (左键按住) → EventRouter wheel 派发到 handleSphereWheel → sphereRadius ±1 [1, 50] → 重绘
左键松开 → 写入所有已放置 sphere 到 volume → 触发 getSphere + getCalculateSpherePositions → activeWheelMode = 'zoom'

SphereMaskVolume ​

独立 MaskVolume,存储 sphere 3D 数据,不污染 layer draw mask。

生命周期位置操作
创建NrrdTools.setAllSlices()new MaskVolume(vw, vh, vd, 1)
清空NrrdTools.reset()sphereMaskVolume = null
存储nrrd_states.sphereMaskVolume—

7.4 PanTool(右键平移工具) ​

文件: tools/PanTool.ts — 124 lines

从 DrawToolCore.paintOnCanvas() 提取(Phase 2, 2026-02-26)。处理所有右键拖拽平移逻辑。

PanHostDeps 类型(Pick<ToolHost, ...>) ​

ts
// tools/ToolHost.ts
type PanHostDeps = Pick<ToolHost, 'zoomActionAfterDrawSphere'>;
// tools/ToolHost.ts
type PanHostDeps = Pick<ToolHost, 'zoomActionAfterDrawSphere'>;

⚠️ 原文档中的 getPanelOffset / setPanelOffset 回调已不存在。PanTool 现在直接通过 canvas.offsetLeft / canvas.offsetTop 读取偏移,无需外部回调。

关键属性与方法 ​

成员说明
rightClicked: boolean右键是否按下
panMoveInnerX/Y: number平移拖拽起始位置偏移(clientX − canvas.offsetLeft)
isActive: boolean (getter)是否正在平移(用于 DrawToolCore re-entry guard)
onPointerDown(e)右键按下:记录起始偏移,更改光标为 grab
onPointerMove(e)拖拽中:计算并更新 canvas 位置
onPointerUp(e)右键松开:清理状态,恢复光标
onPointerLeave()canvas 离开:清理状态

与 DrawToolCore 集成 ​

ts
// DrawToolCore.initTools()
this.panTool = new PanTool(toolCtx, {
  zoomActionAfterDrawSphere: () => this.zoomActionAfterDrawSphere(),
});

// handleOnDrawingMouseDown — right-click branch
this.panTool.onPointerDown(e);
// pointerup permanently routed by EventRouter

// handleOnDrawingMouseUp — right-click branch
this.panTool.onPointerUp(e);

// handlePointerLeave (registered once via EventRouter)
this.panTool.onPointerLeave();
// DrawToolCore.initTools()
this.panTool = new PanTool(toolCtx, {
  zoomActionAfterDrawSphere: () => this.zoomActionAfterDrawSphere(),
});

// handleOnDrawingMouseDown — right-click branch
this.panTool.onPointerDown(e);
// pointerup permanently routed by EventRouter

// handleOnDrawingMouseUp — right-click branch
this.panTool.onPointerUp(e);

// handlePointerLeave (registered once via EventRouter)
this.panTool.onPointerLeave();

7.5 SphereBrushTool(球形画笔/橡皮擦工具) ​

文件: tools/SphereBrushTool.ts — 584 lines

处理 3D 球形体积绘画(SphereBrush 模式)和 3D 球形体积擦除(SphereEraser 模式),包含拖动擦除和分组多切片 undo。

与 SphereTool(写入独立的 sphereMaskVolume 覆盖层)不同,SphereBrushTool 直接写入活跃 layer 的共享 MaskVolume,其输出完全兼容 NIfTI/GLTF 导出。

SphereBrushHostDeps 类型(Pick<ToolHost, ...>) ​

ts
type SphereBrushHostDeps = Pick<ToolHost,
  'getVolumeForLayer' | 'compositeAllLayers' | 'pushUndoGroup'
  | 'renderSliceToCanvas' | 'getOrCreateSliceBuffer' | 'setEmptyCanvasSize'
  | 'reloadMasksFromVolume'
>;
type SphereBrushHostDeps = Pick<ToolHost,
  'getVolumeForLayer' | 'compositeAllLayers' | 'pushUndoGroup'
  | 'renderSliceToCanvas' | 'getOrCreateSliceBuffer' | 'setEmptyCanvasSize'
  | 'reloadMasksFromVolume'
>;

关键方法 ​

方法说明
onSphereBrushClick(e)左键按下:记录中心点,绘制预览,设为活跃
onSphereBrushPointerUp()释放:将 3D 球体写入 volume,推送 undo 组,为所有受影响切片触发 onMaskChanged
onSphereEraserClick(e)左键按下:记录中心点,为所有受影响 Z 切片捕获 before 快照
onSphereEraserMove(e)拖动:沿路径连续擦除,惰性扩展 before 快照
onSphereEraserPointerUp()释放:完成累积擦除,推送 undo 组,为所有受影响切片触发 onMaskChanged
configSphereBrushWheel()返回滚轮处理器,调整 sphereBrushRadius ±1 [1, 50]
drawPreview(x, y, r, isEraser)在 sphereCanvas 上渲染球体预览圆
clearPreview()清除 sphereCanvas 上的预览

3D 几何 ​

  • canvasToVoxelCenter(): 将画布像素坐标转换为 3D 体素中心 [cx, cy, cz]
  • getVoxelRadii(): 根据 mm 半径和体素间距计算各轴体素半径
  • computeBoundingBox(): 计算限制在体积边界内的轴对齐包围盒
  • 球体方程: (dx/rx)² + (dy/ry)² + (dz/rz)² <= 1(椭球体以处理各向异性间距)

Undo 机制 ​

SphereBrush 使用分组 undo(pushUndoGroup(MaskDelta[]))而非单 delta undo:

SphereBrush:
  mousedown → 为包围盒内所有 Z 切片捕获 before 快照
  mouseup   → 捕获 after 快照,diff → 推送 MaskDelta[] 组

SphereEraser(点击释放):
  mousedown → 捕获 before 快照 (dragBeforeSnapshots)
  mouseup   → diff before vs current → 推送 MaskDelta[] 组

SphereEraser(拖动):
  mousedown → 初始化 dragBeforeSnapshots(初始包围盒)
  mousemove → expandDragBeforeSnapshots(新触及的 Z 切片)
  mouseup   → diff 累积 before vs current → 推送单个 MaskDelta[] 组
SphereBrush:
  mousedown → 为包围盒内所有 Z 切片捕获 before 快照
  mouseup   → 捕获 after 快照,diff → 推送 MaskDelta[] 组

SphereEraser(点击释放):
  mousedown → 捕获 before 快照 (dragBeforeSnapshots)
  mouseup   → diff before vs current → 推送 MaskDelta[] 组

SphereEraser(拖动):
  mousedown → 初始化 dragBeforeSnapshots(初始包围盒)
  mousemove → expandDragBeforeSnapshots(新触及的 Z 切片)
  mouseup   → diff 累积 before vs current → 推送单个 MaskDelta[] 组

后端同步 ​

refreshDisplay() 为所有受影响的 Z 切片触发 onMaskChanged(不仅是当前查看切片),确保完整 3D 球体的 NIfTI 和 GLTF 导出正确。

使用边界 ​

SphereBrush/SphereEraser 模式激活时:

  • ❌ Shift 键被禁用 — 不能进入 draw 模式
  • ❌ 拖拽切片被禁用 — DragOperator 自动移除
  • ✅ Crosshair 切换可用 (S 键)
  • ❌ Contrast 模式被阻止

7.6 DrawingTool(绘画工具) ​

文件: tools/DrawingTool.ts — 284 lines

从 DrawToolCore.paintOnCanvas() 提取(Phase 3, 2026-02-26)。处理铅笔、画笔、橡皮擦的所有绘画逻辑,包含 Undo 快照。

DrawingHostDeps 类型(Pick<ToolHost, ...>) ​

ts
// tools/ToolHost.ts
type DrawingHostDeps = Pick<ToolHost,
  'setCurrentLayer' | 'compositeAllLayers' | 'syncLayerSliceData'
  | 'filterDrawedImage' | 'getVolumeForLayer' | 'pushUndoDelta'
  | 'renderSliceToCanvas' | 'renderSliceForBake' | 'getOrCreateSliceBuffer'
>;
// tools/ToolHost.ts
type DrawingHostDeps = Pick<ToolHost,
  'setCurrentLayer' | 'compositeAllLayers' | 'syncLayerSliceData'
  | 'filterDrawedImage' | 'getVolumeForLayer' | 'pushUndoDelta'
  | 'renderSliceToCanvas' | 'renderSliceForBake' | 'getOrCreateSliceBuffer'
>;

关键属性与方法 ​

成员说明
leftClicked: boolean左键是否按下
isPainting: boolean是否正在绘画(mousedown 到 mouseup 期间)
drawingLines: ICommXY[]铅笔模式路径点集合
clearArcFn当前帧的橡皮擦函数(由 reset() 注入)
preDrawSlice/Axis/SliceIndexmousedown 时的 undo 快照数据
isActive: boolean (getter)暴露 leftClicked,用于 DrawToolCore re-entry guard
painting: boolean (getter)暴露 isPainting,用于 mouseUp 条件判断
reset(clearArcFn)每次 paintOnCanvas() 调用时重置状态并注入新橡皮擦函数
onPointerDown(e)左键按下:设置光标、初始化路径、capturePreDrawSnapshot
onPointerMove(e)移动:橡皮擦分支用 clearArcFn,绘画分支积累路径并调用 paintOnCanvasLayer
onPointerUp(e)左键松开:铅笔 fill/画笔 closePath、syncLayerSliceData、pushUndoDelta
onPointerLeave()canvas 离开:重置状态,返回 boolean 表示是否有未完成绘画
createBrushTrackingHandler()返回 mouseover/mouseout/mousemove handler,追踪 mouseOverX/Y 和 mouseOver 状态
renderBrushPreview(ctx, w, h)在 draw 模式渲染笔刷圆圈预览(start() render loop 调用)

onPointerLeave 返回值约定 ​

onPointerLeave() 返回 true 表示用户正在绘画时离开(即 DrawToolCore 需要恢复 wheel 状态):

ts
// DrawToolCore.handlePointerLeave() — 通过 EventRouter 永久路由
const wasDrawing = this.drawingTool.onPointerLeave();
if (wasDrawing) {
  // 恢复 zoom wheel 模式(不再需要手动 removeEventListener)
  this.activeWheelMode = 'zoom';
}
this.panTool.onPointerLeave();
// DrawToolCore.handlePointerLeave() — 通过 EventRouter 永久路由
const wasDrawing = this.drawingTool.onPointerLeave();
if (wasDrawing) {
  // 恢复 zoom wheel 模式(不再需要手动 removeEventListener)
  this.activeWheelMode = 'zoom';
}
this.panTool.onPointerLeave();

与 DrawToolCore 集成 ​

ts
// DrawToolCore.initTools()
this.drawingTool = new DrawingTool(toolCtx, {
  setCurrentLayer: () => this.setCurrentLayer(),
  compositeAllLayers: () => this.renderer.compositeAllLayers(),
  syncLayerSliceData: (index, layer) => this.syncLayerSliceData(index, layer),
  filterDrawedImage: (axis, index) => this.renderer.filterDrawedImage(axis, index),
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
  pushUndoDelta: (delta) => this.undoManager.push(delta),
  getEraserUrls: () => this.eraserUrls,
});

// paintOnCanvas() — reset each call
this.drawingTool.reset(this.useEraser());

// Re-entry guard
if (this.drawingTool.isActive || this.panTool.isActive) return;
// DrawToolCore.initTools()
this.drawingTool = new DrawingTool(toolCtx, {
  setCurrentLayer: () => this.setCurrentLayer(),
  compositeAllLayers: () => this.renderer.compositeAllLayers(),
  syncLayerSliceData: (index, layer) => this.syncLayerSliceData(index, layer),
  filterDrawedImage: (axis, index) => this.renderer.filterDrawedImage(axis, index),
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
  pushUndoDelta: (delta) => this.undoManager.push(delta),
  getEraserUrls: () => this.eraserUrls,
});

// paintOnCanvas() — reset each call
this.drawingTool.reset(this.useEraser());

// Re-entry guard
if (this.drawingTool.isActive || this.panTool.isActive) return;

Undo 快照机制 ​

mousedown → capturePreDrawSnapshot()
  → callbacks.getVolumeForLayer(layer).getSliceUint8(sliceIndex, axis)
  → 保存到 preDrawSlice / preDrawAxis / preDrawSliceIndex

mouseup → pushUndoDelta()
  → callbacks.getVolumeForLayer(layer).getSliceUint8(sliceIndex, axis)  ← 操作后
  → callbacks.pushUndoDelta({ layerId, axis, sliceIndex, oldSlice: preDrawSlice, newSlice })
mousedown → capturePreDrawSnapshot()
  → callbacks.getVolumeForLayer(layer).getSliceUint8(sliceIndex, axis)
  → 保存到 preDrawSlice / preDrawAxis / preDrawSliceIndex

mouseup → pushUndoDelta()
  → callbacks.getVolumeForLayer(layer).getSliceUint8(sliceIndex, axis)  ← 操作后
  → callbacks.pushUndoDelta({ layerId, axis, sliceIndex, oldSlice: preDrawSlice, newSlice })

8. EventRouter(事件路由) ​

文件: eventRouter/EventRouter.ts

8.1 交互模式 ​

Mode触发条件说明
idle默认无交互
drawShift 按住绘画模式
drag垂直拖拽切片导航
contrastCtrl/Meta 按住窗位/窗宽调节
crosshairS 键十字准星

8.2 事件永久路由(Event Lifecycle Refactor) ​

EventRouter 在 bindAll() 时永久绑定所有 pointer/keyboard/wheel 事件到 drawingCanvas。DrawToolCore 通过 set*Handler() 注册处理器,不再手动 addEventListener/removeEventListener。

注册的处理器 ​

方法Handler 内容守卫条件
setPointerDownHandler转发到 handleOnDrawingMouseDown无(每次 pointerdown 都转发)
setPointerMoveHandler转发到 handleOnDrawingMouseMovedrawingTool.isActive || panTool.isActive(仅活跃交互时转发)
setPointerUpHandler转发到 handleOnDrawingMouseUpdrawingTool.isActive || drawingTool.painting || panTool.isActive || sphere模式
setPointerLeaveHandler调用 handlePointerLeave()无
setWheelHandler根据 activeWheelMode 派发无

⚠️ 守卫条件是必须的:drawingTool.onPointerMove() 无条件设置 isDrawing=true(DrawingTool L108),如果不加守卫,空闲时鼠标移动会导致 Brush 预览和 Crosshair 无法渲染。

Wheel 派发器(activeWheelMode) ​

DrawToolCore 新增 activeWheelMode: 'zoom' | 'sphere' | 'sphereBrush' | 'none' 字段,替代原来的手动 wheel add/remove:

Mode触发场景派发目标
'zoom'默认 / mouseUp 恢复handleMouseZoomSliceWheel
'sphere'handleSphereClick 设置handleSphereWheel
'sphereBrush'sphereBrush/sphereEraser mouseDown 设置handleSphereBrushWheel(调整 sphereBrushRadius)
'none'draw 模式 mouseDown 设置无操作(抑制滚轮)

滚轮缩放比例 3.11.4 ​

ZoomTool 的 wheelZoomRatio(e) 是根据滚动了多远来算出这一次滚轮事件的缩放比例,而不是 "发生了一次事件"。它取代了原先固定的每次 ±10%。

滚动 100px      = 一格 = ×1.1        (LOG_STEP_PER_PIXEL = ln(1.1) / 100)
deltaMode 1     × 33px   (行,Firefox)
deltaMode 2     × 800px  (页)
没有 deltaY      → 按一格处理,方向取自 `detail` / `wheelDelta`(旧式事件)
滚动 100px      = 一格 = ×1.1        (LOG_STEP_PER_PIXEL = ln(1.1) / 100)
deltaMode 1     × 33px   (行,Firefox)
deltaMode 2     × 800px  (页)
没有 deltaY      → 按一格处理,方向取自 `detail` / `wheelDelta`(旧式事件)

deltaY 为正(向下滚)表示缩小。比例在对数空间里计算,并夹取到 MAX_RATIO_PER_EVENT = 2, 这样单个异常大的 delta 也不会让视图一下跳飞。

为什么按距离而不是按事件次数。 页面繁忙时浏览器会合并滚轮事件、把它们的 delta 累加起来。 因此按事件计数会让"重"的视图缩放得比"轻"的视图更慢 —— 同样的物理滚动量,恰恰在应用有负载时 产生更少的缩放。按距离计算让两者表现一致,也正是它让触控板那许多细小的 delta 能平滑缩放、 而不是一格一格跳 10%。

结果仍然要过 [minSizeFactor, 8] 的夹取,以及和以前一样的 requestAnimationFrame 合并; 变的只是单次事件的比例。

8.3 默认键盘设置 ​

定义: CanvasState.ts keyboardSettings 字段

ts
IKeyBoardSettings = {
  draw: "Shift",
  undo: "z",
  redo: "y",
  contrast: ["Control", "Meta"],
  crosshair: "s",
  mouseWheel: "Scroll:Zoom",   // 或 "Scroll:Slice"
}

// 额外全局快捷键(在 DrawToolCore keydown 中处理,不可配置):
// Ctrl+1 → 切换到 Scroll:Zoom
// Ctrl+2 → 切换到 Scroll:Slice
IKeyBoardSettings = {
  draw: "Shift",
  undo: "z",
  redo: "y",
  contrast: ["Control", "Meta"],
  crosshair: "s",
  mouseWheel: "Scroll:Zoom",   // 或 "Scroll:Slice"
}

// 额外全局快捷键(在 DrawToolCore keydown 中处理,不可配置):
// Ctrl+1 → 切换到 Scroll:Zoom
// Ctrl+2 → 切换到 Scroll:Slice

没有 sphere 键了 3.11.5 破坏性变更

sphere: "q" 已经从 IKeyBoardSettings、从 EventRouter 的 DEFAULT_KEYBOARD_SETTINGS 中 移除,DrawToolCore keydown 处理器里的那段切换分支也删掉了。

球体模式现在只能通过 setMode("sphere") 进入 —— 也就是距离计算器本来就走的那条路 —— 所以当前 激活的工具永远是宿主自己选的。原来那个快捷键直接改 gui_states.mode.sphere,从而执行 enterSphereMode() 并把 mask 从视图里清掉,而宿主的 UI 仍然汇报着它自己那个被选中的工具 —— 并且没有任何事件去告诉它情况变了。

宿主如果想给它配一个键,自己绑,然后调 setMode。


9. Undo/Redo 系统 ​

文件: core/UndoManager.ts

Delta 结构 ​

ts
interface MaskDelta {
  layerId: string;
  axis: "x" | "y" | "z";
  sliceIndex: number;
  oldSlice: Uint8Array;   // 操作前的切片数据
  newSlice: Uint8Array;   // 操作后的切片数据
}
interface MaskDelta {
  layerId: string;
  axis: "x" | "y" | "z";
  sliceIndex: number;
  oldSlice: Uint8Array;   // 操作前的切片数据
  newSlice: Uint8Array;   // 操作后的切片数据
}
  • 每个 Layer 独立的 undo/redo 栈
  • MAX_STACK_SIZE = 50

栈按需创建 3.10.4,以 layer id 为键,因此任意图层名都能用。 以前这些栈是为 layer1 / layer2 / layer3 三个预先建好的,对其他 id 会以两种不同的方式出错 —— 而 NrrdTools 的构造函数一直接受 options.layers,也就是说其他 id 是完全可达的:

调用自定义 layer id 下的旧行为
pushGroup / pushVolumeSnapshot以 ?? get("layer1") 兜底,静默地追加到了另一个图层的历史里;于是之后一次 undo 会弹出用户根本没在那里画过的笔画
undo / redo对同一次查找做了非空断言,直接抛异常

stackFor(stacks, layerId) 在首次使用时创建条目,两个问题一并消失。其他地方都不需要知道存在 哪些图层,clearAll() 现在也改为遍历 map,而不是遍历一份固定的 id 列表。

Undo 流程 ​

DrawToolCore.undoLastPainting()
  → UndoManager.undo() → MaskDelta
  → vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis)
  → applyUndoRedoToCanvas(layerId)
    → getOrCreateSliceBuffer(axis)
    → renderSliceToCanvas(...)
    → compositeAllLayers()
  → annotationCallbacks.onMaskChanged(sliceData, ...) → 通知后端
DrawToolCore.undoLastPainting()
  → UndoManager.undo() → MaskDelta
  → vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis)
  → applyUndoRedoToCanvas(layerId)
    → getOrCreateSliceBuffer(axis)
    → renderSliceToCanvas(...)
    → compositeAllLayers()
  → annotationCallbacks.onMaskChanged(sliceData, ...) → 通知后端

10. DragOperator ​

文件: DragOperator.ts

负责拖拽交互(切片导航)。

⚠️ Event Lifecycle Refactor 变更: DragOperator 不再手动管理 wheel 事件监听器。原先在 handleOnDragMouseDown 中 removeEventListener("wheel", ...) 和在 handleOnDragMouseUp 中 addEventListener("wheel", ...) 的操作已删除,wheel 现在完全由 EventRouter 的 activeWheelMode 派发器管理。

方法说明
drag(opts?)启用拖拽模式
configDragMode()绑定拖拽监听器
removeDragMode()移除拖拽监听器
updateIndex(move)委托给 DragSliceTool
setEventRouter(eventRouter)订阅模式变化

11. Channel 颜色定义 ​

文件: core/types.ts

11.1 默认颜色(全局常量) ​

Channel颜色HexRGBA
0透明#000000(0,0,0,0)
1祖母绿 (Primary/Tumor)#10b981(16,185,129,255)
2玫瑰红 (Secondary/Edema)#f43f5e(244,63,94,255)
3蓝色 (Tertiary/Necrosis)#3b82f6(59,130,246,255)
4琥珀黄 (Enhancement)#fbbf24(251,191,36,255)
5紫红 Fuchsia (Vessel/Boundary)#d946ef(217,70,239,255)
6青绿 Cyan (Additional)#06b6d4(6,182,212,255)
7橙色 (Auxiliary)#f97316(249,115,22,255)
8紫色 Violet (Extended)#8b5cf6(139,92,246,255)

定义位置:

  • RGBA: MASK_CHANNEL_COLORS
  • CSS: MASK_CHANNEL_CSS_COLORS
  • Hex: CHANNEL_HEX_COLORS

通道 9–255(自动生成) ​

MAX_ENGINE_CHANNEL = 255 —— 每体素一个字节,值本身就是 label,因此 0 表示空、1–255 是 通道。引擎不会把上限卡得比字节更低;要设更小的产品级上限,是产品自己的事。

每套调色板由 extendPalette(seed, from) 构建:原样复制 0–8 的表,再用 generatedChannelColor(channel) 填满 9–255:

hue        = ((channel - 8 - 1) * 137.508) % 360     // 黄金角
saturation = 0.68                                    // 固定
lightness  = 0.55                                    // 固定
hue        = ((channel - 8 - 1) * 137.508) % 360     // 黄金角
saturation = 0.68                                    // 固定
lightness  = 0.55                                    // 固定

两个值得知道的决定:

  • 只有色相在变。 这些 mask 叠在灰度 MRI 上;如果不同通道之间明度也在变,读片者会把它 当成图像本身的差异,而不是标注的差异。
  • 种子色不重新生成。 已交付病例的色块、3D overlay 配色和打印报告的轮廓色都绑定在这 8 个 字面值上,一个碰巧算出不同数值的公式,会悄悄把已经被读过、签发过的工作重新上色。

是预先构建的,而不是套一层 Proxy:applyLayerChannelColors 用 Object.entries 遍历 它们,而 Proxy 的 get 陷阱是伺候不了 Object.entries 的。

WARNING

CHANNEL_COLORS 声明在它所别名的那些表之后。它原本在上面,在那些表还是对象字面量时没问题, 而现在它们是构建出来的,放在上面就是暂时性死区错误。

11.2 颜色转换工具函数(Phase B 新增) ​

函数签名说明
rgbaToHex(color: RGBAColor) → string转 Hex 字符串,如 #ff8000
rgbaToCss(color: RGBAColor) → string转 CSS rgba() 字符串,如 rgba(255,128,0,1.00)

11.3 Per-Layer 自定义颜色(Phase B) ​

每个 MaskVolume 实例拥有独立的 colorMap: ChannelColorMap,在构造时从 MASK_CHANNEL_COLORS 深拷贝。通过 NrrdTools.setChannelColor(layerId, channel, color) 修改某个 layer 的颜色不会影响其他 layer。

颜色流转路径:

volume.colorMap[channel]
  ↓ renderLabelSliceInto()     → Canvas 渲染使用 colorMap
  ↓ buildRgbToChannelMap()     → Canvas→Volume 写回使用 colorMap
  ↓ EraserTool.getChannelColor → 橡皮擦颜色匹配使用 colorMap
  ↓ syncBrushColor()           → 画笔颜色从 colorMap 获取
  ↓ getChannelCssColor()       → Vue UI 从 colorMap 获取显示颜色
volume.colorMap[channel]
  ↓ renderLabelSliceInto()     → Canvas 渲染使用 colorMap
  ↓ buildRgbToChannelMap()     → Canvas→Volume 写回使用 colorMap
  ↓ EraserTool.getChannelColor → 橡皮擦颜色匹配使用 colorMap
  ↓ syncBrushColor()           → 画笔颜色从 colorMap 获取
  ↓ getChannelCssColor()       → Vue UI 从 colorMap 获取显示颜色

12. GaussianSmoother(高斯平滑) ​

文件: core/GaussianSmoother.ts

纯静态工具类,用于分割 mask 的 3D 高斯平滑。无 DOM/Canvas/GUI 依赖。包含直接数组访问和去分支卷积两项性能优化。

12.1 算法 ​

对 MaskVolume 中的单个 label channel 执行可分离 3D 高斯模糊:

  1. 提取: 创建 Float32Array,voxel === channel 的位置为 1.0,其余为 0.0(直接 rawData[] 访问)
  2. 模糊: 沿 X → Y → Z 三轴依次执行 1D 高斯卷积(核截断于 ±3σ,中间段去分支)
  3. 阈值: 以 0.5 为阈值二值化
  4. 写回: 根据阈值结果覆写/擦除体素(直接 rawData[] 访问)

12.1.1 性能优化 ​

两项关键优化显著降低执行时间:

  1. 直接数组访问 — 提取和写回阶段绕过 getVoxel()/setVoxel()(每次调用含 6 次边界检查 + 函数调用开销),通过 volume.getRawData() 直接访问底层 Uint8Array,使用 volume.getBytesPerSlice() 和 volume.getChannels() 内联计算索引:

    typescript
    const rawData = volume.getRawData();
    const channels = volume.getChannels();
    const bytesPerSlice = volume.getBytesPerSlice();
    const rowStride = width * channels;
    // 直接访问: rawData[zOffset + yOffset + x * channels]
    const rawData = volume.getRawData();
    const channels = volume.getChannels();
    const bytesPerSlice = volume.getBytesPerSlice();
    const rowStride = width * channels;
    // 直接访问: rawData[zOffset + yOffset + x * channels]
  2. 去分支卷积 — convolve1D 将循环拆分为三段:左边界(下界检查)、中间段(无任何分支判断,占 ~95% 工作量)、右边界(上界检查):

    typescript
    // 中间段 — 无边界检查
    for (let i = midStart; i < midEnd; i++) {
      let sum = 0;
      const lineOffset = i - radius;
      for (let k = 0; k < kLen; k++) {
        sum += line[lineOffset + k] * kernel[k];
      }
      data[lineStart + i * stride] = sum;
    }
    // 中间段 — 无边界检查
    for (let i = midStart; i < midEnd; i++) {
      let sum = 0;
      const lineOffset = i - radius;
      for (let k = 0; k < kLen; k++) {
        sum += line[lineOffset + k] * kernel[k];
      }
      data[lineStart + i * stride] = sum;
    }

影响范围:仅修改 GaussianSmoother.ts,不影响其他 Tool 的 getVoxel/setVoxel 调用。

12.2 公开 API ​

方法签名说明
gaussianSmooth3D(volume: MaskVolume, channel: number, sigma?: number, spacing?: [number, number, number]): void对指定 channel 执行可分离 3D 高斯平滑(就地修改 volume)
generateKernel1D(sigma: number): Float32Array生成归一化 1D 高斯核,截断于 ±3σ

12.3 各向异性间距支持 ​

当提供 spacing 参数时,各轴 sigma 按 sigma / spacing[axis] 计算,确保物理空间中的平滑半径是各向同性的:

typescript
const sigmaX = spacing ? sigma / spacing[0] : sigma;
const sigmaY = spacing ? sigma / spacing[1] : sigma;
const sigmaZ = spacing ? sigma / spacing[2] : sigma;
const sigmaX = spacing ? sigma / spacing[0] : sigma;
const sigmaY = spacing ? sigma / spacing[1] : sigma;
const sigmaZ = spacing ? sigma / spacing[2] : sigma;

12.4 NrrdTools 集成 ​

executeAction("gaussianSmooth", { sigma }) 的内部流程:

  1. 获取当前活跃的 layer 和 channel
  2. 对所有包含目标 channel 的 Z 切片进行快照(undo 支持,MaskDelta[])
  3. 调用 GaussianSmoother.gaussianSmooth3D(),传入 nrrd_states.image.voxelSpacing 中的体素间距
  4. 捕获平滑后的切片数据,通过 undoManager.pushGroup() 推入撤销栈
  5. 为每个被修改的切片触发 onMaskChanged 回调(后端同步)
  6. 调用 reloadMasksFromVolume() 刷新画布显示
executeAction("gaussianSmooth", { sigma })
  │
  ├─ 快照所有受影响的 Z 切片 (oldSlice)
  │
  ├─ GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacing)
  │
  ├─ 捕获每个 delta 的 newSlice
  │
  ├─ undoManager.pushGroup(deltas)
  │
  ├─ FOR EACH 受影响的切片:
  │   └─ onMaskChanged(sliceData, layerId, channel, z, "z", width, height, false)
  │
  └─ reloadMasksFromVolume()
executeAction("gaussianSmooth", { sigma })
  │
  ├─ 快照所有受影响的 Z 切片 (oldSlice)
  │
  ├─ GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacing)
  │
  ├─ 捕获每个 delta 的 newSlice
  │
  ├─ undoManager.pushGroup(deltas)
  │
  ├─ FOR EACH 受影响的切片:
  │   └─ onMaskChanged(sliceData, layerId, channel, z, "z", width, height, false)
  │
  └─ reloadMasksFromVolume()

12.5 Vue UI 集成 ​

  • 按钮: OperationCtl.vue 中的 "Smoothing: Gaussian"(commFuncBtnValues)
  • 滑块: commSliderRadioValues 中的 "Smooth Sigma"(范围 0.5–5.0,步长 0.5,默认 1.0)
  • 加载动画: 执行期间发送 Segmentation:SwitchAnimationStatus 事件
  • Toast 通知: 通过 useToast composable 显示成功/失败通知
  • Emitter 事件: Segmentation:SwitchAnimationStatus(已注册到 custom-emitter.ts 事件白名单)