第 29 章:带切片和传递函数控制的体数据查看器

学习目标

本章完成一个同时显示二维切片和三维合成体的查看器。你应能指出两幅图共享的是什么、各自的 Mapper 与显示属性是什么,并能解释为什么调窗宽窗位不会改变体渲染、调不透明度不会改变切片。可运行页面是体数据查看器,实现见 src/volume.js。第 16–21 章介绍原理和各对象,本章重在把它们连接为完整应用。

29.1 一份规则网格,两条显示管线

示例通过 vtkHttpDataSetReader 读取 public/sample/volume/index.json 和独立的 data/Density 文件。输出是 vtkImageData,维度 32×32×32,原点 [-16,-16,-16]、三个 spacing 均为 1、方向为单位矩阵。Density 是单分量 Float32 Point Data,共 32³=32768 个值,原始二进制大小 32768×4=131072 字节。它由两个高斯团和一个壳层的固定公式生成,不代表 CT、MRI 或任何测量;页面上的阈值与窗宽也没有医学单位。

index.json + data/Density
         ↓ vtkHttpDataSetReader
      vtkImageData
       ↙       ↘
vtkImageMapper   vtkVolumeMapper
vtkImageSlice    vtkVolume
左 Renderer       右 Renderer

两条管线直接引用同一个 vtkImageData,没有复制两份体素。切片是从选定 K 层取二维采样面;体渲染对每个屏幕像素沿视线进入体数据的区间多次采样,把每个样本的标量映射成颜色和不透明度,再按顺序合成。三维输入并不必然要做体渲染;同一输入也可以提取等值面。这里选择并排呈现,是为了明确区分选层、显示映射与射线合成。

29.2 读取与激活数组

Reader 初始设为不自动启用数组,先获取清单,再明确加载 Density:

const reader = vtkHttpDataSetReader.newInstance({ enableArray: false });
await reader.setUrl('/sample/volume/index.json', { loadData: false });
reader.enableArray('pointData', 'Density', true);
await reader.loadData();
const image = reader.getOutputData();
image.getPointData().setActiveScalars('Density');

这串调用不能压缩成“Reader 读了 JSON”:JSON 清单描述 ImageData 的 extent、origin、spacing、方向和可用数组;loadData() 才请求独立的数值字节。setActiveScalars('Density') 指明后续 Mapper 要使用哪一个 Point Data 标量。示例验证输出类型、数组存在及分量数,再建立可视化对象。Contrast 虽然在清单中登记,但本页面没有启用,因此不会下载;第 30 章专门观察这个差别。

若把 32×32×32 误解为 32768 个小立方体,也会误算空间范围:这是每轴的采样点数,相邻点间形成 31×31×31 个网格单元。当前原点和 spacing 下,K=16 所在的世界 z 坐标是 -16+16×1=0。一般情况下还要考虑方向矩阵,不能永远直接用这个标量公式替代 indexToWorld。

29.3 左图:vtkImageMapper 与 vtkImageSlice

const sliceMapper = vtkImageMapper.newInstance();
sliceMapper.setInputData(image);
sliceMapper.setSlicingMode(vtkImageMapper.SlicingMode.K);
sliceMapper.setSlice(16);
const slice = vtkImageSlice.newInstance();
slice.setMapper(sliceMapper);
slice.getProperty().setColorWindow(180);
slice.getProperty().setColorLevel(90);
sliceRenderer.addViewProp(slice);

vtkImageMapper 负责从规则网格选 K 层;vtkImageSlice 是加入 Renderer 的显示对象;其 Property 解释这张切片的灰度显示。改变 K 值后调用 setSlice(k),更新文字中的层号与世界坐标,最后渲染左图。改变窗宽和窗位时,只调用 slice.getProperty().setColorWindow()、setColorLevel() 与左图的 render()。

窗位可理解为显示亮度区间中心,窗宽是区间跨度。简化地说,落在区间下方的值靠近黑色,上方靠近白色,中间插值;它不重写 Density。对同一个 K=16 层,窗宽变小通常使中间变化更陡,低高值更容易饱和。示例使用线性插值,让相邻采样点之间较平滑。切片的显示与像素长宽还受相机和 viewport 影响,本例用正交相机正对 K 平面以减少透视干扰。

