第 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。掌握这些关系后,再进入切片和体渲染就有了可靠的数据基础。