第 11 章:vtkImageData——规则三维采样场
学习目标
本章从一个只有 12 个采样点的小型三维数组理解 <code>vtkImageData</code>。读完后,你应该能根据 dimensions、origin、spacing 和 direction 找到采样点的世界坐标,正确计算扁平数组索引,并分清采样点范围、单元数量与 <code>getBounds()</code> 返回的覆盖范围。
11.1 先把三维数组想清楚
本章示例设定:
dimensions = [3, 2, 2]
origin = [-1, 2, 0]
spacing = [0.5, 1.5, 2]
<code>dimensions</code> 表示 i、j、k 三个方向分别有 3、2、2 个采样点,因此总计 <code>3 × 2 × 2 = 12</code> 个采样点。对于这个三个方向都至少有两个采样点的网格,点之间组成的三维单元数为 <code>(3−1) × (2−1) × (2−1) = 2</code>。
这里“采样点”和“体素”在日常医学影像语言中有时被混用。为避免计数混乱,本教程在讨论 vtk.js API 时明确说点数和单元数:<code>getNumberOfPoints()</code> 在本例返回 12,<code>getNumberOfCells()</code> 返回 2。
与 <code>vtkPolyData</code> 不同,<code>vtkImageData</code> 不需要把 12 个点坐标逐一写入 <code>vtkPoints</code>。规则网格知道每个整数下标对应哪里。完整初始化代码为:
const imageData = vtkImageData.newInstance();
imageData.setDimensions(3, 2, 2);
imageData.setOrigin([-1, 2, 0]);
imageData.setSpacing([0.5, 1.5, 2]);
在本例中,<code>setDimensions(3, 2, 2)</code> 产生从零开始的 extent,即 <code>[0, 2, 0, 1, 0, 1]</code>。真实数据也可能使用非零起始 extent;下面的简化索引公式只针对本例这样的从零开始布局。
11.2 从网格下标计算空间位置
方向矩阵为默认单位矩阵时,整数下标 <code>(i, j, k)</code> 的采样点位置为:
x = originX + i × spacingX
y = originY + j × spacingY
z = originZ + k × spacingZ
例如 <code>(2, 1, 1)</code> 对应:
x = -1 + 2 × 0.5 = 0
y = 2 + 1 × 1.5 = 3.5
z = 0 + 1 × 2 = 2
因此世界坐标是 <code>[0, 3.5, 2]</code>。不要把三个 spacing 都想成 1:医学影像的层间距离与平面内像素间距经常不同,忽略它们会改变显示的物理比例。
在代码中优先使用 vtk.js 的坐标转换方法,而不是到处手写公式:
imageData.indexToWorld([2, 1, 1]); // [0, 3.5, 2]
imageData.worldToIndex([0, 3.5, 2]); // 近似 [2, 1, 1]
<code>worldToIndex</code> 也可以返回非整数坐标,表示世界位置落在采样点之间。要读取某个离散采样点时,应先决定如何取整并检查范围;渲染时则可以利用非整数位置做插值。
11.3 三维下标怎样放进一维数值数组
TypedArray 是一维的。本例按 i 变化最快、接着 j、最后 k 的顺序存储。对于从零开始的 extent:
flatIndex = i + nx × (j + ny × k)
这里 <code>nx = 3</code>、<code>ny = 2</code>。例如 <code>(2, 1, 1)</code> 的扁平索引为 <code>2 + 3 × (1 + 2 × 1) = 11</code>。
为了让表格能被肉眼检查,示例人为规定标量值:
value(i, j, k) = i + 10j + 100k
于是 <code>(2, 1, 1)</code> 的值为 112。底层数组按顺序是:
[0, 1, 2, 10, 11, 12, 100, 101, 102, 110, 111, 112]
将它作为 Point Data 中的活动标量:
const imageScalars = vtkDataArray.newInstance({
name: "SyntheticValue",
numberOfComponents: 1,
values: new Float32Array(sampleValues),
});
imageData.getPointData().setScalars(imageScalars);
数值数组有 12 个元组,恰好对应 12 个采样点。若你把 j 和 k 的循环顺序颠倒,数组长度仍可能正确,但每个空间位置上的值会错位;这是比“程序直接报错”更危险的数据错误。
11.4 direction:网格方向不必与世界坐标轴相同
<code>direction</code> 决定网格 i、j、k 三个方向在世界坐标中的朝向。一般形式可以写成:
world = origin + direction × diag(spacing) × [i, j, k]
默认方向为单位矩阵,上式就退化成前面的简单坐标计算。阶段示例提供“旋转网格方向 90°”按钮。按下后,<code>(1, 0, 0)</code> 的世界坐标由 <code>[-0.5, 2, 0]</code> 变为 <code>[-1, 2.5, 0]</code>;它的标量值仍为 1。改变的是采样网格在世界中的方向,不是数组顺序或数值。
本例使用的九个方向值为:
imageData.setDirection([0, 1, 0, -1, 0, 0, 0, 0, 1]);
它让 i 轴沿世界 +y 方向,j 轴沿世界 −x 方向,k 轴保持 +z。不要单凭数组在一行里的排布猜测旋转结果;用 <code>indexToWorld</code> 检查几个基向量位置更可靠。方向应构成合法的正交坐标基。
11.5 为什么 getBounds 不等于采样中心的最小和最大坐标
默认方向下,采样点中心的坐标范围是:
x: -1 到 0
y: 2 到 3.5
z: 0 到 2
但本教程固定版本中,<code>imageData.getBounds()</code> 给出的图像覆盖范围会在边界采样点外侧再扩展半个采样间距。因此本例返回:
[-1.25, 0.25, 1.25, 4.25, -1, 3]
顺序是 <code>[xmin, xmax, ymin, ymax, zmin, zmax]</code>。这不是计算错误:采样点有自己的中心位置,而图像覆盖范围还包括边界采样点周围的半个间距。方向旋转后,返回的仍是世界坐标中的轴对齐包围盒。
排查坐标问题时,应明确自己要的是某个采样点的位置,还是整个图像的覆盖边界。前者用 <code>indexToWorld</code>,后者用 <code>getBounds()</code>。
11.6 体数据的内存从这里开始增长
本例只有 12 个 Float32 标量,原始数值占 <code>12 × 4 = 48</code> 字节。一份 <code>512 × 512 × 512</code> 的单分量 Float32 数据,原始数组就需要 512 MiB;加载、处理和上传 GPU 的过程中还可能存在额外副本。理解 dimensions 与数组类型,才能在后面的真实体渲染中估算浏览器内存,而不是只看文件大小。
<code>vtkImageData</code> 目前只是数据对象。第 15 章会用 ImageMapper 显示切片,第 19–20 章再用 VolumeMapper 和 Volume 显示三维体。创建了 ImageData,并不等于已经进行了体渲染。
本章小结
<code>vtkImageData</code> 用 dimensions、origin、spacing 和 direction 隐式定位规则采样点,再用数据数组保存每处的值。本章的小网格可以逐项核对:12 个点、2 个单元、<code>(2,1,1)</code> 对应扁平索引 11、世界坐标 <code>[0,3.5,2]</code>、标量 112。掌握这些关系后,再进入切片和体渲染就有了可靠的数据基础。