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+ToolHostPick<>类型解耦。Callback 接口统一(已完成): 原先 10 个
*Callbacks接口已统一为单一ToolHost接口(tools/ToolHost.ts),每个 Tool 通过Pick<ToolHost, ...>选择所需的宿主方法子集。
- CanvasState.ts — 纯状态容器
- RenderingUtils.ts — 渲染工具
- DrawToolCore.ts — 绘画核心(组合 CanvasState + RenderingUtils)
- NrrdTools.ts — 对外 API Facade(组合 CanvasState + DrawToolCore)
- tools/LayerChannelManager.ts — 图层/通道管理
- tools/SliceRenderPipeline.ts — 切片渲染管线
- tools/DataLoader.ts — 数据加载
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 创建位置:
- 系统 Canvas: CanvasState.ts
generateSystemCanvases() - Layer Canvas: CanvasState.ts
generateLayerTargets(layerIds) - Canvas 注释说明: CanvasState.ts constructor
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 类型 |
|---|---|---|---|
| LayerChannelManager | tools/LayerChannelManager.ts | setActiveLayer/Channel/SphereType、可见性控制、自定义通道颜色 | LayerChannelHostDeps (3 methods) |
| SliceRenderPipeline | tools/SliceRenderPipeline.ts | 切片轴配置、canvas 渲染、mask 重载、canvas 翻转、view/canvas 辅助 | SliceRenderHostDeps (10 methods) |
| DataLoader | tools/DataLoader.ts | NRRD 切片加载、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 设置自定义颜色
// 将 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 颜色(推荐,只触发一次重渲染)
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 使用相同颜色
// 把所有 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:读取当前颜色
// 读取 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:重置颜色
// 重置 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)需要手动触发刷新:
// 在 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 回调来自动刷新:
// 在初始化时注册回调(nrrd_states 是 NrrdTools 内部状态,需通过 draw() 选项设置)
// ⚠️ 目前 onChannelColorChanged 挂载在 nrrd_states 上,暂不支持直接从外部设置
// 推荐方式:手动在 setChannelColor() 后调用 refreshChannelColors()// 在初始化时注册回调(nrrd_states 是 NrrdTools 内部状态,需通过 draw() 选项设置)
// ⚠️ 目前 onChannelColorChanged 挂载在 nrrd_states 上,暂不支持直接从外部设置
// 推荐方式:手动在 setChannelColor() 后调用 refreshChannelColors()场景 7:完整的初始化+颜色设置示例(Vue 组件中)
// 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(整数):
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 | (): boolean | Contrast 快捷键是否启用 |
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 打上 contrastOrdersetAllSlices(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 = onSliceMoveshowPreviewSlice(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 原样读回去 |
solidifyLayerForErase | pointer-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:
{
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() → 重渲染 overlaysetCalculateDistanceSphere(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 坐标后调用):
// 后端 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 / z | number | 各轴像素数 |
voxelSpacing | number[] | 体素间距 |
spaceOrigin | number[] | 空间原点 |
layers | string[] | Layer ID 列表 |
nrrd_states.view (IViewState)
| 字段 | 类型 | 说明 |
|---|---|---|
currentSliceIndex | number | 当前切片索引 |
maxIndex / minIndex | number | 切片索引范围 |
changedWidth / changedHeight | number | Canvas 显示尺寸 |
sizeFactor | number | 缩放因子。所有写入它的地方都会夹取到 [minSizeFactor, 8] —— setMainAreaSize、ZoomTool 的滚轮、NrrdState.setZoomFactor |
minSizeFactor | number | sizeFactor 的下限,取值 [0.05, 1]。默认 1(即不小于 1:1)。在 reset() 和病例加载之后保留 |
originWidth / originHeight | number | 原始图像尺寸 |
nrrd_states.interaction (IInteractionState)
| 字段 | 类型 | 说明 |
|---|---|---|
mouseOverX / mouseOverY | number | 鼠标位置 |
mouseOver | boolean | 鼠标是否在画布上 |
cursorPageX / cursorPageY | number | 光标页面坐标 |
drawStartPos | ICommXY | 绘画起始点 |
nrrd_states.sphere (ISphereState)
| 字段 | 类型 | 说明 |
|---|---|---|
sphereOrigin / skinSphereOrigin 等 | ICommXYZ | null | 各类型 sphere 原点 |
sphereRadius | number | sphere 半径 |
sphereBrushRadius | number | SphereBrush/SphereEraser 半径 (1-50) |
sphereMaskVolume | MaskVolume | null | sphere 体积数据 |
nrrd_states.flags (IInternalFlags)
| 字段 | 类型 | 说明 |
|---|---|---|
stepClear | number | 清除步长(内部用) |
clearAllFlag | boolean | 当前是否为全层清除操作 |
loadingMaskData | boolean | 是否正在加载 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)
| 字段 | 类型 | 说明 |
|---|---|---|
pencil | boolean | 铅笔工具激活 |
eraser | boolean | 橡皮擦工具激活 |
sphere | boolean | 球体工具激活 |
sphereBrush | boolean | 球形画笔工具激活 |
sphereEraser | boolean | 球形橡皮擦工具激活 |
activeSphereType | "tumour" | "skin" | "nipple" | "ribcage" | 当前 sphere 类型 |
gui_states.drawing (IDrawingConfig)
| 字段 | 类型 | 说明 |
|---|---|---|
globalAlpha | number | 全局透明度 (0.6) |
lineWidth | number | 线宽 |
color / fillColor / brushColor | string | 画笔颜色 (Hex) |
brushAndEraserSize | number | 画笔/橡皮擦大小 |
maskRenderMode | MaskRenderMode | "fill"(默认)或 "outline"。对所有图层和通道同时生效;只被渲染路径读取,写入路径从不读它 |
gui_states.viewConfig (IViewConfig)
| 字段 | 类型 | 说明 |
|---|---|---|
mainAreaSize | number | 主区域大小 |
dragSensitivity | number | 拖拽灵敏度 |
cursor / defaultPaintCursor | string | 光标样式 |
readyToUpdate | boolean | 准备更新标志 |
gui_states.layerChannel (ILayerChannelState)
| 字段 | 类型 | 说明 |
|---|---|---|
layer | string | 当前活跃 Layer (默认 "layer1") |
activeChannel | number | 当前活跃 Channel (1-255;0 = 空/已擦除) |
layerVisibility | Record<string, boolean> | Layer 可见性 |
channelVisibility | Record<string, Record<number, boolean>> | Channel 可见性 |
layerOpacity | Record<string, number> | Per-layer 透明度 (0.1–1.0,默认 1.0) |
3.3 protectedData (IProtected)
定义位置: CanvasState.ts constructor
| 字段 | 说明 |
|---|---|
axis | 当前视图轴 "x" / "y" / "z" |
maskData.volumes | Record<string, MaskVolume> — 每个 Layer 对应的 3D 体积 |
layerTargets | Map<string, ILayerRenderTarget> — 每个 Layer 的 canvas+ctx |
canvases | 5 个系统 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。
// 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
onLayerVolumeCleared: (layerId: string) => voidonLayerVolumeCleared: (layerId: string) => void4.3 onChannelColorChanged(Phase B 新增)
存储位置: CanvasState.annotationCallbacks.onChannelColorChanged(IAnnotationCallbacks 接口,core/types.ts)
⚠️ 注意: 原文档说定义在
INrrdStates上,已不正确。该回调现在属于IAnnotationCallbacks,存储在CanvasState.annotationCallbacks中,不在nrrd_states上。
onChannelColorChanged: (layerId: string, channel: number, color: RGBAColor) => voidonChannelColorChanged: (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 模式下左键松开时调用。
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 后调用(所有类型通用)。
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 Type | Layer | Channel | 颜色 |
|---|---|---|---|
| tumour | layer1 | 1 | #10b981 (Emerald) |
| nipple | layer1 | 2 | #f43f5e (Rose) |
| ribcage | layer1 | 3 | #3b82f6 (Blue) |
| skin | layer1 | 4 | #fbbf24 (Amber) |
⚠️ 当前 sphere 数据不写入 layer MaskVolume,仅作为 overlay 显示。Channel 映射预留供未来使用。
5. MaskVolume 存储与渲染
5.1 内存布局
内存布局: [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 各轴切片维度
| 轴 | 切片宽度 | 切片高度 | 说明 |
|---|---|---|---|
| z (Axial) | width | height | 最常用,连续内存 |
| y (Coronal) | width | depth | 按行提取 |
| x (Sagittal) | height | depth | 列沿 y、行沿 z(4.1.0 起改变) |
对应 emptyCanvas 尺寸设置: SliceRenderPipeline.setEmptyCanvasSize() → tools/SliceRenderPipeline.ts(已从 NrrdTools 提取)
5.3 切片提取 (读取 Mask)
getSliceUint8(sliceIndex, axis) — MaskVolume.ts:1019-1058
返回原始 Uint8Array,用于:
- 后端同步 (
getMaskcallback) - Undo/Redo 快照
各轴实现:
- Z 轴 L1032-1035: 连续内存
subarray批量复制(最快) - Y 轴 L1036-1042: 按行迭代复制
- X 轴 L1043-1055: 逐像素提取(最慢)
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 精确匹配走 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
renderLabelSliceInto(
sliceIndex: number,
axis: 'x' | 'y' | 'z',
target: ImageData, // 预分配的 ImageData buffer
channelVisible?: Record<number, boolean>, // Channel 可见性
opacity: number = 1.0
): voidrenderLabelSliceInto(
sliceIndex: number,
axis: 'x' | 'y' | 'z',
target: ImageData, // 预分配的 ImageData buffer
channelVisible?: Record<number, boolean>, // Channel 可见性
opacity: number = 1.0
): void渲染逻辑:
- 读取 label 值 (0-255)
label === 0→ 透明 (RGBA 全 0)channelVisible?.[label] === false→ 隐藏该 Channel → 透明- 否则 → 从 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(前侧在左边) | −k | transform(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) 决定:
/** 冠状面和矢状面的切片行里 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
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 类型 |
|---|---|---|---|
| LayerChannelManager | tools/LayerChannelManager.ts | 211 | LayerChannelHostDeps (3 methods: reloadMasksFromVolume, getVolumeForLayer, onChannelColorChanged) |
| SliceRenderPipeline | tools/SliceRenderPipeline.ts | 453 | SliceRenderHostDeps (10 methods) |
| DataLoader | tools/DataLoader.ts | 222 | DataLoaderHostDeps (7 methods) |
DrawToolCore 管理的 Tool(事件处理)
这些 Tool 由 DrawToolCore.initTools() 初始化,处理用户交互事件:
| Tool | 文件 | HostDeps 类型 | 说明 |
|---|---|---|---|
| SphereTool | tools/SphereTool.ts | SphereHostDeps | 3D 球形标注工具,支持 4 种类型 (tumour/skin/ribcage/nipple),包含点击放置 (onSphereClick) 和松开完成 (onSpherePointerUp) |
| CrosshairTool | tools/CrosshairTool.ts | —(无 callbacks) | 十字准星位置标记、坐标转换、crosshair 渲染 (renderCrosshair) |
| ContrastTool | tools/ContrastTool.ts | ContrastHostDeps | 窗位/窗宽调节 |
| ZoomTool | tools/ZoomTool.ts | ZoomHostDeps | 缩放/平移 |
| EraserTool | tools/EraserTool.ts | —(无 callbacks) | 橡皮擦 |
| PanTool | tools/PanTool.ts | PanHostDeps | 右键拖拽平移画布 |
| DrawingTool | tools/DrawingTool.ts | DrawingHostDeps | 铅笔/画笔/橡皮擦绘画逻辑,含笔刷 hover 追踪 (createBrushTrackingHandler) 和圆圈预览 (renderBrushPreview) |
| SphereBrushTool | tools/SphereBrushTool.ts | SphereBrushHostDeps | 3D 球形体积绘画 (sphereBrush) 和擦除 (sphereEraser);拖动擦除;分组多切片 undo |
| ImageStoreHelper | tools/ImageStoreHelper.ts | ImageStoreHostDeps | Canvas↔Volume 同步 |
| DragSliceTool | tools/DragSliceTool.ts | DragSliceHostDeps | 拖拽切换切片 |
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 模式":
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 同步流程:
- 将 layer canvas 绘制到 emptyCanvas [L124]
- 从 emptyCanvas 获取 ImageData [L127-132]
- 调用
volume.setSliceLabelsFromImageData()[L142-148] 写入 MaskVolume - 提取切片通知后端 [L161]
filterDrawedImage(axis, sliceIndex) — ImageStoreHelper.ts:85-107
Volume → Canvas 读取,调用 volume.renderLabelSliceInto().
7.3 SphereTool(球形标注工具)
类型与常量
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, ...>)
// 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, ...>)
// 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 集成
// 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, ...>)
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, ...>)
// 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/SliceIndex | mousedown 时的 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 状态):
// 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 集成
// 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 | 默认 | 无交互 |
draw | Shift 按住 | 绘画模式 |
drag | 垂直拖拽 | 切片导航 |
contrast | Ctrl/Meta 按住 | 窗位/窗宽调节 |
crosshair | S 键 | 十字准星 |
8.2 事件永久路由(Event Lifecycle Refactor)
EventRouter 在 bindAll() 时永久绑定所有 pointer/keyboard/wheel 事件到 drawingCanvas。DrawToolCore 通过 set*Handler() 注册处理器,不再手动 addEventListener/removeEventListener。
注册的处理器
| 方法 | Handler 内容 | 守卫条件 |
|---|---|---|
setPointerDownHandler | 转发到 handleOnDrawingMouseDown | 无(每次 pointerdown 都转发) |
setPointerMoveHandler | 转发到 handleOnDrawingMouseMove | drawingTool.isActive || panTool.isActive(仅活跃交互时转发) |
setPointerUpHandler | 转发到 handleOnDrawingMouseUp | drawingTool.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 字段
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:SliceIKeyBoardSettings = {
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 系统
Delta 结构
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 | 颜色 | Hex | RGBA |
|---|---|---|---|
| 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(高斯平滑)
纯静态工具类,用于分割 mask 的 3D 高斯平滑。无 DOM/Canvas/GUI 依赖。包含直接数组访问和去分支卷积两项性能优化。
12.1 算法
对 MaskVolume 中的单个 label channel 执行可分离 3D 高斯模糊:
- 提取: 创建 Float32Array,voxel === channel 的位置为 1.0,其余为 0.0(直接
rawData[]访问) - 模糊: 沿 X → Y → Z 三轴依次执行 1D 高斯卷积(核截断于 ±3σ,中间段去分支)
- 阈值: 以 0.5 为阈值二值化
- 写回: 根据阈值结果覆写/擦除体素(直接
rawData[]访问)
12.1.1 性能优化
两项关键优化显著降低执行时间:
直接数组访问 — 提取和写回阶段绕过
getVoxel()/setVoxel()(每次调用含 6 次边界检查 + 函数调用开销),通过volume.getRawData()直接访问底层Uint8Array,使用volume.getBytesPerSlice()和volume.getChannels()内联计算索引:typescriptconst 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]去分支卷积 —
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] 计算,确保物理空间中的平滑半径是各向同性的:
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 }) 的内部流程:
- 获取当前活跃的 layer 和 channel
- 对所有包含目标 channel 的 Z 切片进行快照(undo 支持,
MaskDelta[]) - 调用
GaussianSmoother.gaussianSmooth3D(),传入nrrd_states.image.voxelSpacing中的体素间距 - 捕获平滑后的切片数据,通过
undoManager.pushGroup()推入撤销栈 - 为每个被修改的切片触发
onMaskChanged回调(后端同步) - 调用
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 通知: 通过
useToastcomposable 显示成功/失败通知 - Emitter 事件:
Segmentation:SwitchAnimationStatus(已注册到custom-emitter.ts事件白名单)