使用VTK和Python进行体绘制
- Introduction
- Volume Rendering
- 1. Imports
- 2. Helper-functions
- 3.Options
- 4. Image-Data Input
- 5. Prep-work
- 6. Volume Rendering
Introduction
科学可视化技术是运用计算机图形学、图像处理、计算机视觉等方法,将科学、工程学、医学等计算、测量过程中的符号、数字信息转换为直观的图形图像,并在屏幕上显示的理论、技术和方法。
体绘制是科学可视化领域中的一个技术方向。如前所述,体绘制的目标是在一副图片上展示空间体细节。举例而言,你面前有一间房子,房子中有家具、家电,站在房子外面只能看到外部形状,无法观察到房子的布局或者房子中的物体;假设房子和房子中的物体都是半透明的,这样你就可以同时查看到所有的细节。这就是体绘制所要达到的效果。
体绘制基本流程如下图
完成本文的体绘制工作,首先要下载大脑图像数据集,大脑图像数据集下载。使用jupyter notebook进行python代码编写和运行。
Volume Rendering
下面将从在数据集中加载和转换标签字段开始,以使其与将使用的 volume 映射类兼容。然后定义颜色和不透明度传递函数,这是整个 volume 渲染过程中最重要的部分,因为这些函数定义了最终渲染的外观。定义了这些之后,将演示使用 VTK 提供的一些不同 volume 映射类的体绘制,并展示它们的结果。
1. Imports
import os
import numpy
import vtk
其中 vtk 在 Jupyter Notebook 中使用 pip 安装即可
2. Helper-functions
下面定义几个帮助函数:
(1)vtk_show()
这个函数允许传递一个 vtkRenderer 对象并获得该渲染的 PNG 图像输出,与 IPython Notebook 单元格输出兼容。
from IPython.display import Image
def vtk_show(renderer, width=400, height=300):
"""
Takes vtkRenderer instance and returns an IPython Image with the rendering.
"""
renderWindow = vtk.vtkRenderWindow()
renderWindow.SetOffScreenRendering(1)
renderWindow.AddRenderer(renderer)
renderWindow.SetSize(width, height)
renderWindow.Render()
windowToImageFilter = vtk.vtkWindowToImageFilter()
windowToImageFilter.SetInput(renderWindow)
windowToImageFilter.Update()
writer = vtk.vtkPNGWriter()
writer.SetWriteToMemory(1)
writer.SetInputConnection(windowToImageFilter.GetOutputPort())
writer.Write()
data = str(buffer(writer.GetResult()))
return Image(data)
(2)createDummyRenderer()
这个函数只是创建一个vtkRenderer对象,设置一些基本属性,并为本文的渲染目的配置相机。由于我们将渲染几个不同的场景,所以为每个案例创建一个新的渲染器和场景比一直移除或添加 actor 更简单,从而使每个渲染独立于其之前的渲染。
def createDummyRenderer():
renderer = vtk.vtkRenderer()
renderer.SetBackground(1.0, 1.0, 1.0)
camera = renderer.MakeCamera()
camera.SetPosition(-256, -256, 512)
camera.SetFocalPoint(0.0, 0.0, 255.0)
camera.SetViewAngle(30.0)
camera.SetViewUp(0.46, -0.80, -0.38)
renderer.SetActiveCamera(camera)
return renderer
(3)两个lambda表达式
用于快速将 一个 list 或者一个 tuple 转换为 一个 numpy 的 n 维数组,反之亦然。
l2n = lambda l: numpy.array(l)
n2l = lambda n: list(n)
3.Options
在 Jupyter Notebook 开头定义一些变量:
# Path to the .mha file
filenameSegmentation = "./nac_brain_atlas/brain_segmentation.mha"
# Path to colorfile.txt
filenameColorfile = "./nac_brain_atlas/colorfile.txt"
# Opacity of the different volumes (between 0.0 and 1.0)
volOpacityDef = 0.25
- filenameSegmentation 用于保存 .mha 文件的位置;该文件在同一文件中同时包含标题和二进制图像数据
- filenameColorfile 用于保存 .txt 文件的位置;该文件是附带的颜色文件,它本质上是一个CSV 文件,列出了标签字段中的每个索引以及代表的大脑结构的名称和推荐的 RGB 颜色。
- volOpacityDef 稍后在我们为volume映射器定义不透明度传递函数时发挥作用,它是所有渲染大脑结构的基线不透明度。
4. Image-Data Input
根据提供的 .mha 文件加载标签字段
VTK 支持读取 .mha 格式的未压缩 MetaImage 文件,通过 vtkMetaImageReader 类进行读取
reader = vtk.vtkMetaImageReader()
reader.SetFileName(filenameSegmentation)
castFilter = vtk.vtkImageCast()
castFilter.SetInputConnection(reader.GetOutputPort())
castFilter.SetOutputScalarTypeToUnsignedShort()
castFilter.Update()
imdataBrainSeg = castFilter.GetOutput()
读取之后,vtkImageCast 在下方创建一个新对象 castFilter 并将其输入连接到 reader 输出,从而为其提供图像。然后,关键是调用适当的方法来设置输出图像的所需数据类型。在我们的例子中,我们希望输出图像是unsigned short类型的,因此我们调用该SetOutputScalarTypeToUnsignedShort方法。castFilter 随后调用 Update,reader 获取“原始”图像数据,并将其转换为 的类型 unsigned short。最后通过检索该数据 GetOutput 并将其存储在 imdataBrainSeg 变量中.
5. Prep-work
(1)Transfer functions: Read colorfile into a dictionary
VTK任何体绘制的外观都归结为定义传递函数。这些传递函数告诉volume映射器要为输出中的每个像素赋予什么颜色和不透明度。
import csv
fid = open(filenameColorfile, "r")
reader = csv.reader(fid)
dictRGB = {}
for line in reader:
dictRGB[int(line[0])] = [float(line[2])/255.0,
float(line[3])/255.0,
float(line[4])/255.0]
fid.close()
循环遍历从颜色文件中读取的每个条目,并创建一个字典dictRGB,其中索引标签充当key,value是一个列表,其中包含分配给该组织的 RGB 颜色。
(2)Transfer functions : Define color transfer function
定义一个颜色传递函数。这是 vtkColorTransferFunction 类的一个实例,并充当标量像素值到给定 RBG 颜色的映射。
funcColor = vtk.vtkColorTransferFunction()
for idx in dictRGB.keys():
funcColor.AddRGBPoint(idx,
dictRGB[idx][0],
dictRGB[idx][1],
dictRGB[idx][2])
首先创建一个新 vtkColorTransferFunction 的 funcColor,它允许我们创建我们之前讨论过的标签索引颜色映射。然后我们简单地遍历dictRGB之前创建的字典中的所有键,即所有标签索引,并使用 AddRGBPoint 方法添加具有该标签索引和匹配 RGB 颜色的点。
(3)Transfer functions : Define scalar opacity transfer function
下面需要定义一个标量不透明度函数。我们将使用它来简单地将每个标签与不透明度值匹配。由于我们没有针对不同组织预定义不透明度值,将所有不透明度设置为变量 volOpacityDef 中定义的单个值:
funcOpacityScalar = vtk.vtkPiecewiseFunction()
for idx in dictRGB.keys():
funcOpacityScalar.AddPoint(idx, volOpacityDef if idx<>0 else 0.0)
请注意,不透明度函数是通过 vtkPiecewiseFunction 定义的,因为它只是定义了一个 1-1 映射。还要注意 AddPoint 添加新点的方法的用法。注意到 0 标签的透明度设置为了 0.0。这意味着我们正在制作background,即分割周围的黑色空白空间,完全不可见(否则我们只会在渲染周围看到一个巨大的黑色块)。
(4)Transfer functions : Define gradient opacity transfer function
标量不透明度函数简单地为每个像素强度分配一个不透明度值。然而,这通常会导致外观相当均匀的渲染,其中外部组织在图像中占主导地位。这就是渐变不透明度函数发挥作用的地方。通过这样的函数,我们将标量空间梯度(即标量在空间中变化的程度)映射到不透明度乘数。这些梯度在“行进”通过同质区域时趋向于变小,而在穿过不同组织时变大。因此,通过这样的功能,我们可以使组织的“内部”变得相当透明,同时使组织之间的边界更加突出,从而使整个volume的图像更加清晰。
funcOpacityGradient = vtk.vtkPiecewiseFunction()
funcOpacityGradient.AddPoint(1, 0.0)
funcOpacityGradient.AddPoint(5, 0.1)
funcOpacityGradient.AddPoint(100, 1.0)
这个函数也是通过一个vtkPiecewiseFunction对象来定义的。通过这个函数,高于1.0的低梯度的像素,将其不透明度乘以0.0,梯度在1和5之间的像素,其不透明度乘以0.0~0.1,梯度高于5的像素,其透明度将乘以一个大于1的数。
(5)Volume Properties
下面定义 volume 的基本属性,创建和配置一个 vtkVolumeProperty 对象,该对象“代表渲染 volume 的通用属性”:
propVolume = vtk.vtkVolumeProperty()
propVolume.ShadeOff()
propVolume.SetColor(funcColor)
propVolume.SetScalarOpacity(funcOpacityScalar)
propVolume.SetGradientOpacity(funcOpacityGradient)
propVolume.SetInterpolationTypeToLinear()
通过 SetColor ,SetScalarOpacity 和 SetGradientOpacity 方法,把我们之前定义的 funcColor、funcOpacityScalar 和funcOpacityGradient三个传递函数分配给体积属性。最后,对 volume 进行插值。这里选择最近邻插值,通过SetInterpolationTypeToNearest 方法设置,线性插值通过SetInterpolationTypeToLinear方法设置。通常,在处理离散数据时,就像案例中的标签字段一样,我们会选择最近邻插值,因为这样我们就不会引入与任何组织都不匹配的“新”值。
6. Volume Rendering
(1)vtkVolumeRayCastMapper
vtkVolumeRayCastMapper 是 VTK 中的“经典”体绘制类,其将自己宣传为“用于体绘制的缓慢但准确的映射器”。顾名思义,vtkVolumeRayCastMapper该类通过类型为 vtkVolumeRayCastFunction 的合适的光线投射函数进行光线投射的方式来执行体绘制。
vtkVolumeRayCastMapper 包含下面三种:
- vtkVolumeRayCastCompositeFunction:根据存储在 volume 的 vtkVolumeProperty 中的属性沿着光线进行合成。
- vtkVolumeRayCastMIPFunction:计算沿射线遇到的最大值。
- vtkVolumeRayCastIsosurfaceFunction:将射线与标量场中的解析等值面相交。
在本例中,选择 vtkVolumeRayCastCompositeFunction :
首先通过 vtkVolumeRayCastCompositeFunction 类创建一个名为funcRayCasttype的新对象。然后我们通过SetCompositeMethodToClassifyFirst 方法将函数设置为在插值之前进行的首次像素分类。这使标签保持相对均匀,但如果我们决定采用该方法的另一种SetCompositeMethodToInterpolateFirst 方法,不同的标签会被破坏并且会得到混乱的渲染。
funcRayCast = vtk.vtkVolumeRayCastCompositeFunction()
funcRayCast.SetCompositeMethodToClassifyFirst()
接下来,我们需要创建一个 volume 映射器。先通过 vtkVolumeRayCastMapper 类创建了一个名为 mapperVolume 的新对象,并通过 SetVolumeRayCastFunction 方法将新创建的光线投射函数 funcRayCast 提供给 mapperVolume 。最后,我们将正在渲染的实际图像数据提供给 mapperVolume ,这些数据存储在对象 imdataBrainSeg 中。
mapperVolume = vtk.vtkVolumeRayCastMapper()
mapperVolume.SetVolumeRayCastFunction(funcRayCast)
mapperVolume.SetInput(imdataBrainSeg)
第三步,我们创建一个 vtkVolume 对象,命名为 actorVolume ,这相当于一个 vtkActor 但是用于 volume 数据。我们把映射器映射到 mapperVolume ,把属性设置为我们在准备过程中创建的 vtkVolumeProperty 对象 propVolume。
actorVolume = vtk.vtkVolume()
actorVolume.SetMapper(mapperVolume)
actorVolume.SetProperty(propVolume)
最后,我们只是完成预渲染动作。我们通过在开始时定义的辅助函数 createDummyRenderer 创建一个新的渲染器,并加入actorVolume。
renderer = createDummyRenderer()
renderer.AddActor(actorVolume)
执行
vtk_show(renderer, 800, 800)
输出效果如下图:
(2)Clipping Plane
由于数据集的组织太多,脑回彼此混合,上图展示出来的不是特别清晰。下面通过裁剪的方式进行优化。
vtkVolumeRayCastMapper 类以及在 VTK 中的volume映射器都支持裁剪,为了使用裁剪,首先需要创建一个具有适当位置和方向的平面:
_origin = l2n(imdataBrainSeg.GetOrigin())
_spacing = l2n(imdataBrainSeg.GetSpacing())
_dims = l2n(imdataBrainSeg.GetDimensions())
_center = n2l(_origin+_spacing*(_dims/2.0))
使用 vtkImageData 的适当方法来检索图像数据的原点坐标、间距和尺寸,并使用 l2n 辅助函数快速将这些列表转换为numpy.ndarray对象,以允许我们和这些数做一些数学运算。然后,计算图像数据的中心坐标并将其存储在 _center 下。
然后我们需要做的就是创建一个新的 vtkPlane,将其原点设置为图像数据的中心,并将其法线设置为负 Z 轴。
planeClip = vtk.vtkPlane()
planeClip.SetOrigin(_center)
planeClip.SetNormal(0.0, 0.0, -1.0)
接下来重复vtkVolumeRayCastMapper过程的代码,唯一区别是将新创建的裁剪平面添加到 volume 映射器:
mapperVolume.AddClippingPlane(planeClip)
执行
vtk_show(renderer, 800, 800)
输出效果如下图:
(3)vtkVolumeTextureMapper2D
vtkVolumeTextureMapper2D 是另一个 volume 渲染器,它使用完全在 GPU 端发生的基于纹理的体绘制,并在很短的时间内生成更漂亮的渲染。
下面的代码和我们之前的唯一区别是,mapperVolume现在是 vtkVolumeTextureMapper2D,而不是vtkVolumeRayCastMapper。此外,我们没有定义任何光线投射函数,因为这不是此类的操作方式。除了这些差异之外,代码的其余部分与之前相同。
mapperVolume = vtk.vtkVolumeTextureMapper2D()
mapperVolume.SetInput(imdataBrainSeg)
mapperVolume.AddClippingPlane(planeClip)
actorVolume = vtk.vtkVolume()
actorVolume.SetMapper(mapperVolume)
actorVolume.SetProperty(propVolume)
renderer = createDummyRenderer()
renderer.AddActor(actorVolume)
执行
vtk_show(renderer, 800, 800)
输出效果如下图: