原文链接:http://www.aosabook.org/en/vtk.html

作者:Berk Geveci 与 Will Schroeder

可视化工具箱(Visualization Toolkit, VTK)是一种广泛使用的数据处理与可视化软件系统。它应用于科学计算、医学影像分析、计算几何、渲染、图像处理以及信息学等领域。本章,我们展示一个VTK的简要概览,包括一些使之成为一个成功系统的基本设计模式。

要真正理解一个软件系统,关键之处不仅要理解它能够解决什么问题,而且还要了解它出现时的特定文化环境。在VTK的案例中,系统表面看起来要设计成用于科学数据的三维可视化系统。但VTK出现时的文化语境为奋斗者们添上了一个意义深远的背后故事,这有助于解释软件为什么是这样设计和部署的。

在VTK创生和开始编写之时,它的初始作者(Will Schroeder、Ken Martin、Bill Lorensen)还是GE研发部门的科研人员。我们向一个名为LYMB的先驱性系统投入了大量精力,该系统是一种以C实现的、类似Smalltalk的开发环境。在那个时代,它是一个伟大的系统,我们作为科研人员一再地被阻止在两大障碍上:1)IP问题(此处意指知识产权(Intellectual Property, IP)——译注。)和2)非标准的、有所有权的软件。IP问题之所以是个问题,是因为一旦GE公司的律师介入,那么尝试将软件向公司外部公布软件就几乎不可能了。第二,即使我们在GE公司内部部署软件,许多我们的用户也会受制于学习一个有所有权的、非标准系统,因为为了掌握它而作出的努力不能在他离开公司后转移到新的雇主那里;并且,这种软件没有标准工具集所提供的广泛支持。于是,VTK的原始动机就是开发一个开放标准,或曰“协作平台”,通过它,我们能够很容易地将技术传授给我们的用户。因此,为VTK选择一个开源许可证或许是我们所做出的最重要的设计决策。

最终选择了非互利的、自由的许可证(比如:选BSD(即BSD许可证(Berkeley Software Distribution License,BSD)。——译注。)而不选GPL(即GPL许可证(GNU General Public License)。——译注。))在事后证明是一个值得效仿的决策,因为它最终使基于商业的服务和咨询成为可能,而这正成就了Kitware。在我们做出这个决定的时候,我们最感兴趣的是降低与学术界、研究机构以及商务实体之间合作的壁垒。我们从那时也发现,许多组织都避免使用互利性许可证,由于它们可能造成的严重问题。事实上,我们可能会争论互利性许可证在延缓开源软件的接收上有很大作用,但这另当别论。这里的要点是:与任何软件系统相关的重要设计决策之一就是著作权许可证的选择。重新审视项目的目标,然后再恰当地解决IP问题是很重要的。

24.1 VTK是什么?

VTK最初是以一个科学数据可视化系统出现的。可视化领域之外的许多人都天真地把它当成一种特殊的几何渲染:查看虚拟物体并与之交互。尽管这些确实是可视化的一部分,但是通常的数据可视化还包括把数据转换成感知性输入的整个过程,典型的数据是图像,此外还包括触觉、听觉等其他形式。数据形式不仅由几何拓扑结构组成——比如像网格或者复杂空间分解等抽象形式,还有核心结构的属性,诸如标量(如:温度或压强),矢量(如:速度),张量(如:应力与张力),以及渲染属性,诸如表面法线和纹理坐标等。

注意,通常情况下,表示时空信息的数据被看做是科学可视化的一部分。然而,还有更抽象数据形式,比如市场统计资料、网页、文档以及其它信息,它们只能通过诸如非结构文档、表格、图和树等抽象(即:非时空)关系来表示。这些抽象数据一般通过信息可视化的方法来处理。在社区的帮助下,VTK现在能够完成科学可视化和信息可视化方面的工作。

作为一种可视化系统,VTK的角色是以这些形式获取数据,并最终将它们转换成利于人类感官理解的形式。因此,VTK的核心需求之一就是创建数据流管线的能力,这种管线能够读入、处理、表示并最终渲染数据。这样,工具箱就必须构建成一个灵活的系统,它的设计在许多层面上反映了这一点。例如,我们有目的地将VTK设计成这样一种工具箱,它具有许多可互换的组件,这些组件可以组合起来用于处理多种数据。

24.2 架构特性

在深入介绍VTK特殊的架构特性之前,先介绍顶层的概念,它们系统的开发和使用都产生了深远的影响。其中之一就是VTK的混合包装设施。该设施从VTK的C++实现自动生成Python,Java,和Tcl等的语言绑定(还可绑定更多的语言,并且有些已经实现了——译注)。最具实力的开发者将使用C++进行工作。使用者和应用程序开发者也可以使用C++,但是通常情况下,上文提到的解释性语言更加适合这两个群体。混合的编译性/解释性环境将这两个领域的优势结合在了一起:计算密集型算法的高性能和样机或开发的灵活性。事实上,这种多语言计算的方法在许多科学计算社区中得到广泛应用,并且许多团队将VTK作为他们自己软件的一个范本。

就软件过程而言,VTK采用CMake来控制构建过程;CDash/CTest用于测试;然后CPack用于跨平台部署。VTK确实可以在几乎任何计算机上进行编译,包括因其简陋的开发环境而声名狼藉的超级计算机。此外,开发工具外围还包括网页、wiki、邮件列表(用户区和开发者区),文档生成设施(即:Doxygen)和bug追踪系统(Mantis)。

24.2.1 核心特性

由于VTK是面向对象系统,在其内部,对类的访问和数据成员的实例化都被小心地管理起来。通常情况下,所有的数据成员的访问权限均为protected或private。通过SetGet方法来访问这些数据成员,这两种方法具有各种类型的形参,例如:布尔型数据、模态数据、字符串、以及向量。这些方法中的多数的创建是通过向类的头文件中插入宏来实现的。例如:

vtkSetMacro(Tolerance, double); vtkGetMacro(Tolerance, double);

可以展开为如下形式:

virtual void SetTolerance(double); virtual double GetTolerance();

使用这些宏的原因已经超出了仅仅使代码清晰。VTK中有重要的数据成员控制调试、更新对象的修改时间(MTime)、并恰当地管理引用计数。这些宏正确地操作这些数据,因而强烈推荐使用它们。例如,当一个对象的修改时间没有得到恰当的管理时,VTK中就会出现一个尤其严重的bug。在这种情况下,代码就不会按其应该运行的方式运行,或者还会执行多次。

VTK的优势之一就是其相对简单的用于表示和管理数据的方法。典型的情况下,各种特殊数据(例如:vtkFloatArray)的数组用于表示信息的连续片段。例如:一个装载有三个三维坐标点的表可以用具有9个元素的vtkFloatArray来表示。这些数组有一种元组的记法,故有一个三维坐标点即一个3元组,而一个对称的3×3张量矩阵可以由一个6元组表示()。专门采用这种设计是因为在科学计算中,与操作数组的系统(例如:Fortran)接口是很常见的,并且这样还能使对大块连续数据的内存分配与回收变得更加高效。再者,连续数据的通信、串行、以及IO操作通常更有效率。这些(可以加载各种类型数据的)核心数据数组表示了VTK中的大部分数据,且具有多种方便的方法,以进行信息的插入和访问,包括用于快速访问的方法、以及在添加更多数据时所需要的自动分配内存的方法。数据数组是抽象类vtkDataArray的子类,该抽象类的意义在于:通用的虚方法可用于简化编码。但是,为了实现更高的性能,静态的、模版化的函数被引入,这样就可以根据不同的参数类型进行切换,并实现随后对连续数据数组的直接访问。

即使由于性能方面的原因,模板被广泛地使用,C++模板通常在公有类的API中也是不可见的。这点在STL中也是如此:我们采用了PIMPL设计模式来隐藏模版实现的复杂细节。这种模式为我们提供了很大帮助,尤其是在以前文所述将代码包装为解释性代码的时候。避免公有API中模板的复杂性意思是:在应用程序开发者看来,VTK实现大部分是无需考虑数据类型的选择的。当然,在其外壳之下,代码的执行是由数据类型来驱动的,而该数据类型则一般是运行时访问数据时确定的。