29.4 右图:射线、颜色与不透明度

右侧 vtkVolumeMapper 读取整个 ImageData,并交给 vtkVolume:

const volumeMapper = vtkVolumeMapper.newInstance();
volumeMapper.setInputData(image);
volumeMapper.setSampleDistance(1);
const volume = vtkVolume.newInstance();
volume.setMapper(volumeMapper);
const color = vtkColorTransferFunction.newInstance();
const opacity = vtkPiecewiseFunction.newInstance();
volume.getProperty().setRGBTransferFunction(0, color);
volume.getProperty().setScalarOpacity(0, opacity);
volumeRenderer.addVolume(volume);

这里的 0 是第一个标量分量的索引;数据只有一个分量。颜色函数把数值映射为 RGB,不透明度函数把数值映射为每个采样位置的 alpha。两者是独立函数:换成灰度配色可保留透明结构;提高不透明度会让前面的样本更快遮住后面的样本,却不改颜色节点本身。

示例的暖色方案设置低值暗蓝、约 65 的蓝色、约 130 的橙色、约 220 的浅色;灰度方案则从黑到白。不透明度由“起始值”与“倍率”重建几个控制点:低于起始值的背景透明,之后逐渐增加。控制点需要按标量值递增设置;例如起始值升高时,先删除旧点,再按新顺序加入。倍率最终限制在 [0,1]。这只是演示用函数,不能把它当成某种实际组织的通用分类。

沿射线采样间距由 volumeMapper.setSampleDistance() 控制。较小间距让射线采样更密,通常提高细节与计算负担;它不修改 ImageData 的 spacing、数组长度或原始分辨率。由于相邻样本的不透明度合成依赖步长,不能仅凭某一张图推断参数“物理正确”。示例设置 setAutoAdjustSampleDistances(false),让滑块控制的值更直接;不同显卡和浏览器的实际性能仍可能不同。setScalarOpacityUnitDistance(0,1) 给定不透明度的参考物理长度,在 spacing 都为 1 的这个例子里便于观察。

勾选“体梯度光照”会调用 volume.getProperty().setShade(true)。体渲染的光照通常依据标量场梯度估计方向,增强边界的立体感;它不是 Three.js 三角形材质上的面法线光照,也不能替代数值和传递函数的解释。对于平滑或噪声很大的体数据,光照可能强化不希望强调的结构。

29.5 分开管理两幅视图的状态

本例用两个 vtkGenericRenderWindow,因此各有 Renderer、相机和 RenderWindow。切片事件只让左图重绘,传递函数、采样间距和 shade 事件只让右图重绘。共享的是 image,并非相机或显示属性。重置按钮分别设置切片相机正对 K 平面、体相机从斜上方看体积,再更新裁剪范围。加载完成前控件保持 disabled,避免事件处理器访问尚未建立的 Mapper 或 Volume。

建议完成一组对照操作:先把 K 从 16 调到 22,确认页面给出的世界 z 从 0 变到 6,同时右图不因选层而改变;再固定 K,分别调窗宽/窗位和不透明度起始值,确认影响各自视图;最后改变采样间距、颜色方案与光照,观察边缘和性能。结果是颜色与可见性的变化,不是源数组数值的变化。

同一体数据的切片与合成体渲染

29.6 排错顺序与迁移到真实数据

画面为空时,先确认请求到了清单及 data/Density,再检查 image.isA('vtkImageData')、维度、active scalars、数组元组数和范围;随后查 Mapper 连接、Volume/切片是否加入正确 Renderer、相机与裁剪范围。若右图几乎全黑或全实心,重点查不透明度函数是否覆盖实际数据范围,而不是先改变灯光。若切片与体外形不一致,查两者是否用了同一个 ImageData、方向矩阵是否正确、相机朝向是否让你在比较同一个方位。

迁移到真实体数据时,应先弄清数组代表的测量量、单位、采样间距、方向矩阵和缺失值,再设计传递函数。真实数据的值域可能与示例的 0–220 完全不同;机械照搬这些控制点常导致空画面或误导性的颜色。第 30 章进一步把“知道清单中有数组”和“实际下载数值”拆开观察。