一些用户很想知道为什么VTK使用引用计数来管理内存而不是垃圾回收这一对用户来说更为友好的方式。基本的答案是当数据被删除的时候,VTK需要对其完全控制,因为要处理的数据量可能十分巨大。例如,一组1000×1000×1000字节的体数据的数据量是1G字节。把这么大的数据留在内存中等待垃圾回收器来决定是否应该释放它们,确实不是一个好主意。在VTK中,大部分类(vtkObject的子类)具有内建的引用计数能力。每个对象都包含有一个引用计数,它在该对象实例化时被初始化为1。每次使用该对象都会进行注册,然后引用计数就加1。类似地,当使用该对象进行了反注册(或者等效地认为该对象被删除),那么引用计数就会减1。最终的对象引用计数减至0,此时该对象自毁。下面列举一个典型的例子:

vtkCamera *camera = vtkCamera::New();     // reference count is 1 camera->Register(this);                   // reference count is 2 camera->Unregister(this);                 // reference count is 1 renderer->SetActiveCamera(camera);        // reference count is 2 renderer->Delete();                       // ref count is 1 when renderer is deleted camera->Delete();                         // camera self destructs

这里还有另外一个关于为什么引用计数对于VTK很重要的原因——它提供了有效复制数据的能力。例如:想象有一个数据对象D1,它由许多数据数组组成:点、多边形、颜色、标量、以及纹理坐标等。现在假设处理该数据来生成一个新的数据对象D2,此对象与第一个对象相同,还外加了向量数据(用于定位点)。一种浪费资源的方式是完全复制(深拷贝)D1来创建D2,然后向其中加入新的向量数据数组。另有一种方法,我们创建一个空的D2,然后将D1中的数组传给D2(浅拷贝),使用引用计数来追踪数据所有权,最终向添加新的向量数组。后者方法避免了复制数据,这正如前文所述,对一个优秀可视化系统是必不可少的。我们在本章的稍后内容中可以看到,数据处理的管线例行公事式地实现了这种运行机制,即:将数据从算法的数据复制至输出,此时引用计数对于VTK是必不可少的。

当然,引用计数也有一些臭名昭著的问题。偶尔会存在引用周期,这时循环中的对象以一种相互支持的配置来引用彼此。这种情况下,就需要明智的介入,或者在VTK中,一种在vtkGarbageCollector中实现的特殊设施就可以用来管理牵涉与上述循环中的对象。当这样的类被鉴别到的时候(这被期望发生在开发过程中),该类就会将其自身注册至垃圾回收器,并管理其自己的RegisterUnregister方法的开销。然后紧接着的对象销毁(或者反注册)方法对局部的引用计数网络进行拓扑分析,搜索已经分离了的相互引用的对象群。这些都将被垃圾回收器予以删除。

VTK中的多数实例化过程是通过一种以静态类成员实现的对象工厂运行。典型的语义表达如下:

vtkLight *a = vtkLight::New();

这里要认识到的重要之处是:这里实际被实例化的可能不是vtkLight,可能是vtkLight的子类(例如:vtkOpenGLLight)。采用对象工厂的动机多种多样,最为重要的是应用的可移植性和设备不相关性。例如,前文中我们在一个渲染场景中创建了一个光源。在一个运行于特定平台上的特定的应用程序中,vtkLight::New可能会生成一个OpenGL光源,然而在不同的平台上,存在着图形系统中其他渲染库或方法来创建光源的可能性。到底实例化什么样的派生类是一种运行时系统信息的功能。在早期的VTK中,可以有包括gl、PHIGS、Starbase、XGL、以及OpenGL等多种选择。然而这些图形库中的多数现在已经消失了,出现了包括DirectX和基于GPU方法在内的新方法。随着时间的推移,一个利用VTK写成的应用程序没必要进行修改,因为开发者已经派生出了特定的对应于新设备的vtkLight的子类和其他渲染类来支持不断发展的技术。另外一个对象工厂的重要用处是使性能增强变动的运行时替换成为可能。例如,一个vtkImageFFT可能取代一个访问特种用途硬件或数值计算库的类。

24.2.2 数据表示

VTK的一个优点就是其表示数据复杂形式的能力。这些数据形式包括从简单表格到有限元网格之类的复杂结构。所有这些数据形式都是vtkDataObject的子类,如图24.1所示(注意这是数据对象类的继承图的一部分)。

enter p_w_picpath description here

图24.1 数据对象类

vtkDataObject类的最重要的特点之一是它能被可视化管线(见下节)处理。上图展示的类中,只有一部分典型地应用于大多数实际的应用程序中。vtkDataSet及其派生类被用于科学可视化(见图24.2)。例如,vtkPolyData用于表示多边形网格;vtkUnstructuredGrid用于表示网格,而vtkImageData表示二维或者三维的像素和体素数据。

enter p_w_picpath description here

图24.2 数据集类

24.2.3 管线架构

VTK由若干主干子系统组成。与可视化包关联最紧密的子系统或许应该是数据流/管线架构了。从概念上讲,管线架构由三类基本对象组成:表示数据的对象(上文中的vtkDataObject),将数据从一种形式处理、变换、滤波或者映射成另外一种形式的对象(vtkAlgorithm),以及执行管线的对象(vtkExecutive)——此管线控制着一个由交错数据(?)和过程对象(即:管线)组成的连通图。图24.3展示了一个典型的管线。

enter p_w_picpath description here

图24.3 典型的管线

尽管概念上很简单,但真正地实现这种管线架构却是挑战性的。一个原因就是数据的表示可能会很复杂。例如,某些数据集由层次化的或分组的数据组成,那么执行这种数据就需要特殊的迭代或递归。对于复合性的事务,并行处理(不论使用内存共享还是可扩展的、分布式的方法)需要将数据划分成片段,这些片段可能需要重叠,以一致地计算比如导数等的边界信息。

算法对象也同样引入了其自身的复杂性。某些算法可能需要多个输入并且/或者产生多个不同类型的输出。某些可以对数据进行局部运算(例如:计算一个网格的中心),而另外一些则需要全局性的信息,例如计算直方图。任何情况下,算法将其输入看作是不变量,算法只是读取输入、以求得输出。这是因为数据可能是多个算法的输入,一个算法可以践踏另外一个算法的输入可不是什么好主意。

最后,执行过程的复杂程度视执行策略的特点而定。有些场合,我们可能希望将滤波器之间的处理结果暂存。这将使那些如果管线发生变化就必须进行的重新计算量最小化。另一方面,可视化数据集可能很大,这种情况下我们可能希望在计算过程不再需要这些数据的时候释放它们。最后,有一些复杂的执行策略,例如数据的多分辨率处理,它需要管线以迭代的方式运行。

为了展示这些概念中的一部分,并进一步解释管线架构,来看下面的C++示例:

vtkPExodusIIReader *reader = vtkPExodusIIReader::New(); reader->SetFileName("example.exe");  vtkContourFilter *cont = vtkContour::New(); cont->SetInputConnection(reader->GetOutputPort()); cont->SetNumberOfContours(1); cont->SetValue(0, 200);  vtkQuadricDecimation *deci = vtkQuadricDecimation::New(); deci->SetInputConnection(cont->GetOutputPort()); deci->SetTargetReduction( 0.75 );  vtkXMLPolyDataWriter *writer = vtkXMLPolyDataWriter::New(); writer->SetInputConnection(deci->GetOutputPort()); writer->SetFileName("outputFile.vtp"); writer->Write();

这个示例中,reader对象读取一个巨大的非结构网格(或网)数据文件。接下来的滤波器从该网格中生成一个等值面。vtkQuadricDecimation滤波器通过大量削减(即:减少表示等值围线的三角形的数量)来降低等值面的数据大小,该等值面是一个多边形数据集。最后大量削减后,新的减少了数据量的结果将被写回磁盘。实际的管线执行在writer调用Write方法的时候(即:需要数据的时候)发生。

正如这个示例所展示的,VTK的管线执行机制是实际要求驱使的。当一个像是writer或者mapper(一个数据渲染对象)的漏(sink)需要数据的时候,它就向其输入发出请求。如果作为输入的滤波器已经有了合适的数据,它就简单地向漏返回执行控制权。然而,若输入并没有合适的数据,它就需要进行计算。随后,它就必须先向它自己的输入请求数据。这个过程将会沿着管线继续上溯,直到有滤波器或者源拥有“合适的数据”或者到达了管线的始端,这时,滤波器就会按照正确的顺序依次执行,而数据就会沿管线流向请求需要它的地方。

这里我们将展开来讲什么是“合适的数据”。缺省情况下,VTK源或是滤波器执行后,其输出被管线缓存以避免将来不必要的执行。这样做是为了以存储为代价,使计算量和/或I/O最小化,这是可配置的行为。管线缓存的不仅是数据对象,还有关于这些数据对象生成的条件的元数据。这种元数据包括时间戳(即:计算时间),它捕捉这些数据对象何时被用于计算。因此,在最简单的情况下,“合适的数据”就是指从其开始上溯的所有管线对象变动之后被计算得出的数据。通过下面的示例展示这种特征更容易。我们在上面的VTK程序的最后加入如下代码:

vtkXMLPolyDataWriter *writer2 = vtkXMLPolyDataWriter::New(); writer2->SetInputConnection(deci->GetOutputPort()); writer2->SetFileName("outputFile2.vtp"); writer2->Write();

如前文所述,第一个writer->Write调用引发整个管线的执行。当writer2->Write被调用时,管线将缓存的时间戳与削减滤波器、围线滤波器以及reader的变动时间对比后会发现削减滤波器的输出缓存是即时的。于是,数据请求无需传播的远于writer2。现在,我们来考虑下面的变化。

cont->SetValue(0, 400);  vtkXMLPolyDataWriter *writer2 = vtkXMLPolyDataWriter::New(); writer2->SetInputConnection(deci->GetOutputPort()); writer2->SetFileName("outputFile2.vtp"); writer2->Write();

现在,管线执行对象会发现围线滤波器在围线滤波器和削减滤波器的输出最后一次被执行之后又发生了变动。因此,这两个滤波器的缓存就是过时的,它们需要重新执行。然而,鉴于reader在围线滤波器之前就发生了变动,所以它的缓存是有效的,因此reader不需要重新执行。

这里描述的场景是要求驱动的管线系统的最简单的例子。VTK管线远比此复杂。当滤波器或者漏请求数据,它可以提供附加的信息以请求特殊的数据子集。例如,一个滤波器可以通过数据的流片段进行核心外分析。我们通过修改之前的示例来展示。

vtkXMLPolyDataWriter *writer = vtkXMLPolyDataWriter::New(); writer->SetInputConnection(deci->GetOutputPort()); writer->SetNumberOfPieces(2);  writer->SetWritePiece(0); writer->SetFileName("outputFile0.vtp"); writer->Write();  writer->SetWritePiece(1); writer->SetFileName("outputFile1.vtp"); writer->Write();

这里writer请求管线的上游载入并以两个片段来处理数据,每个片段独立地流向下游。你可能会注意到之前所述的那种简单的执行逻辑在这里行不通了。根据这种逻辑,Write函数第二次被调用时,管线不应该重新执行,因为上游没有发生任何变动。于是为了着力解决这一更加复杂的情况,执行对象具有附加的逻辑以处理这里所说的片段请求。VTK管线执行事实上由多重关卡组成。数据对象的计算实际上最后一关。这一关之前是请求关。这里是漏和滤波器告诉上游它们需要从即将进行的计算中获得什么数据的地方。在上面的示例中,writer将会提醒它的输入,它需要两个片段中的第0个。这一请求实际上会沿路传播到reader。当管线执行时,reader就会知道它需要读取一个数据的子集。再者,关于缓存数据对应的是哪个片段的信息存储于对象的元数据中。下次滤波器向其输入请求数据时,这个元数据就会被与当前请求作比较。于是该示例中的管线就会重新执行,以处理一个不同的片段请求。

滤波器可以发出若干更多类型的请求。这些请求包括特定时间戳的请求、特殊结构化范围的请求、或者幽灵层数量的请求(即:用于计算邻域信息的边界层)。此外,在请求通过的过程中,每一个滤波器都允许修改来自下游的请求。例如,一个无法通行流的滤波器(例如:流水线滤波器)可以忽略片段请求并要求整个数据。


本文链接:http://www.ituring.com.cn/article/6695