Avizo用户使用手册-15
[TOC]
Chapter 15
15 Avizo XPand Extension
本文档描述如何为Avizo可视化系统开发自定义扩展。这类扩展可以包括文件读写例程、新的可视化模块、数据对象以及其他组件。为了能够开发自定义扩展,你将需要Avizo的开发者扩展,简称Avizo XPand Extension。此外,你还需要一个C++开发环境,例如Windows上的Microsoft Visual Studio(详情参见第15.1.2小节)。
要理解本文档,你需要具备一些C++编程的基础知识。此外,你应当已经熟悉标准的Avizo系统。如果你还不了解Avizo,我们建议你先完成Avizo用户指南中的各个教程。
Avizo基于若干外部库,例如Open Inventor、OpenGL、Qt和Tcl。本文档不提供这些库的文档。它只在理解示例所需之处给出一些基本提示。一般来说,这足以让你编写自己的标准I/O例程和Avizo模块。至于细节,我们请你参阅这些外部库的原始文档。
- 简介,包括快速入门指南和升级信息
- 开发向导,一个用于简化开发任务的工具
- 文件IO,描述如何编写读写例程
- 编写模块,涵盖计算模块与显示模块
- 数据类,如何使用它们以及如何从它们派生
- 为模块编写文档并集成到用户指南中
- 文档标记语言,可在在线文档中获取,描述在为Avizo资源编写文档时可以使用的所有命令
- 杂项,资源文件摘要、保存Avizo项目等等。
15.1 Avizo XPand Extension 简介
Avizo XPand Extension允许你向Avizo添加新的组件,例如文件读写例程、用于可视化数据的模块或用于处理数据的模块。新的模块类和新的数据类可以被定义为已有类的子类。
请注意,更改或修改已有的模块或Avizo图形用户界面的某些部分是不可能的(或者只能在非常有限的范围内做到)。
在以下各节中,我们
- 给出Avizo XPand Extension的概览,
- 讨论不同平台的系统要求,
- 概述Avizo文件树的结构,
- 在一个快速入门教程中展示如何编译示例包,
- 提供关于编译与调试的额外提示,
- 并提及如何升级和维护已有代码。
注意:一个Avizo项目实际上就是一组互相连接的模块和数据对象。在Avizo XPand Extension编程接口中,Avizo项目常被称为network(网络)或module network(模块网络),或者object pool(对象池)。pool也指Avizo的Project View,更具体地说是Project Graph View。
15.1.1 Avizo XPand Extension 概览
Avizo XPand Extension是对普通Avizo版本的一个扩展。除了普通版本中所包含的文件之外,开发者版本本质上还提供了编译自定义扩展所需的所有C++头文件。
15.1.1.1 包与共享对象
Avizo是一个面向对象的软件系统。除了图形用户界面或3D查看器之类的核心组件之外,它还包含大量的数据对象、模块、读取器和写入器。数据对象与模块是C++类,读取器与写入器是C++函数。
这些组件并不是被编译进单一的静态可执行文件,而是被组织成一组互相关联的模块,并被归入包(packages)中。一个包就是一个共享对象(在Unix上通常称为.so或.sl,在Windows上称为.dll),它可以在需要时被Avizo在运行时动态加载。这个概念有两个优点。一方面,程序本身保持很小,因为只有用户实际需要的那些包才会被加载。另一方面,它提供了几乎无限的可扩展性,因为新的包可以在任何时候被添加,而无需重新编译主程序。
因此,为了向Avizo开发者版本添加自定义组件,必须创建并编译新的包或共享对象。一个包可以包含任意数量的模块,而开发者可以自行决定是把他的模块组织到若干个包中,还是只放在一个包中。
15.1.1.2 包资源文件
每个包都会随附存储一个资源文件(resource file)。该文件包含关于在某个特定包中所定义组件的信息。当Avizo启动时,它首先扫描所有可用包的资源文件,从而知晓在运行时可能被用到的所有组件。
标准Avizo包的资源文件位于Avizo安装目录下的share/resources中。关于注册读写例程或不同种类模块的细节,在第15.3节和第15.4节中给出。
15.1.1.3 本地Avizo目录
通常Avizo会由系统管理员安装在一个普通用户不允许创建或修改文件的位置。因此,建议每个用户在自己的个人本地Avizo目录(local Avizo directory)中创建新的包。本地Avizo目录与Avizo安装目录的结构本质上相同。新的本地Avizo目录最容易通过使用开发向导(Development Wizard)来创建——那是一个专用的对话框,在第15.2节中详细描述。
一旦本地Avizo目录被设置好,位于其中的资源文件也会在Avizo启动时被扫描。这样,新的组件就可以被添加,或者已有的组件可以被重新定义。
15.1.1.4 外部库
Avizo基于若干业界标准库。其中最重要的是Open Inventor、OpenGL、Qt和Tcl。
Avizo的3D图形基于OpenGL和Open Inventor。OpenGL是专业3D图形的业界标准。Open Inventor是一个使用OpenGL的C++库,它提供了一个面向对象的场景描述层。编写Avizo的可视化模块,本质上意味着从输入数据创建一个Open Inventor场景。如果你已有做这件事的代码,把它变成一个Avizo模块会很直接。Open Inventor的头文件已包含在Avizo XPand Extension中,OpenGL则必须已安装在你的系统上。
Qt是一个用于构建图形用户界面(GUI)的跨平台C++库。Avizo就是用Qt构建的。不过,标准Avizo模块中所使用的用户界面元素被封装在称为端口(ports)的特殊Avizo类中。因此,你可以在不了解Qt的情况下开发自己的模块。只有当你打算完全开发新的用户界面组件(例如专用对话框)时,才需要Qt。另请注意,在这种情况下,Qt的头文件和库以LGPL许可包含在Avizo XPand Extension中。Avizo 2019.1在所有受支持的平台上都链接到Qt 5.6。
最后,Tcl是一个C库,它提供了一种可扩展的脚本语言,被Avizo所使用。所有必需的头文件都包含在Avizo XPand Extension中。Avizo程序员通常不需要了解Tcl API的细节,而只需从已有的示例中派生自己的代码。
15.1.2 系统要求
为了按本文档所述开发新的Avizo组件,你需要Avizo的开发者扩展(称为Avizo XPand Extension)以及Avizo的调试文件。两者都可以用Avizo安装程序安装。
图 15.1:Avizo安装程序中的先决条件。
你还需要适当的C++开发环境。C++编译器一般不兼容,因此应当使用System Requirements一节中所列的编译器与编译器版本。其他编译器版本可能也能工作,但这一点无法保证。特别是,除Linux之外不可能使用GNU gcc编译器。
在所有Unix平台上,为了使用Avizo XPand Extension所提供的GNUmakefile,需要GNU make工具(gmake)。为此,你应当在已经列于你的path中的某个目录(例如/usr/bin)中创建一个链接。
在Mac OS X平台上,为了使用Avizo XPand Extension所提供的GNUmakefile,基本上需要GNU make工具(gmake)。你也可以使用基于GNU gcc编译器的其他开发工具(例如Xcode)来修改和构建你的代码。
诸如已安装内存或特殊图形适配器之类更一般的硬件要求,列在Avizo 用户指南中。在所有系统上,必须安装OpenGL库以及OpenGL头文件。
15.1.3 Avizo文件树的结构
与普通版本一样,Avizo的开发者版本(带Avizo XPand Extension)安装在一个称为AvizoRoot Directory的单一目录中。该目录包含运行Avizo所需的所有二进制文件、共享对象和资源文件,以及编译新组件所需的所有C++头文件。请注意,为了在调试模式下编译新组件,必须在Avizo安装期间启用调试二进制文件的安装。新组件本身独立地存储在AvizoLocal Directory中。每个用户都可以定义他/她自己的本地Avizo目录。本地Avizo目录的结构与AvizoRoot Directory非常相似。这两个目录的结构在以下两小节中更详细地描述。
15.1.3.1 Avizo根目录
Avizo根目录的内容在不同平台上可能略有差异。例如在Windows上没有子目录lib。取而代之的是,编译好的共享对象位于bin/arch-*-Optimize下。Open Inventor C++类的在线文档目录(share/devref/oiv)。Avizo C++类的在线文档目录(share/devrefAvizo/)在Windows上不存在。取而代之的是提供了一个压缩归档文件Avizo.chm,可以通过快捷方式访问。一个典型的Avizo安装目录如下所示:
1 | AvizoRoot/ ................................. Avizo安装目录 |
15.1.3.2 本地Avizo目录
本地Avizo目录包含自定义模块的源代码与目标文件、自定义包的资源文件,以及编译好的自定义包本身。这些包既可以以调试版本编译,也可以以优化版本编译。相应的目标文件和编译好的共享对象分别位于名为arch-*-Debug和arch-*-Optimize的不同子目录中。这里星号表示具体的架构,例如Windows系统上的Win64VC12,或者Linux平台上的Linux、LinuxAMD64。
应当使用开发向导来创建一个新的本地Avizo目录。详情请参阅第15.2节。像AvizoLocal/lib或AvizoLocal/share/resources这样的子目录,会在某个自定义包首次被编译时自动创建。同样,本地Avizo目录的内容在不同平台上可能略有差异。例如在Windows上,编译好的共享对象位于bin而不是lib下。
1 | AvizoLocal/ |
15.1.4 快速入门教程
本节包含一个简短教程,说明如何编译并执行Avizo XPand Extension所提供的示例包。该示例包包含本手册其他地方所描述的示例模块和IO例程。在这一点上,你只需对开发自己的模块和IO例程所需的基本流程有一个大致了解。细节将在后续各节中讨论。
为了开发自定义Avizo包,需要一个专用目录,即本地Avizo目录。最初,应当使用开发向导来创建这个目录。我们来看看这是如何完成的:
- 启动Avizo,并从Avizo主窗口的XPand菜单中选择Development Wizard。
- 确保向导对话框窗口中的Set local Avizo directory项被选中。
- 按下Next按钮。
现在你可以为本地Avizo目录输入一个目录名。例如,你可以在你的主目录中选择AvizoLocal。该目录必须不同于Avizo所安装的目录。
- 输入本地Avizo目录的名称。
- 选择copy example package按钮。
- 按下OK按钮。
如果该目录尚不存在,Avizo会询问你是否应当创建它。该目录的名称会被存储在Windows注册表中,或者存储在Unix或MacOS主目录下的.AvizoRegistry文件中,这样下次Avizo启动时,在该目录中定义的所有模块或IO例程就都可用了。
下一步是为Windows创建Visual Studio项目文件,或为Unix和MacOS创建GNUmakefile。这些文件将从一个Package文件生成——每个自定义包目录中都必须存在该文件。Package文件的语法在第15.2.10节中描述。示例包已经包含一个Package文件,因此这里不需要再创建。
- 在开发向导的主页上选择Create build system。
- 按下Next按钮。
- 选择all local packages作为目标。
- 选择你想创建哪种构建系统。
- 按下OK按钮。
所选构建系统的文件现在将被自动创建。自动生成的好处是,include路径与库路径总是被正确设定。此外,本地包之间的任何依赖关系也会被考虑在内。
一旦构建系统被创建,你就可以关闭开发向导并退出Avizo。现在我们准备编译示例包。不过提醒一下:如果你在Avizo安装期间没有安装”debug binaries”,你将无法在调试模式下编译。各平台的编译流程不同:
Windows Visual Studio
- 启动Visual Studio,并从本地Avizo目录加载解决方案文件
AvizoLocal.vc120.sln。如果你的本地Avizo目录不叫AvizoLocal,那么解决方案文件也会有其他名称。 - 通过从Build菜单中选择Build Solution,以调试模式构建所有本地包。
Unix GNUmakefile系统
- 在一个shell中切换到本地Avizo目录。
- 键入
gmake以调试模式构建所有本地包。如果gmake尚未安装在你的系统上,你可以在Avizo根目录的子目录bin中找到它。要么把这个目录加入你的path变量,要么在已经列于你path中的某个目录(例如/usr/bin)中创建一个链接。
Mac GNUmakefile系统
- 打开一个
Terminal窗口,并切换到你之前创建的本地Avizo目录。 - 键入
gmake以构建所有本地包。
重要说明:如果编译器显示了类似ld: framework not found CUDA的错误消息,请参阅Mac OSX的系统要求。
我们现在准备启动Avizo以测试示例包。不过,由于我们是以调试模式编译示例包的,我们也需要以调试模式启动Avizo。否则,Avizo将找不到正确的DLL或共享库。对于Linux,请用命令行选项-debug启动Avizo。在Windows上,请使用开始菜单中的Avizo (debug)快捷方式。在Mac系统上,你可以立即启动该应用程序,新路径会被自动识别。
为了检查示例包是否已被成功编译并且能被Avizo加载,你可以例如从Avizo主窗口的Project > Create Object...菜单中选择Other / DynamicColormap项。随后应当会创建出一个新的colormap对象。你可以在本地Avizo目录下的src/mypackage/MyDynamicColormap.cpp中找到这个新对象的源代码。在同一目录中,还有该类的头文件。
如果你想以release模式编译示例包,你必须在Visual Studio中更改活动配置并重新编译代码。在Unix上,你必须调用gmake MAKE_CFG=Optimize。你也可以把MAKE_CFG定义为一个环境变量。
15.1.5 编译与调试
本节提供快速入门指南未涵盖的、关于如何编译和调试自定义Avizo包的额外信息。你第一次阅读本手册时可以跳过它。这些信息只有在你实际开发自己的代码时才会变得相关。
前面已经提到,自定义Avizo包的开发应当在一个本地Avizo目录中进行。最初,应当使用第15.2节所描述的开发向导来创建这样的目录。本地Avizo目录的名称被存储在Windows注册表中,或者存储在你Unix主目录下的.AvizoRegistry文件中。在Windows和Unix上,本地Avizo目录的名称都可以通过定义环境变量AVIZO_LOCAL来覆盖。如果你想在不同的本地Avizo目录之间切换,这可能有用。不过一般来说,建议不要设置这个变量。
对于每一个本地包,都有一个资源文件存储在本地Avizo目录的子目录share/resources中。该文件包含关于该包所提供的所有模块和IO例程的信息。一个包既可以以适合调试的调试模式编译,也可以以打开编译器优化的release模式编译。在前一种情况下,DLL或共享库存储在Windows上的bin/arch-*-Debug下,以及Unix上的lib/arch-*-Debug下——前提是你在Avizo安装期间安装了”debug binaries”。在后一种情况下,它们存储在bin/arch-*-Optimize或lib/arch-*-Optimize下。这里的’*’表示实际的架构名称。下面将描述如何在不同平台上以两种模式编译本地包,以及如何使用调试器调试代码。
15.1.5.1 Windows Visual Studio
注意:混用由不同版本的Visual Studio(Visual Studio 2008、2010、2012等)所生成的代码,并未获得官方支持。因此,一般来说你应当使用与Avizo编译时相同的Visual Studio版本(见系统要求)。不过,如果你在任何使用你自定义模块的Windows PC上,把相应的Visual Studio运行时与Avizo一起安装,那么用另一个版本的Visual Studio编译你自己的模块可能也能工作。请注意这不是推荐的用法。
Visual Studio的工作区和项目文件由开发向导从Avizo Package文件自动生成。应当不需要手动更改项目设置。
默认情况下,Visual Studio会以调试模式编译。为了生成优化后的代码,你需要更改活动配置。这可以通过从Build菜单中选择Configuration Manager…来完成。在Active Solution Configuration下拉菜单中选择Release。
为了执行你本地包的调试模式版本,你必须用位于bin/arch-*-Debug文件夹中的调试版Avizo.exe启动Avizo。为方便起见,开始菜单中提供了一个链接Avizo (Debug)。不过,如果你想调试你的代码,你需要从Visual Studio启动Avizo。因此,你需要在项目属性对话框中指定正确的可执行文件。
你可以通过从Project菜单中选择Properties来打开项目属性对话框。从左侧窗格中选择Debugging。在Command字段中,选择位于Avizo根目录中的文件bin/arch-<version>-Debug/Avizo.exe(见图15.2)。请把<version>替换为你所拥有的Avizo版本(见系统要求)。
图 15.2:在Visual Studio中指定可执行文件的名称。
现在你可以通过按F5,或者从Debug菜单中选择Start,从Visual Studio启动Avizo。为了调试你的代码,你可以在代码中的任意位置设置断点。例如,如果你想调试一个读取例程,请在该例程的开头设置一个断点,执行Avizo,然后通过加载某个文件来调用该读取例程。
15.1.5.2 Linux
为了在Linux下编译一个本地包,你需要切换到该包目录并在一个shell中执行gmake。gmake工具位于Avizo根目录的bin子目录中。要么把这个目录加入你的path,要么在已经列于你path中的某个目录(例如/usr/bin)中创建一个链接。
所需的GNUmakefile将由开发向导从Avizo Package文件自动生成。应当不需要手动编辑GNUmakefile。取决于Package文件的内容,某个包目录中的所有源文件都会被编译,或者只编译其中一个子集。默认情况下,所有文件都会被编译。开发向导会把Avizo根目录的名称放入文件GNUmakefile.setroot。你可以通过定义环境变量AVIZO_ROOT来覆盖它。例如,当同时使用两个不同的Avizo版本时,这可能有用。
默认情况下,gmake会编译调试代码。为了编译优化后的代码,请调用gmake MAKE_CFG=Optimize。或者,你也可以把环境变量MAKE_CFG设为Optimize。
如果你有一台多处理器机器,你可以通过用选项-j<n>调用gmake来一次编译多个文件。这里<n>表示要并行运行的编译任务数量。通常处理器数量的两倍是一个不错的选择。
如果你编译的是调试代码,你必须用命令行选项-debug调用Avizo。否则会使用优化后的版本。如果不存在这样的版本,则会出错。除了在命令行指定-debug之外,你也可以把环境变量MAKE_CFG设为Debug。
为了在调试器中运行Avizo,请用-gdb或-ddd命令行选项调用Avizo启动脚本。
请注意,你通常无法在刚启动调试器之后就在一个本地包中设置断点。这是因为,在该包中所定义的第一个模块或读写例程被调用之前,该包的共享对象文件不会被链接到Avizo中。为了在不调用任何模块或读写例程的情况下加载共享对象,你可以使用命令dso open lib<name>.so,其中<name>表示本地包的名称。一旦共享对象被成功加载,就可以设置断点了。这些断点在下次程序启动时是否仍然有效,取决于调试器。
15.1.6 维护已有代码
本节面向已经使用先前版本的Avizo XPand Extension开发过自定义模块的程序员。具体来说,我们描述
- 如何升级到Avizo XPand Extension 2019,以及
- 如何重命名一个已有的包。
15.1.6.1 升级到最新版本的Avizo XPand Extension
Avizo是一个不断演进的交互式软件产品。Avizo XPand Extension API可能会发生变化。虽然我们尽力保持兼容性,但仍可能引入一些不兼容的更改,需要你调整已有的代码,例如与模块和用户界面相关的代码。
XPand Reference Manual包含所有你可以安全使用的类和方法,因为版本之间的源代码兼容性会得到保证。我们尽力在版本之间保持兼容性;不过,为了改进我们的API,有时不得不打破兼容性。如果你使用内部类或方法,我们不保证任何兼容性;一旦使用,你在编译步骤会得到一个兼容性警告。Avizo Programmer’s Reference文档($AVIZO_ROOT/share/devrefAvizo/Avizo.chm)中有一个Porting Guide标签页,指出了Avizo 2019.1中的兼容性断裂。
此外,请注意由Avizo XPand Extension所提供的Open Inventor和Qt的头文件与库。这些API本身沿着各自的路径演进,可能引入特定的不兼容性。更多细节请参阅它们各自的发布说明。
15.1.6.2 重命名一个已有的包
有时你可能想重命名一个已有的Avizo包,例如当把一个已有包用作新自定义包的模板时。为此,需要做以下更改:
- 重命名包目录:
AvizoLocal/src/oldname变为AvizoLocal/src/newname - 重命名包目录中的以下文件:
oldnameAPI.h变为newnameAPI.hshare/resources/oldname.rc变为share/resources/newname.rc - 在包资源文件
share/resources/newname.rc以及Package文件中,把oldname替换为newname。 - 在文件
newnameAPI.h中,把OLDNAME_API替换为NEWNAME_API。 - 在该包的所有头文件和源文件中,如有必要请调整include指令,也就是说,不再写
#include <oldname/SomeClass.h>
而改写为#include <newname/SomeClass.h>
所有替换都可以用任意文本编辑器完成。在所有文件都按需修改之后,应当使用Avizo开发向导创建一个新的Visual Studio项目文件或新的GNUmakefile。
15.2 开发向导
开发向导是一个特殊工具,它帮助你搭建一个本地Avizo目录树,以便你能为Avizo编写自定义扩展。此外,开发向导还可用于创建Avizo模块或读写例程的模板。开发这类组件的细节在其他各节中讨论。在这一点上,我们想对开发向导的功能给出一个简短概览。
具体来说,我们讨论
- 如何调用开发向导
- 如何设定或创建本地Avizo目录
- 如何向本地Avizo目录添加一个包
- 如何向一个已有的包添加组件
- 如何创建构建系统的文件
最后,还提供了一节描述Package文件语法。
15.2.1 启动开发向导
为了调用开发向导,请先启动Avizo。然后从主窗口的XPand菜单中选择Development Wizard。请注意,只有在你运行Avizo的开发者版本(已安装Avizo XPand Extension)时,该菜单项才可用。
开发向导的布局如图15.3所示。最初,该向导会告知你当前正在使用的本地Avizo目录。如果没有定义本地Avizo目录,也会有相应提示。此外,该向导让你在四种可执行的不同任务之间选择。它们是
- 设定本地Avizo目录(或创建一个新的)
- 向本地Avizo目录添加一个新包
- 向一个已有的包添加一个组件
- 创建构建系统的文件
第一个选项总是可用。只有在已经指定了一个有效的本地Avizo目录时,才能添加新包。要使本地Avizo目录有效,除其他条件外,它必须包含一个名为src的子目录。如果本地Avizo目录的src目录中至少存在一个包,那么就可以向某个包添加一个新组件,即一个模块或一个读写例程。最后,最后一个选项允许你创建构建系统所需的所有文件,即Visual Studio项目文件或Unix平台的GNUmakefile。
15.2.2 设置本地Avizo目录
本地Avizo目录包含你所有自定义扩展的源代码和二进制文件。你可以使用开发向导轻松指定该目录的名称(见图15.4)。由于所有用户都可以为Avizo编写自己的扩展,建议你在自己的主目录中创建本地Avizo目录。
如果所指定的目录不存在,开发向导会询问你是否应当创建它。如果你确认,该目录及其子目录就会被创建。你也可以在文本字段中指定一个已存在的空目录。那么这些子目录将在该位置被创建。
最后,你也可以选择先前由开发向导所创建的已有目录。在这种情况下,一个简单的检查会验证所指定的目录。
图 15.3:Avizo开发向导的初始布局。
图 15.4:设置本地Avizo目录。
要取消设定本地Avizo目录,请清空文本字段并单击OK。该目录不会被删除,但下次Avizo启动时,在本地Avizo目录中定义的模块和IO例程将不再可用。
一旦你设置好本地Avizo目录,该目录的名称就会被永久存储。这意味着下次Avizo启动时,位于本地Avizo目录share/resources子目录中的.rc文件就能被读取。这样,自定义组件就为Avizo所知了。在Windows上,本地Avizo目录的名称存储在Windows注册表中。在Unix系统上,它存储在你主目录下的.AvizoRegistry文件中。在两种情况下,这些设置都可以通过定义环境变量AVIZO_LOCAL来覆盖。
开发向导提供了一个开关,用于把示例包复制到本地Avizo目录。如果该按钮被激活,而所指定的已有本地Avizo目录中已经包含示例包,你会得到一个警告。示例包被复制到本地Avizo目录的子目录src/mypackage中。它包含本指南中作为示例给出的所有读写例程以及各个模块。
你可以选择开关Copy GPU example package以提供另一个示例包。要构建这个包,请先安装CUDA工具包。该示例被复制到本地Avizo目录的子目录src/gaussianfiltercudac中。
15.2.3 添加一个新包
所有Avizo组件都被组织到包中。每个包会被编译成一个单独的共享对象(在Windows上是DLL文件)。因此,在任何组件能被定义之前,必须先在本地Avizo目录中创建至少一个包。为此,请在向导的第一页选择add package to local Avizo directory并按Next。在下一页你可以输入新包的名称(见图15.5)。
图 15.5:向本地Avizo目录添加一个新包。
包的名称不得包含任何空白字符或标点符号。当一个包被添加时,本地Avizo目录的src下会创建一个同名的子目录。在这个目录中,存储着该包所有模块和IO例程的源代码与头文件。此外,在每个包目录中必须有一个Package文件,构建系统文件可以由它生成。
最初,一个默认的Package文件会被复制到新的包目录中。这个默认文件会添加最常用的Avizo库以供链接。它还会选中包目录中所有的C++源文件以供编译。关于如何从Package文件生成构建系统,请参阅第15.2.9小节。
除了Package文件之外,文件version.cpp也会被复制到新的包目录中。该文件允许你把版本信息放入你的包,之后可以在Avizo系统信息对话框中查看。最后,一个空的package.rc文件也会被复制到share/resources中。之后的模块和IO例程将在这个文件中注册。
15.2.4 添加一个新组件
如果你在开发向导的第一页选择add component选项,系统会询问你要向哪个包添加哪种组件。请记住,只有在找到一个至少包含一个已有包的有效本地Avizo目录时,add component选项才可用。具体来说,可以创建以下模板
- 普通模块(ordinary module),
- 计算模块(compute module),
- 读取例程(read routine),
- 或写入例程(write routine)
(见图15.6)。对话框下部的选项菜单让你指定该组件应被添加到哪个包。在你按下Next按钮之后,系统会要求你输入关于你想添加的那个特定组件的更具体信息。到这一步为止,还没有执行任何实际操作,也就是说没有文件被创建或修改。
图 15.6:向一个已有的包添加新组件。
15.2.5 添加一个普通模块
Avizo中的普通模块直接可视化它所附加的数据对象。示例包括Isosurface模块、Volume Render模块和Surface View模块。这类模块有时也称为显示模块(display modules),在Project View中由黄色图标表示。
要使用开发向导创建一个普通模块的模板,你必须输入该模块的C++类名、要在可能输入数据对象的弹出菜单中显示的名称、可能输入数据对象的C++类名,以及定义该输入类的包(见图15.7)。
图 15.7:创建自定义模块的模板。
一旦你单击OK,包目录中就会创建两个文件:新模块的头文件和源代码文件。此外,一条新的module语句会被添加到位于包目录share/resources下的包资源文件中。在你向某个包添加了一个新模块之后,你需要重新创建构建系统文件,然后才能编译该模块。详情在第15.2.9小节中描述。
15.2.6 添加一个计算模块
Avizo中的计算模块通常接受一个或多个输入数据对象,执行某种计算,并把一个结果数据对象放回Project View。计算模块在Project View中由红色图标表示。
普通模块与计算模块之间唯一的区别是:前者直接派生自HxModule,而后者派生自HxCompModule。使用开发向导创建计算模块的模板时,需要填写的输入字段与普通模块相同。这些输入字段的含义在第15.2.5小节中描述。
15.2.7 添加一个读取例程
正如第15.3.2小节将更详细解释的,读取例程是全局C++函数,用于从以某种文件格式存储的文件内容创建一个或多个Avizo数据对象。要创建一个新读取例程的模板,首先必须指定该例程的名称(见图15.8)。该名称必须是一个有效的C++名称。它不得包含空白或任何其他特殊字符。
此外,还必须指定文件格式的名称和首选的文件名扩展名。文件浏览器将使用该扩展名来识别文件格式。格式名称将显示在任何匹配文件的旁边。
最后,可以设置一个开关,以创建一个支持多文件输入的读取例程模板。这样的例程签名会略有不同。它允许你从多个输入文件创建单个数据对象。例如,多个2D图像文件可以被合并成单个3D图像体。详情在第15.3.2.3小节中提供。
图 15.8:创建读取例程的模板。
在你按下OK之后,包目录中会创建一个新文件<name>.cpp,其中<name>表示该读取例程的名称。此外,该读取例程会被注册到包资源文件中。有些文件格式可以通过唯一的文件头来识别,而不仅仅依靠文件名扩展名。在这种情况下,你可能想按第15.3.2.1小节所述修改资源文件条目。
请记住,在你向某个包添加了一个新的读取例程之后,你需要重新创建构建系统文件,然后才能编译它。详情在第15.2.9小节中描述。
15.2.8 添加一个写入例程
写入例程是一个全局C++函数,它接受指向某个数据对象的指针,并把该数据以某种文件格式写入文件。细节在第15.3.3小节中解释。为了创建写入例程的模板,首先必须指定该例程的名称(见图15.9)。该名称必须是一个有效的C++名称。它不得包含空白或任何其他特殊字符。
此外,还必须指定文件格式的名称和首选的文件名扩展名。在保存数据对象之前,名称和扩展名都会显示在Avizo文件浏览器的文件格式菜单中。
最后,必须选择要保存的数据对象的C++类名,以及定义该类的包。一些重要的数据对象,例如HxUniformScalarField3或HxSurface,已经列在相应的组合框中。不过,也可以在这里指定任何其他类,包括自定义类。你甚至可以使用一个接口类(例如HxLattice3)的名称来代替数据类的名称(见第15.5.2.1小节)。
在你按下OK之后,包目录中会创建一个新文件<name>.cpp,其中<name>表示该写入例程的名称。此外,该写入例程会被注册到包资源文件中。
图 15.9:创建写入例程的模板。
请记住,在你向某个包添加了一个新的写入例程之后,你需要重新创建构建系统文件,然后才能编译它。详情在第15.2.9小节中描述。
15.2.9 创建构建系统文件
在你能够实际编译自己的包之前,你需要为Windows上的Microsoft Visual Studio创建项目文件,或为Unix创建GNUmakefile。这些文件包含诸如要编译的源代码文件、或正确的include与库路径之类的信息。由于手动设置和编辑这些文件并不容易,Avizo提供了一种自动创建它们的机制。为此,每个包中必须存在一个所谓的Package文件。Package文件包含该包的名称和一个依赖包列表。它还可以包含额外的标签以定制构建过程。package文件的语法在第15.2.10小节中描述。不过通常不需要修改由开发向导所创建的默认Package文件。
虽然构建系统文件的自动生成是一个非常有用的特性,但这也意味着你不应当手动修改所生成的项目文件或GNUmakefile,因为它们很容易被Avizo覆盖。
如果你在开发向导的主页上选择Create build system,然后按下Next按钮,图15.10中所示的控件就会被激活。你可以选择是要为所有本地包创建构建系统文件,还是只为某个特定的包创建。
15.2.10 Package文件语法
Package文件包含关于某个本地包的信息。由这个文件可以生成Visual Studio项目文件或GNUmakefile。Package文件是一个Tcl文件。它定义一组Tcl变量,指示诸如包名、依赖库或构建该包时要复制的额外文件之类的事项。由开发向导所创建的默认Package文件如下所示:
图 15.10:创建构建系统文件。
1 | set PACKAGE {mypackage} |
在大多数情况下,默认文件都能很好地工作,无需修改。不过,为了完成特殊任务,这些变量的默认值可以被更改,或者可以定义额外的变量。下面是描述各个变量含义的详细列表:
PACKAGE
变量PACKAGE指示该包的名称。这应当与包目录的名称相同。包名不得包含除字母或数字之外的任何字符。
LIBS
列出该包所依赖的所有库。默认情况下,这里插入的是最常用的Avizo包。你可以按需修改这个列表。例如,如果你想链接到一个在Windows上称为foo.lib、在Unix上称为libfoo.so的库,你应当把foo添加到LIBS中。
除了真实的库名之外,你还可以在LIBS变量中使用以下别名:
oiv- 表示Open Inventor库tcl- 表示Tcl库opengl- 表示OpenGL库qt- 表示Qt库
如果你只想在某个特定平台上链接某个库,你可以设置一个专用变量LIBS-arch,其中arch表示该平台。你还可以进一步区分代码的调试版本与release版本。下面是一个例子:
1 | set LIBS {mclib amiramesh schedule hxz qt oiv opengl tcl} |
SHARE
列出所有应当从包目录复制到本地Avizo目录的文件。默认情况下只复制包资源文件。不过,如有必要,你可以在这里添加额外的文件。你可以使用通配符来代替显式的文件名。这些通配符会用标准Tcl命令glob来解析。例如,如果你的包中有一些演示脚本,你可以按以下方式复制它们:
1 | set SHARE { |
与LIBS变量一样,你可以在这里追加一个arch字符串,即SHARE-arch。这样这些文件就只会在所指定的平台上被复制。
INCLUDES
这个变量可以包含一个额外include路径的列表。编译器使用这些路径来定位头文件。默认情况下,include路径被设为$AVIZO_ROOT/include、$AVIZO_ROOT/include/oiv、$AVIZO_LOCAL/src以及本地包目录。
COPY
这可以包含一个文件列表,这些文件从本地包目录以外的位置复制过来。你需要指定目标文件的名称,后跟相对于本地Avizo目录的目的文件名称。例如,你可能想把某些数据文件从某个归档中复制到Avizo目录里。这可以按以下方式实现。
1 | set COPY { |
与LIBS变量一样,你可以在这里追加一个arch字符串,即COPY-arch。这样这些文件就只会在所指定的平台上被复制。一个常见的应用是把某个特定平台上所需的外部库复制到Avizo目录中。
SRC
这个变量指定要为该包编译的源代码文件。该变量的默认值是
1 | set SRC {*.cpp *.c} |
这意味着默认情况下,本地包目录中所有的.cpp和.c文件都会被编译。有时你可能想用一个显式的源文件列表来替换这个默认值。
同样,你可以为SRC变量追加一个arch字符串,这样某些文件就只会在某个特定平台上被编译。
INCSRC
这个变量指定要包含到Visual Studio包项目文件中的头文件。该变量的默认值是
1 | set INCSRC {*.h *.hpp} |
这意味着默认情况下,本地包目录中所有的.h和.hpp文件都会被考虑在内。同样,你可以为INCSRC变量追加一个arch字符串,这样某些头文件就只会在某个特定平台上被考虑。
15.3 文件I/O
本节描述如何向Avizo添加用户自定义的读写例程。自定义读写例程的目的,是为Avizo中尚不可用的文件格式添加支持。
首先给出一些关于文件格式的一般提示。然后我们讨论Avizo中读取例程应当是什么样子。随后处理写入例程。最后讨论AmiraMesh API。使用这个API,为新的自定义数据对象实现文件I/O会相当容易。
15.3.1 关于文件格式
在深入细节之前,让我们先澄清一些一般概念。在Avizo中,所有加载到系统中的数据都被封装在C++数据类中。第15.5节提供了关于标准数据类的更多信息。例如,有一个类表示四面体网格(HxTetraGrid),另一个类表示定义在四面体网格上的标量场(HxTetraScalarField3),还有一个类表示3D图像数据(HxUniformScalarField3)。数据类的每一个实例在Avizo Project View中都由一个绿色图标表示。
数据在磁盘文件中被存储的方式称为文件格式。虽然数据类与文件格式之间存在关联,但它们是两个不同的东西。特别重要的是要理解,它们之间不存在一一对应关系。
通常,某个特定的数据类(例如3D图像数据)可以被存储为许多不同的文件格式(3D TIFF、DICOM、一组2D JPEG文件等等)。另一方面,某个特定的文件格式并不必然只对应恰好一个数据类。例如,一个简单的Fluent UNS格式数据文件可以包含六面体网格(HxHexaGrid)或四面体网格(HxTetraGrid)。一个同时包含四面体、六面体、金字塔和楔形体的更复杂的Fluent UNS案例可以在Avizo XWind Extension中被读入,并且该网格将被存储为一个非结构化模型(HxUnstructuredModel)。
请注意,数据类的实例(Avizo中的一个绿色图标)与文件格式的实例(实际的文件)之间同样不存在一一对应关系。常常是多个文件对应于单个数据对象,例如构成单个3D图像体的多张2D图像。另一方面,单个文件可以包含多个数据对象的数据。例如,一个AVS UCD文件可以包含一个四面体网格以及定义在它上面的多个标量场。
最后请注意,当把一个数据对象保存为某种特定格式的文件时,信息可能会丢失。例如,当把一个3D图像体保存为一组2D JPEG图像时,边界框信息会丢失。同样,Avizo中还有一些用户自定义的参数或属性,无法被编码进大多数标准文件格式。另一方面,文件读取器往往也不会解释某个特定文件格式所提供的全部信息。
15.3.2 读取例程
正如上一节已经提到的,读取例程是一个C++函数,它读取一个磁盘文件、解释数据、创建某个Avizo数据类的一个实例,并用从文件中读取的数据填充该实例。
为了编写一个读取例程,显然需要两样东西:文件格式的规范,以及关于用Avizo的哪一个数据类来表示这些数据、以及如何使用该类的信息。关于标准Avizo数据类的更多信息在第15.5节中给出。这些类的C++接口在在线参考文档中描述。
读取例程既可以是某个类的静态成员函数,也可以是一个全局函数。除了函数本身之外,还需要在包资源文件中有一个条目。这样Avizo才能知晓该读取例程的存在,以及该读取器能够处理的文件类型。
在以下讨论中,用户自定义读取例程的实现将通过两个具体示例来说明,即一个简单的标量场读取例程和一个曲面与标量场读取例程。随后将讨论关于读取例程的更多细节。
15.3.2.1 标量场读取器
在本节中,我们给出一个简单的读取例程,它被设计用于读取图像体,即3D标量场,其文件格式是为这个示例专门发明的一种非常简单的格式。该文件格式称为PPM3D(因为它类似于ppm 2D图像格式)。PPM3D格式将是一种ASCII文件格式,包含一个文件头、三个指定3D图像体尺寸的整数,以及作为0到255范围内整数的像素数据。一个示例文件可能看起来像这样:
1 | # PPM3D |
该读取例程的完整源代码包含在Avizo XPand Extension所提供的示例包中。为了跟随下面的示例,请先使用开发向导创建一个本地Avizo目录。请确保开关copy example package已被激活,如第15.2.2小节所述。随后该读取例程可以在本地Avizo目录下的src/mypackage/readppm3d.cpp中找到。
我们先来看一下该读取器带注释的源代码。之后会有一些一般性说明。
1 | /////////////////////////////////////////////////////////////////// |
该源文件以一些include开头。
首先,包含了文件api.h。该文件为Windows系统提供导入与导出的存储类说明符。它们被编码在宏MYPACKAGE_API中。在Unix系统上该宏为空,可以被省略。
接着,包含了文件HxMessage.h。该头文件提供了全局指针theMsg,它允许我们在Avizo控制台窗口中打印文本消息。在我们的读取例程中,如果发生读取错误,我们使用theMsg来打印错误消息。
最后,包含了含有待创建数据类声明的头文件,即HxUniformScalarField3.h。作为一般规则,Avizo中的每一个类都在单独的头文件中声明。头文件的名称与C++类的名称相同。
读取例程本身接受一个参数,即要读取的数据文件的名称。成功时它应当返回1,如果发生错误且无法创建任何数据对象则返回0。该读取例程的主体相当直接。以读取方式打开文件。读取图像体的尺寸。创建一个新的HxUniformScalarField3类型的数据对象,其余数据被写入该数据对象。最后,文件被再次关闭,并通过调用HxData::registerData把该数据对象放入Project View。原则上,所有读取例程都像这个示例一样。当然,所创建数据对象的类型以及该对象被初始化的方式可能有所不同。
为了让新的读取例程为Avizo所知,必须向包资源文件(即文件mypackage/share/resources/mypackage.rc)添加一个条目。在我们的情形下,这个条目如下所示:
1 | dataFile -name "PPM3D Demo Format" \ |
dataFile命令注册了一种名为PPM3D Demo Format的新文件格式。选项-header指定一个正则表达式,它被用于自动文件格式检测。如果某个文件的前64个字节匹配该表达式,该文件就会被自动用这个读取例程加载。当然,有些数据格式没有唯一的文件头。在这种情况下,格式也可以从标准的文件名扩展名检测出来。这样的扩展名可以用dataFile命令的-ext选项指定。多个扩展名可以指定为一个逗号分隔的列表。
读取例程实际的C++名称通过-load指定。最后,必须使用-package选项指定包含该读取例程的包。
如果你已经编译了mypackage示例包中的示例,你可以尝试加载示例文件mypackage/data/test.ppm3d。如你所见,文件浏览器会自动检测该文件格式,并在它的文件列表中显示PPM3D Demo Format。
15.3.2.2 曲面与曲面场读取器
既然你已经原则上知道了读取例程是什么样子,让我们来考虑一个更复杂的示例。在本节中,我们讨论一个创建多个数据对象的读取例程。特别地,我们想从文件读取一个三角形曲面网格。除了曲面描述之外,该文件还可能为曲面的每个顶点存储数据值。定义在曲面网格上的数据在Avizo中由单独的类表示。因此,该读取器必须先创建一个只表示曲面网格的数据对象。然后必须为每个曲面场创建适当的数据对象。
同样,这个文件格式相当简单,是为这个示例的目的而发明的。我们称之为Trimesh格式。它是一种简单的、没有文件头的ASCII格式。第一行包含点的数量和三角形的数量。然后列出各点的x、y、z坐标。这一节之后是三角形规格说明,每个三角形由三个点索引构成,点计数从一开始。下一节是顶点数据。它以包含任意数量整数的一行开始。每个整数指示存在一个定义在曲面顶点上、具有一定数量变量的数据场,例如标量场为1,矢量场为3。每个顶点的数据值随后在各自的行中给出。下面是一个包含单个标量曲面场的小示例:
1 | 4 2 |
你可以在本地Avizo目录下的src/mypackage/readtrimesh.cpp中找到该读取器的完整源代码。请记住,示例包必须在编译之前已被复制到本地Avizo目录中。详情请参阅第15.2.2小节。让我们先看看完整的读取例程,然后再讨论细节:
1 | /////////////////////////////////////////////////////////////////// |
该读取例程的第一部分与上一节所概述的PPM3D读取器非常相似。包含所需的头文件,读取点与三角形的数量,并执行一致性检查。
然后创建一个HxSurface类型的Avizo曲面对象。类HxSurface被设计用于表示任意的三角形集合。这些三角形被组织成补片(patches)。一个补片可以被视为两个体积区域——一个”内部”区域和一个”外部”区域——之间的边界。因此,对于每一个补片,都应当定义一个内部区域和一个外部区域。在我们的情形下,所有三角形都会被插入到单个补片中。在这个补片被创建并初始化之后,点与三角形的数量被设定,也就是说动态数组points和triangles被适当地调整大小。
接着,读取点坐标和三角形。每个三角形由构成它的三个点定义。点索引在文件中从一开始,但在HxSurface类中应当从零开始。因此,所有索引都被减一。一旦所有三角形都被读取,它们就被插入到我们之前创建的那个补片中。该曲面现在已被完全初始化,可以通过调用HxData::registerData添加到Project View中。
该读取例程的第二部分是读取数据值。首先,我们检查定义了多少个数据场,以及每个场有多少个数据变量。
对于每一组数据变量,都会创建一个相应的曲面场。这些场被临时存储在动态数组fields中。我们没有直接调用类HxSurfaceField的构造函数,而是使用静态方法HxSurfaceField::create。该方法会检查数据变量的数量,并在可能的情况下自动创建某个子类(例如HxSurfaceScalarField或HxSurfaceVectorField)的实例。原则上,曲面场既可以逐节点也可以逐三角形地存储数据。这里我们处理的是顶点数据,因此我们在HxSurfaceField::create中把编码指定为HxSurfaceField::ON_NODES。
最后,把数据值读入之前创建的曲面场中。之后,所有场都通过再次调用HxData::registerData被添加到Project View。为了给曲面场定义一个有用的名称,我们调用方法composeLabel。该方法接受一个参考名称(在这个情形下是曲面的名称),并把后缀替换为其他字符串,这里是”data”。Avizo会自动修改该名称以使其唯一。因此,我们可以对所有曲面场执行相同的替换。
与任何其他读取例程一样,我们的Trimesh读取器在能被使用之前必须先在包资源文件中注册。这通过mypackage/share/resources/mypackage.rc中的以下语句完成:
1 | dataFile -name "Trimesh Demo Format" \ |
dataFile命令的大多数选项已在上一节解释过。不过,与PPM3D格式不同,Trimesh格式无法通过它的文件头来识别。因此,我们使用-ext选项来告知Avizo:所有文件名扩展名为trimesh或tm的文件都应当用Trimesh读取器打开。
15.3.2.3 关于读取例程的更多内容
从前两节所给出的示例中,读取例程的基本结构应当已经清楚。不过,还有一些在某些情况下可能有意义的内容。这些将在下面讨论。
一次读取多张图像
Avizo文件浏览器允许你一次选择多个文件。通常,所有这些文件会先确定文件格式、然后调用相应的读取例程,一个接一个地被打开。不过在某些情况下,单个Avizo数据对象的数据分布在多个文件中。最典型的例子是3D图像——其中每一张切片都存储在一个单独的2D图像文件中。为了能够创建一个完整的3D图像,所有单独2D图像的文件名都必须能被某个读取例程获取。为了便于此,Avizo中的读取例程可以有两种不同的签名。除了普通形式
1 | int myreader(const char* filename); |
之外,读取例程也可以定义为如下形式:
1 | int myreader(int n, const char** filenames); |
两种情况下,在包资源文件中都可以使用完全相同的 dataFile 命令。Avizo会自动检测某个读取例程接受的是单个文件名还是多个文件名作为参数。在后一种情况下,读取例程会以文件浏览器中所选全部文件的名称被调用——前提是所有这些文件都具有相同的文件格式(如果选中了多种不同格式的文件,则每种格式的读取例程只会以与之匹配的那些文件被调用)。你可以在开发向导中选择开关 create reader for multiple files(为多文件创建读取例程)来生成一个多文件读取例程的模板(见第15.2.7节)。
Load命令
Avizo项目当前的状态,连同其所有的数据对象和模块,都可以存储到一个脚本文件中。执行该脚本时,应当能够恢复Avizo项目的状态。当然,这是一项困难的任务,尤其是当数据对象在从文件加载之后又被修改过的情况下。不过,即使不是这种情况,Avizo也必须知道之后该如何重新加载这些数据。
为此,应当为数据对象定义一个名为 LoadCmd 的特殊参数。该参数应当包含一段Tcl命令序列,执行时可以恢复该数据对象。通常,load命令只需简单地设置为 load <filename>,这在调用 HxData::registerData 时即已完成。然而,如果文件的格式无法被自动检测,或者从单个文件创建了多个数据对象(例如我们的 Trimesh 示例),这种做法就会失效。
在这类情况下,应当手动设置load命令。对于 Trimesh 读取例程而言,可以通过在例程的最末尾、恰好在方法返回点之前添加如下代码行来实现:
1 | QString loadCmd = QString( |
这段代码需要一些解释。当load命令的第一行被执行时,文件被加载,所有数据对象被创建。请注意,我们把 -trimesh 选项作为 load 的一个参数指定出来。这确保了总是会使用 Trimesh 读取例程,待加载文件的格式将不会被自动判定。Tcl命令 load 返回一个列表,其中包含了所有已创建的数据对象的名称。这个列表被存储在变量 TMPIO 中。之后,各个单独对象的名称可以通过从这个列表中提取相应的元素来获得,这是通过Tcl命令 lindex 完成的。
在读取例程中使用对话框
在某些情况下,除非用户以交互方式指定了某些参数,否则文件无法被成功读取。通常这意味着必须在读取例程内部弹出一个专用对话框。例如,在Avizo中读入原始(raw)数据时就是这么做的。为了编写你自己的对话框,你必须使用Qt——一个用于设计图形用户界面的平台无关工具包。Qt随Avizo XPand扩展一同提供,采用LGPL许可,因此你可以很方便地用它在Avizo中创建自定义对话框。
如果你不想使用Qt,可以考虑在一个普通模块中实现你的读取例程。尽管这在某种程度上打破了Avizo的数据导入概念,但当然也是可行的。这样你就可以利用普通的端口来让用户指定所需的导入参数。
15.3.3 写入例程
与读取例程一样,Avizo中的写入例程也是C++函数,既可以是全局函数,也可以是任意类的静态成员函数。在下面的讨论中,我们针对上一节中已说明过读取代码的同样两种格式来给出写入例程。首先讨论 标量场的写入例程,然后是 曲面和曲面场的写入例程。
15.3.3.1 标量场的写入例程
在本节中,我们说明如何实现一个例程,把3D图像(即类 HxUniformScalarField3 的实例)写入到第15.3.2.1节中引入的PPM3D格式的文件中。写入例程甚至比读取例程更简单。同样,其源代码包含在Avizo XPand扩展的示例包中。一旦你用开发向导创建了本地Avizo目录并把示例包复制到该目录中,你就可以在本地Avizo目录的 src/mypackage/writeppm3d.cpp 下找到这个写入例程。代码如下:
1 | ///////////////////////////////////////////////////////////////// |
在开头部分,包含的头文件与读取例程中相同。
api.h 为Windows系统提供导入和导出的存储类说明符。这些被编码在宏 MYPACKAGE_API 中。在Unix系统上,这个宏是空的,可以省略。
HxMessage.h 提供了全局指针 theMsg,它允许我们在Avizo控制台窗口中打印文本消息。
最后,HxUniformScalarField3.h 包含了要写入文件的数据类的声明。
写入例程的签名与读取例程不同。它接受两个参数,即一个指向要写入文件的数据对象的指针,以及文件的名称。在调用写入例程之前,Avizo总是会检查指定的文件是否已经存在。如果存在,会询问用户是否应当覆盖已有文件。因此,这样的检查不需要在每个写入例程中重新编码。与读取例程一样,写入例程应当在成功时返回1,在发生错误、数据对象无法保存时返回0。
写入例程的主体几乎是自解释的。开头处会检查该3D图像是否真的由字节数据构成。一般来说,这样一幅图像的数据值类型可以是8位字节、16位短整型、32位整型、浮点型或双精度型。如果图像确实包含字节,则打开一个文件并把图像内容写入其中。不过请注意,数据对象还包含一些无法用我们这个简单的PPM3D文件格式来存储的信息。首先,这适用于图像体的边界框,即第一个体素和最后一个体素中心在世界坐标中的位置。此外,该对象的所有参数(定义在类型为 HxParamBundle 的成员变量 parameters 中)在图像被写入PPM3D文件并再次读回时都会丢失。
与读取例程一样,写入例程也必须在包资源文件(即 mypackage/share/resources/mypackage.rc)中注册。这通过如下语句完成:
1 | dataFile -name "PPM3D Demo Format" \ |
选项 -save 指定写入例程的名称。选项 -type 指定可以使用该格式保存的数据对象的C++类名。请注意,一种导出格式可以为多个不同类型的C++对象注册。在这种情况下,应当指定多个 -type 选项。然而,对于每种类型,都必须存在一个具有不同签名的独立写入例程(多态)。例如,如果我们还想为 HxStackedScalarField3 类型的对象注册PPM3D格式,我们就必须额外实现下面这个例程:
1 | int writeppm3d(HxStackedScalarField3* field, const char* fname); |
除了标准数据类之外,还有所谓的 接口类 也可以用 -type 选项指定。例如,通过这种方式可以为n分量的规则3D场实现一个通用的写入例程。这类数据被封装在接口 HxLattice3 中。关于接口的更多信息,请参阅第15.5节。
到这一步,你可以按照第15.1.5节(编译与调试)中给出的说明,尝试编译并执行这个写入例程。
15.3.3.2 曲面和曲面场的写入例程
为了完整起见,本节描述第15.3.2.2节中引入的 Trimesh 格式的写入例程。请记住,Trimesh 格式既适合存储三角网格,也适合存储定义在曲面顶点上的任意数量的数据值。在Avizo中,曲面和定义在曲面上的数据场由不同的对象表示。这在设计写入例程时也会带来一些影响。
在我们的示例中,我们实际上实现了两个不同的写入例程,一个用于曲面,另一个用于曲面场。如果用户选择曲面并用 Trimesh 写入例程导出它,则曲面网格以及所有附加的数据场都将被写入文件。另一方面,如果用户选择某个特定的曲面场,则相应的曲面和仅所选的那个场会被写入。
写入例程的源代码可以在本地Avizo目录的 src/mypackage/writetrimesh.cpp 下找到。请记住,在编译之前必须把示例包复制到本地Avizo目录中。相关细节请参阅第15.2.2节。同样,让我们先来看代码:
1 | ///////////////////////////////////////////////////////////////// |
在代码的上半部分,首先定义了一个静态工具方法,它接受三个参数:一个指向曲面的指针、一个指向曲面场的指针的动态数组,以及一个文件名。这就是实际把数据写入文件的函数。一旦你理解了第15.3.2.2节中给出的 Trimesh 读取例程,跟上这段写入代码应该毫无困难。
在代码的下半部分,定义了上面提到的两个写入例程,一个用于曲面,另一个用于曲面场。由于这些例程要被导出以供外部使用,我们需要应用包宏 MYPACKAGE_API——至少在Windows上是如此。
现在让我们更仔细地看看曲面写入例程。这个例程首先把附加在曲面上的所有曲面场收集到一个动态数组中。这是通过扫描 surface->downStreamConnections 完成的,它提供了一个附加在该曲面上的所有对象的列表。每个对象的类类型使用方法 isOfType 来检查。这种动态类型检查与Open Inventor中的做法相同。如果找到了一个曲面场,并且它包含定义在其节点上的数据,就把它追加到临时数组 fields 中。然后,曲面本身以及收集到的这些场,通过调用写入代码上半部分中定义的那个工具方法被写入文件。
第二个写入例程,即适配曲面场的那个,则更为简单。这里同样使用了一个场的动态数组,但这个数组只填入表示原始曲面场的数据。一旦完成,就可以像第一种情况那样调用同一个工具方法。
尽管实际上定义了两个写入例程,但在包资源文件中只需要一个条目。该条目如下所示(见 mypackage/share/resources/mypackage.rc):
1 | dataFile -name "Trimesh Demo Format" \ |
为了编译并执行这个写入例程,请遵循第15.1.5节(编译与调试)中给出的说明。
15.3.4 使用AmiraMesh API以Avizo数据格式读写文件
除了许多标准文件格式之外,Avizo还提供了它自己的原生格式,称为Avizo数据格式。Avizo数据文件格式非常灵活。它可以用来保存许多不同种类的数据对象,包括图像数据、有限元网格以及定义在这类网格上的解数据。除其他特性之外,它支持ASCII或二进制数据编码、数据压缩,以及任意参数的存储。该格式本身在用户手册的参考部分中有更详细的描述。在本节中,我们想讨论如何以Avizo格式保存自定义数据对象。为此,提供了一个特殊的C++工具类,名为 AmiraMesh。使用这个类,以Avizo格式读写文件变得非常容易。
下面我们首先给出AmiraMesh API的 概览。之后,我们给出两个简单的示例。在第一个示例中,我们展示如何以Avizo格式 写出 颜色映射表。在第二个示例中,我们展示如何把这样的颜色映射表再 读回 来。
15.3.4.1 概览
AmiraMesh API由单个C++类组成。这个类称为 AmiraMesh。它定义在位于Avizo根目录下的头文件 include/amiramesh/AmiraMesh.h 中。该类被设计为在内存中完整地表示一个Avizo文件中所存储的信息。读取文件时,首先创建一个 AmiraMesh 类的实例。然后可以对这个实例进行解释,其中包含的数据可以被复制到一个相匹配的Avizo数据对象中。同样地,在写文件时,首先创建一个 AmiraMesh 类的实例并用所有必需的信息对其初始化。然后只需调用一个成员方法,就可以把这个实例写入文件。
如果你查看头文件或 AmiraMesh 类的文档,你会注意到有四个公有方法,分别叫做 setParameters、setLocationList、setDataList 和 setFieldList。这些方法完整地存储了一个文件中所包含的信息。第一个方法存储参数束(带一个 HxParamBundle 参数)。与在Avizo数据对象中一样,它被用来存储任意的参数层次结构。其余三个方法操作指向局部定义类的指针的动态数组。最重要的局部类是 Location 和 Data,它们分别存储在 locationList 和 dataList 中。为了操作这两个列表,创建了四个访问器 getLocationList、setLocationList、getDataList 和 setDataList。
一个 Location 定义了单个或多维数组的名称。它本身并不存储任何数据。数据由 Data 类来存储。每个 Data 类都必须引用某个 Location。例如,在写出一个四面体网格时,我们可以定义两个不同的一维location,一个叫做 Nodes,另一个叫做 Tetrahedra。在节点上,我们定义一个 Data 实例来存储节点的x、y、z坐标。同样地,在四面体上我们定义一个 Data 实例来存储一个四面体的四个点的索引。
正如 AmiraMesh 类文档中所述,Data 类可以接受一个指向某块已经存在的内存的指针。通过这种方式,就避免了在写入文件之前复制全部数据。为了写出压缩数据,必须调用成员方法 setCodec。目前,支持两种不同的压缩方案。第一种称为 HxByteRLE,实现了逐字节的简单行程长度编码。第二种称为 HxZip,使用了由外部 zlib 库提供的更为复杂的压缩技术。在任何情况下,读取Avizo数据文件时数据都会被自动解压缩。
应当指出,Avizo数据文件格式本身仅仅提供了一种把按单维或多维数组组织的任意数据存储到文件中的方法。它并没有指定关于数据语义的任何内容。因此,在读取一个Avizo数据文件时,并不清楚应当从它创建什么类型的数据对象。为了便于自定义数据对象的文件I/O,Avizo数据文件的实际内容由一个特殊参数 ContentType 来指明。对于每一种这样的类型,都注册一个专用的读取例程。与普通读取例程一样,Avizo数据读取例程是一个全局函数或某个C++类的静态成员方法。它具有如下签名:
1 | int readMyAvizoFile(AmiraMesh* m, const char* filename); |
每当 ContentType 参数与该读取方法所注册的类型相匹配时,这个方法就会被调用。读取例程应当从AmiraMesh类的内容创建一个Avizo数据对象。文件名可以用来定义所得数据对象的名称。为了注册一个Avizo读取例程,必须把类似于下面这样的语句放入包资源文件中:
1 | AvizoFormat -ContentType "MyType" \ |
15.3.4.2 写出一个Avizo数据文件
作为一个具体的示例,在本节中我们想展示如何以Avizo数据格式写出一个颜色映射表。具体来说,我们考虑 HxColormap256 类型的颜色映射表,它由N个离散的RGBA四元组构成。与大多数其他写入方法一样,Avizo数据写入例程是一个全局C++函数。在讨论细节之前,先来看代码。
1 | HXCOLOR_API |
在例程的第一部分,定义了一个 AmiraMesh 类型的变量 m。颜色映射表的参数被复制到 m 中。此外,还设置了另外两个参数。第一个称为 MinMax,描述了颜色映射表的坐标范围。第二个指明了该Avizo数据文件的内容类型。这个参数确保了颜色映射表之后能够被一个相匹配的Avizo数据读取例程读回(见第15.3.4.3节)。
在能够存储RGBA数据值之前,必须先创建一个大小正确的 Location 并把它插入到 AmiraMesh 类中。之后,创建并插入一个 Data 类的实例。Data 类的构造函数接受一个指向该 Location 的指针作为参数。此外,还指定了一个指向RGBA数据值的指针。每个RGBA四元组由四个float类型的数字组成。
15.3.4.3 读取一个Avizo数据文件
在上一节中,我们给出了一个简单的用于颜色映射表的Avizo数据写入例程。现在我们想把这样的文件再读回来。为此,我们在类 HxColormap256 中定义一个静态的Avizo数据读取函数。当然,也可以使用一个全局C++函数。该读取函数在包资源文件 hxcolor.rc 中以如下方式注册:
1 | AvizoFormat -ContentType "Colormap" \ |
这条语句表明:如果Avizo数据文件包含一个值等于 Colormap 的 ContentType 参数,就应当调用包 hxcolor 中定义的类 HxColormap256 的静态成员方法 readAmiraMesh。读取例程的源代码如下所示:
1 | int |
与写入例程相比,读取例程要稍微复杂一些,因为其中执行了一些一致性检查。首先,在 AmiraMesh 结构的成员 dataList 中搜索一个一维数组,该数组包含由三个或四个float类型元素构成的向量。这个数组应当包含颜色映射表的RGB或RGBA值。如果找到了一个相匹配的 Data 结构,就创建一个 HxColormap256 类型的新实例。参数从 AmiraMesh 类被复制到这个新的颜色映射表中。之后,复制实际的颜色值。尽管写入例程只导出float类型的RGBA四元组,读取例程也支持字节数据。为此,用一个switch语句区分了两种不同的情况。如果文件只包含3分量的数据,则每个颜色映射表条目的不透明度值被设为1。最后,通过求取2分量参数 MinMax 来设置颜色映射表的坐标范围,并通过调用 HxData::registerData 把这个新的颜色映射表加入到项目视图中。
15.4 编写模块
除了数据类之外,模块是Avizo的核心。它们包含用于可视化和数据处理的实际算法。模块是从公共基类 HxModule 派生的C++类的实例。
模块有两大类:计算模块(compute modules)和 显示模块(display modules)。第一类通常对输入数据执行某种操作,创建某个结果数据对象,并把它放入项目视图中。相比之下,显示模块通常直接可视化它们的输入数据。在本节中,两类模块都将在各自的小节中介绍。对于每一种情况,都会给出一个具体示例并进行详细讨论。
此外,我们在本节中还会讨论AvizoPlot API。这个API使得在一个模块内部创建简单的折线图或条形图成为可能。
而且,Avizo还允许你用CUDA创建 GPU上的计算模块。对于每种情况,都会给出并详细讨论一个具体示例。
15.4.1 计算模块
如前所述,计算模块 通常接受一个或多个输入数据对象,并从这些输入计算出一个新的结果数据对象。所得的结果数据对象被放入项目视图中。计算模块在项目视图中用红色图标表示。它们派生自基类 HxCompModule。
为了了解如何实现一个新的计算模块,我们来看一个具体示例。具体来说,我们想编写一个计算模块,它对一幅3D图像(即一个 HxUniformScalarField3 类型的输入对象)执行阈值操作。该模块产生另一幅3D图像作为输出。在所得图像中,所有值低于用户指定的最小值或高于最大值的体素都应当被置为零。
为了更容易理解,我们从这个模块一个非常简单且功能有限的版本开始,然后迭代地改进代码。具体来说,我们按三个步骤进行:
- 版本1:仅仅扫描输入图像,还不产生结果
- 版本2:创建一个输出对象作为结果,使用进度条
- 版本3:添加一个 Apply 按钮,尽可能覆写已有的结果
你可以在Avizo XPand扩展所提供的示例包中,即本地Avizo目录下的 src/mypackage 中,找到全部三个版本的源代码。对于每个版本,都有两个文件:一个名为 MyComputeThresholdN.h 的头文件和一个名为 MyComputeThresholdN.cpp 的源代码文件(其中 N 为1、2或3)。由于名称不同,你可以并行地编译和执行全部三个版本。
为了创建一个新的本地Avizo目录,请遵循第15.2.2节中给出的说明。为了编译示例包,请参阅第15.1.5节(编译与调试)。
15.4.1.1 版本1:一个计算模块的骨架
我们模块的第一个版本还不产生任何输出。它只是扫描输入图像,并打印出高于和低于阈值的体素数量。
与大多数其他模块一样,我们的计算模块由一个包含类声明的头文件以及一个包含实际代码(即类定义)的源文件组成。我们先来看头文件 MyComputeThreshold1.h:
1 | ///////////////////////////////////////////////////////////////// |
与C++代码中的惯例一样,文件开头有一个define语句,用于防止文件内容被多次包含。然后包含了三个头文件。
包头文件 api.h 被包含进来。这个文件为Windows系统提供导入和导出的存储类说明符。这些被编码在宏 MYPACKAGE_API 中。一个声明时不带这个宏的类,在其所定义的DLL之外将无法访问。
紧随 api.h 之后,HxCompModule.h 包含了我们这个计算工具的基类的定义。
最后一个文件 HxPortFloatTextN.h 包含了我们想在类中使用的一个 端口(port)的定义。端口表示工具的一个输入参数。在我们的例子中,我们使用一个 HxPortFloatTextN 类型的端口。这个端口提供一个或多个文本字段,用户可以在其中输入浮点数。所需的文本字段和标签会在端口的构造函数内部自动创建。作为程序员,你只需把一些端口放进你的工具中,指定它们的类型和标签,而不必费心去为它创建用户界面。
在头文件的其余部分,除了从 HxCompModule 派生出一个新类之外,没有做别的事情。宏 HX_HEADER 是必需的。这个宏尤其定义了该类的默认构造函数和析构函数。还定义了一个成员函数,即一个名为 compute 的重载虚方法。当模块被创建时,以及每当该模块的某个输入数据对象或端口发生状态变化时,compute 方法就会被调用。事实上,正如我们稍后将看到的,与输入数据对象的连接也是通过一个端口来建立的。在这个示例中,我们在类中只声明了一个端口,具体来说是一个 HxPortFloatTextN 类型的实例。
相应的源文件如下所示:
1 | ///////////////////////////////////////////////////////////////// |
在include语句和强制的 HX_INIT_CLASS 宏之后,定义了构造函数。为了调用基类的构造函数以及类成员的构造函数,必须使用通常的C++语法。基类 HxCompModule 的构造函数接受该模块可以连接到的输入数据对象的类类型。Avizo使用一套特殊的运行时类型信息系统,它独立于ANSI C++编译器所提供的rtti特性。
第二个要定义的方法是析构函数。
我们必须实现的第三个方法是 compute 方法。我们首先通过一个名为 portData 的成员取得指向输入数据对象的指针。这个端口继承自基类 HxModule,也就是说,每个模块都有这个成员。该端口的类型为 HxConnection,在用户界面中表示为一条蓝线(如果已连接)。compute方法的其余部分相当直观。数据的实际访问方式以及计算的执行方式,当然高度取决于输入数据类和该模块所执行的任务。在这个例子中,我们只是遍历输入图像的所有体素,统计低于最小值和高于最大值的体素数量。为了访问某个体素的值,我们使用 evalReg 方法。这个方法由任何具有规则坐标的标量场提供,即由 HxRegScalarField3 类的任何实例提供。无论该场的基本数据类型是什么,结果总是会被强制转换为float。
编译 mypackage 示例包并重启Avizo。从Avizo的 data/tutorials 目录加载文件 chocolate-bar.am。打开创建对象(Create Object)弹出菜单,在 Local 类别中单击 ComputeThreshold1 以附加这个新模块。在 ComputeThreshold1 的 range 端口中输入不同的阈值,并在你改变 range 值的同时观察 Console 窗口中出现的不同结果:
1 | 0 voxels < 0, 8882137 voxels > 0 |
15.4.1.2 版本2:创建一个结果对象
既然我们已经有了模块的第一个可工作版本,就可以添加更多功能了。首先,我们想创建一个真正的输出数据对象。然后我们还想通过使用Avizo的进度条、以及为range端口提供更好的默认值来改进这个模块。我们模块的头文件不会受这些改动的影响。我们只需在源文件 MyComputeThreshold2.cpp 中添加一些代码。
先从输出数据对象开始。在compute方法中,就在for循环之前,我们插入如下语句:
1 | // Create output with the same primitive data type as input: |
这创建了一个 HxUniformScalarField3 类型的新实例,其维度和基本数据类型与输入数据对象相同。由于输出具有相同的边界框,即相同的体素大小,我们只是复制了边界框。请注意,这种做法只对具有均匀坐标的场有效。对于其他规则坐标类型,例如堆叠坐标或曲线坐标,我们参见第15.5.2节。
输出对象创建之后,其体素值尚未初始化。这是在嵌套for循环的内层部分完成的。为此使用的方法 set 会自动执行从float到输出场基本数据类型的强制转换。总之,for循环的内层部分现在如下所示:
1 | float value = field->evalReg(i,j,k); |
使用 new 运算符创建一个新的数据对象并不会自动让它出现在项目视图中。相反,我们必须显式地注册它。在计算模块中,这可以通过调用方法 setResult 来完成:
1 | setResult(output); // register result |
如果该数据对象尚未存在于项目视图中,这个方法会把它加入项目视图。此外,它还把该对象的 master 端口连接到计算模块本身。与任何其他连接一样,这条链接在项目视图中会用一条黑线表示。数据对象的master端口可以连接到一个计算模块或一个编辑器。这样的master连接表明该数据对象受某个”上游”组件控制,也就是说,它的内容可能会被它所连接到的那个对象覆写。
现在我们已经创建了一个输出对象,接下来处理进度条。虽然对于测试数据集 chocolate-bar.am 而言,我们的阈值操作并不会花很长时间,但在执行可能耗时较长的计算时,表明应用程序正忙是一种良好的实践。更好的做法是显示一个进度条,这并不困难。在compute例程中耗时的部分之前,即在嵌套for循环之前,我们添加如下一行:
1 | // Turn the application into busy state, |
我们在这里使用了 HxApplication 类的全局实例 theProgress。相应的头文件必须在源文件的开头包含进来。这个方法把应用程序切换到”忙”状态,并在状态栏中显示一条工作消息。与方法 startWorking 不同,这个变体不会激活停止按钮。详情参见第15.7.2节。计算完成后,我们必须调用
1 | theProgress->stopWorking(); // stop progress bar |
以再次关闭”忙”状态。在嵌套for循环内部,我们就在处理一个新的2D切片之前更新进度条。这是通过下面这行代码完成的:
1 | // Set progress bar, the argument ranges between 0 and 1. |
(float)(k+1)/dims[2] 的值在计算过程中会从零逐渐递增到一。请注意,你不应当在三重循环的最内层调用 setProgressValue。每次调用都涉及一次图形用户界面的更新,因此相对昂贵。在一次计算过程中更新进度条几百次是完全可以的,但几十万次就不行了。
我们在计算模块的第二个版本中加入的另一个小改进与 range 端口有关。在构造函数中,我们为最小和最大字段设置了新的初始值。虽然两个值默认都是0,但我们现在分别把它们设为410和950:
1 | // Set default value for the range port: |
你现在可以通过从Avizo的 data/tutorials 目录加载测试数据集 chocolate-bar.am 来测试计算模块的这第二个版本。从data弹出菜单的 Local 类别中附加 ComputeThreshold2 模块。打开 Console 窗口,在你改变端口 range 值的同时观察结果。你还可以看到每次改动都会在 项目视图 中创建新的输出对象。为了更好地体会进度条的效果,试着把输入数据重采样,例如重采样到512x512x100,然后把计算模块连接到重采样后的数据集。不过,请确保你的系统上安装了足够的主内存。
15.4.1.3 版本3:复用结果对象
在测试我们模块的前两个版本时,我们看到该模块的compute方法会在模块被创建时以及range端口被改变时自动触发。每次都会创建一个新的结果输出数据对象。这会很快占满计算机的主内存以及Avizo的图形用户界面。因此,我们现在改变这一行为:新的结果对象只在第一次时创建。之后每当range端口被改变时,应当覆写已有的结果对象。为了实现这一点,我们按如下方式修改compute方法的中间部分:
1 | // Access size of data volume: |
getResult 方法检查是否存在某个数据集,其master端口连接到该计算模块。这通常是由先前一次 setResult 调用所设置的对象。然而,它也可能是任何其他对象。因此,必须通过调用输出对象的 isOfType 成员方法来执行一次运行时类型检查。如果输出对象不是 HxUniformScalarField3 类型,变量 output 就会被置为空。然后再检查输出对象是否与输入对象具有相同的维度和相同的基本数据类型。如果这个测试失败,output 同样会被置为空。最后,只有在结果尚不存在、或者已有结果与输入不匹配时,才会创建新的结果对象。这样就可以交互式地尝试不同的range值,而不会创建一大堆新的结果。
不过,当range端口的某个数字被改变时,计算会立即开始。有时这可能是我们想要的,但在本例中,我们更愿意像许多其他计算模块那样添加一个 Apply 按钮。用户必须显式地按下这个按钮才能启动计算。为了使用 Apply 按钮,必须在模块头文件的public部分添加下面这行代码:
1 | // Start computation when this button is clicked. |
当然,相应的包含文件 hxcore/HxPortDoIt.h 也必须被包含进来。与另一个端口一样,我们必须在源文件中我们模块的构造函数里初始化 portDoIt:
1 | MyComputeThreshold3::MyComputeThreshold3() : |
为了实现所期望的行为,我们最后修改compute方法,使其在 Apply 按钮未被按下时立即返回。这可以通过在compute方法的开头添加下面这段代码来实现:
1 | // Check whether doIt button was hit |
有了这些改动,这个模块已经相当可用了。从Avizo的 data/tutorials 目录加载测试数据集 chocolate-bar.am。从data弹出菜单的 Local 类别中附加 ComputeThreshold3 模块。按下 Apply 按钮,改变range后再次按下 Apply。打开 Console 窗口查看结果。你可能已经注意到,项目视图 中只创建了一个输出对象。在试验range的同时给结果附加一个 Ortho Slice 模块(使用 Ortho Slice 中的直方图映射以便看到细微变化)。试着断开结果与模块之间的连接,然后再次按 Apply,就会创建一个新的输出。
注意:默认情况下,HxPortDoIt 端口在其所属模块的控制面板中并不可见。相反,模块具有 HxPortDoIt 这一事实会激活(点亮为绿色)属性区域底部的 Apply 按钮。若要在模块控制面板中显示DoIt端口,请在 Edit/Preferences 对话框的 Layout 选项卡中勾选 Show DoIt buttons 复选框。
最后,谈一些关于性能的看法。尽管在这个简单示例中性能可能并不关键,但在实际应用中性能通常会成为一个问题。在最内层的循环中,调用 field->evalReg 和 output->set 这两个方法虽然方便,却相当昂贵。例如,如果输入像 chocolate-bar.am 那样由16位有符号整数构成,这些方法会涉及从 16-bit signed 到 float 再回到 16-bit signed 的强制转换。
性能可以通过编写显式处理某种特定基本数据类型的代码来改善。可以通过调用 field->lattice().dataPtr() 获得指向 HxUniformScalarField3 实际数据值的指针。该方法返回的指针类型为 void*。它必须被显式地强制转换为该场实际所属的数据类型。体素值本身是不带任何填充地排列的。这意味着体素 (i, j, k) 的索引为 (k*dims[1]+j)*dims[0]+i,其中 dims[0] 和 dims[1] 分别表示x和y方向上的体素数量。
15.4.2 显示模块
我们的下一个示例是一个在Avizo的3D查看器中显示某些几何体的模块。该模块接受一个曲面模型作为输入,并在每个属于n个三角形的顶点处绘制一个小立方体,其中n是一个用户可调的参数。
从上一节中我们已经知道基本思路:我们从基类 HxModule 派生出一个新类。由于这次模块不产生新的数据集,我们直接使用 HxModule 作为基类,而不是 HxCompModule。作为输入,该模块应当接受 HxSurface 类的数据。我们还需要一个额外的端口,让用户指定参数n。与上一节一样,我们逐步开发这个模块的不同版本,从而引入新的概念:
- 版本1:
创建一个Open Inventor场景图并在查看器中显示它。 - 版本2:
添加一个颜色映射表端口,为Tcl命令提供一个parse方法。 - 版本3:
实现一种新的显示模式,动态地显示或隐藏某个端口。
你可以在Avizo XPand扩展所提供的示例包中,即本地Avizo目录下的 src/mypackage 中,找到该模块全部三个版本的源代码。对于每个版本,都有两个文件:一个名为 MyDisplayVerticesN.h 的头文件和一个名为 MyDisplayVerticesN.cpp 的源代码文件(其中 N 为1、2或3)。由于名称不同,你可以并行地编译和执行全部三个版本。
为了创建一个新的本地Avizo目录,请遵循第15.2.2节中给出的说明。为了编译示例包,请参阅第15.1.5节(编译与调试)。
15.4.2.1 版本1:显示几何体
我们模块的第一个版本称为 MyDisplayVertices1,它仅仅检测出感兴趣的顶点,并用小立方体把它们显示出来。为了理解代码,我们首先需要更仔细地看看类 HxSurface。正如我们在参考文档中所能看到的,一个曲面本质上包含一个3D点的数组和一个三角形的数组。每个三角形有三个指向点列表的索引。为了统计每个顶点的三角形数量,我们只需遍历三角形列表,并为每个顶点递增一个计数器。
一旦我们检测出了所有感兴趣的顶点,就要用小立方体把它们显示出来。这是通过创建一个Open Inventor场景图来完成的。如果你想更多地了解Open Inventor,你大概应该看看Addison-Wesley出版的优秀著作 The Inventor Mentor。简而言之,一个Open Inventor场景图是一种描述3D场景的树状C++对象结构。我们的场景相当简单。它由一个 分隔符节点(separator node)构成,其中包含若干立方体,即 SoCube 类的实例。由于 SoCube 总是位于原点,我们在每个 SoCube 之前放置一个额外的 SoTranslation 类型的节点。我们调整立方体的大小,使其每条边长为输入曲面边界框对角线长度的0.01倍。
在这段简短的概览之后,我们现在来看该模块的头文件。它称为 MyDisplayVertices1.h:
1 |
|
这个头文件很容易理解。首先,包含了一些其他头文件。然后,这个新模块被声明为 HxModule 的一个子类。与通常一样,宏 MYPACKAGE_API 和 HX_HEADER 是强制的,后者尤其包含了默认构造函数和析构函数的定义。我们的模块实现了一个compute方法。此外,它有一个 HxPortIntSlider 类型的端口,允许用户指定要显示的顶点的三角形数量。
一个指向实际Open Inventor场景的指针存储在类型为 McHandle<SoSeparator> 的成员变量 scene 中。McHandle 是一种所谓的 智能指针。它可以像普通的C指针那样使用。然而,每次给它赋值时,被引用对象的引用计数会自动增加或减少。这是通过调用该对象的 ref 或 unref 方法完成的。如果引用计数变为零或更小,该对象会被自动删除。我们建议使用智能指针而不是C指针,因为它们更安全。
该模块的实际实现包含在文件 MyDisplayVertices1.cpp 中。这个文件如下所示:
1 | ///////////////////////////////////////////////////////////////// |
这里发生了很多事情。现在让我们更详细地指出其中的一些。构造函数用 HxSurface::getClassTypeId 返回的类型初始化基类。这确保该模块只能附加到 HxSurface 类型的数据对象上。构造函数还初始化成员变量 portNumTriangles。滑块的范围被设为1到12。初始值被设为6。最后,创建一个新的Open Inventor分隔符节点并存储在 scene 中。
析构函数只包含一次调用,即 hideGeom(scene)。这会导致该Open Inventor场景从所有查看器中被移除(前提是它是可见的)。当 McHandle 的析构函数被调用时,场景本身会被自动删除。
实际的计算在 compute 方法中执行。如果不存在输入曲面,该方法会立即返回。如果存在输入曲面,就统计每个点的三角形数量。为此,定义了一个动态数组 triCount。这个数组为每个顶点提供一个计数器。初始时它被填充为零。计数器在一个遍历所有三角形的顶点的循环中被递增。
在compute方法的第二部分,创建Open Inventor场景图。首先,移除 scene 之前的所有子节点。然后确定输入曲面边界框对角线的长度。立方体的大小将与这个长度成比例地设置。为方便起见,指向曲面坐标的指针存储在一个局部变量 p 中。实际上,坐标的类型是 McVec3<float>。然而,这个类与Open Inventor的向量类 SbVec3f 完全兼容。因此,指向坐标的指针可以像代码中所示那样进行强制转换。
在一切设置完毕之后,在一个for循环中检查数组 triCount 的每一个元素。如果某个元素的值与所选的每顶点三角形数量相匹配,就创建、初始化两个 SoTranslation 和 SoCube 类型的Inventor节点,并把它们插入到 scene 中。由于 SoTranslation 也会影响所有后续的平移节点,我们必须把最后一个点的位置记在 q 中,并从当前点的位置中减去它。或者,我们也可以把 SoTranslation 和 SoCube 封装在一个额外的 SoSeparator 节点中。不过,这会导致场景图更复杂。在compute方法的最末尾,通过调用 showGeom 让新的场景图在查看器中变为可见。这个方法会自动检查某个节点是否已经可见。因此,它可以用相同的参数被多次调用。
该模块以通常的方式在包资源文件中注册,即在 mypackage/share/resource/mypackage.rc 中。一旦你编译了示例包,就可以通过加载位于本地Avizo目录中的曲面 mypackage/data/test.surf 来测试这个模块。从该数据弹出菜单的 Local 类别中附加模块 DisplayVertices1。
15.4.2.2 版本2:添加颜色和一个parse方法
在本节中,我们想为我们的模块再添加两个特性。首先,我们想使用一个颜色映射表端口,它允许我们指定立方体的颜色。其次,我们想添加一个parse方法,它允许我们为该模块指定额外的Tcl命令。
颜色映射表端口用于建立与某个颜色映射表的连接,即与 HxColormap 类型的某个类的连接。它派生自 HxConnection,但与基类不同的是,它提供了一个图形用户界面,显示颜色映射表的内容并让用户改变其坐标范围。如果该端口未连接任何颜色映射表,则显示一种默认颜色。用户可以通过双击色条来编辑这种默认颜色。
为了给我们的模块提供一个颜色映射表端口,我们必须在模块的头文件中插入下面这行:
1 | HxPortColormap portColormap; |
当然,我们还必须包含类 HxPortColormap 的头文件。这个文件位于包 hxcolor 中。请注意,端口在屏幕上显示的顺序取决于它们在头文件中声明的顺序。如果我们把 portColormap 声明在 portNumTriangles 之前,颜色映射表端口就会显示在整数滑块之前。
在我们模块的compute方法中,我们在移除场景图先前的子节点之后紧接着插入下面这段代码:
1 | SoMaterial* material = new SoMaterial; |
有了这些代码行,我们就在所有平移节点和立方体节点之前、向分隔符中插入了一个材质节点。这个材质节点使立方体以某种特定的颜色显示。我们调用颜色映射表端口的 getColor 方法来确定这个颜色。如果该端口未连接颜色映射表,这个方法只是返回默认颜色。然而,如果它已连接,颜色就取自该颜色映射表。作为参数,我们指定 numTriPerVertex,即所选顶点的三角形数量。因此,取决于 portNumTriangles 的值,立方体会以不同的颜色显示。当然,这要求颜色映射表的范围大致从1延伸到10或12。
除了颜色映射表端口之外,我们还想给我们的模块添加一个Tcl命令接口。这是通过重载 HxModule 的虚方法 parse 来完成的。因此,我们把下面这行插入到该模块的类声明中:
1 | virtual int parse(Tcl_Interp* t, int argc, char **argv); |
在parse方法中,可以定义一些特殊命令,让我们能以更复杂的方式控制模块。一个典型的应用是那些不应当由用户界面中一个单独端口来表示的特殊参数。作为示例,我们想提供一个方法,让我们能够改变立方体的大小。在该模块的初始版本中,立方体被调整为每条边长为输入曲面边界框对角线长度的0.01倍。这个缩放因子的值现在应当存储在成员变量 scale 中。为了设置和获取这个变量,应当提供两个Tcl命令 setScale 和 getScale。parse方法的实现如下所示:
1 | int |
命令在一串if-else语句中定义。对于每个命令,都应当使用方法 cmdCheck。在if-else序列的末尾,应当调用基类的parse方法。请注意,在发出一个命令之后,该模块的compute方法默认不会被自动调用。这与端口的交互式改变形成对比。不过,我们可以像上面所示的那样在某个命令中显式地调用 fire。在这种情况下,立方体的大小会立即被调整。你可以通过加载 mypackage/data/test.surf、给它附加 DisplayVertices2、然后在Avizo控制台窗口中输入类似 DisplayVertices2 setScale 0.03 这样的命令来测试这个parse方法。
15.4.2.3 版本3:添加一个update方法
除了compute方法之外,模块还可以定义一个 update 方法。这个方法会在compute方法之前、以及每当某个模块被选中时被调用。在update方法中,可以配置该模块的用户界面,即:如果需要,可以动态地显示或隐藏端口,可以调整端口的灵敏度,或者可以动态地修改某个选项菜单的条目数。
为了说明update方法可以如何工作,我们在模块中实现一种备选的显示模式。在这种模式下,应当显示曲面的所有顶点,而不只是那些具有特定相邻三角形数量的顶点。在这第二种模式下,滑块 portNumTriangles 就不再有意义了。因此,我们通过定义一个恰当的update方法来把它隐藏。下面这些行被添加到头文件 MyDisplayVertices3.h 中:
1 | // Mode: 0=selected vertices, 1=all vertices |
这个新的单选框端口让用户在两种显示模式之间切换。与compute方法一样,update方法不接受参数,也没有返回值。
如果你查看源代码文件 MyDisplayVertices3.cpp,你会注意到单选框端口在该模块的构造函数中被初始化,并且文本标签被正确设置。update方法本身相当简单:
1 | void |
滑块 portNumTriangles 会依据单选框端口的值被显示或隐藏。请注意,在update方法被调用之前,所有端口都被标记为要显示。因此,你必须在每次 update 被调用时把它们隐藏。例如,show 和 hide 调用不应当被封装在一个检查某个输入端口是否是新的if语句之中。
为了支持新的全顶点显示样式,我们稍微修改了Open Inventor场景图的创建方式。不再使用单个 SoMaterial 节点,而是每当某个立方体的颜色需要改变时(即每当某个顶点的三角形数量与前一个不同时)就插入一个新的节点。新的for循环如下所示:
1 | int lastNumTriPerVertex = -1; |
同样,你可以通过加载文件 mypackage/data/test.surf 并给它附加 DisplayVertices3 来测试这个模块。如果你把 physics.icol 颜色映射表连接到颜色映射表端口,把颜色映射表范围调整为1…9,并选择全顶点显示样式,你应当得到一幅类似于图15.11所示的图像。
图 15.11:示例模块 DisplayVertices3 在曲面的顶点处显示小立方体。立方体依据相邻三角形的数量着色。
15.4.3 一个带绘图输出的模块
在某些情况下,你可能想在一个Avizo模块中显示一幅简单的2D图表,例如一幅直方图或某种条形图。为了便于完成这项任务,Avizo提供了一个专用的 Plot API,它可以在任何Avizo对象中使用,无论该对象是计算模块还是显示模块。
类 PzEasyPlot 提供了打开一个绘图窗口并在该窗口中绘图所必需的方法。下面,我们同样借助一个 示例 来说明如何使用这个类。具体来说,我们要编写一个模块,为标签图像中定义的所有材料绘制每张切片的体素数量。标签图像通常表示一次图像分割操作的结果。对于每个体素,都有一个标签指明该体素属于哪种材料。Plot API的更多特性 将在单独的一节中描述。
15.4.3.1 一个简单的绘图示例
在本节中,我们展示如何使用类 PzEasyPlot 绘制一些简单的曲线。如上所述,这些曲线表示一幅标签图像中各材料的每张切片的体素数量。为此,我们定义一个新的模块,称为 MyPlotAreaPerSlice。
与其他示例一样,这个模块包含在Avizo示例包中。为了查看示例包,你必须按第15.2.2节所描述的那样创建一个本地Avizo目录。为了编译示例包,请参阅第15.1.5节(编译与调试)。
我们先来看头文件 MyPlotAreaPerSlice.h:
1 | ///////////////////////////////////////////////////////////////// |
这个类声明非常简单。该模块直接从 HxModule 派生。它提供了一个compute方法和一个 HxPortButtonList 类型的端口。事实上,我们只会使用一个按钮来让用户弹出绘图窗口。绘图窗口类 PzEasyPlot 本身由一个智能指针引用,即由一个 McHandle<PzEasyPlot> 类型的变量引用。我们已经在第15.4.2.1节中使用过智能指针,详情参见该节。
现在让我们看看源文件 MyPlotAreaPerSlice.cpp:
1 | ///////////////////////////////////////////////////////////////// |
在构造函数中,基类 HxModule 用类 HxLabelLattice3 的类类型ID进行初始化。这个类不是从 HxData 派生的数据类,而是一个所谓的 接口。接口用于为那些并非通过继承直接相关的对象提供一个公共API。在我们的例子中,MyPlotAreaPerSlice 可以连接到任何提供 HxLabelLattice3 接口的数据对象。这可能是一个 HxUniformLabelField3,但也可能是一个 HxStackedLabelField3 或别的什么东西。
同样在构造函数中,创建了一个 PzEasyPlot 类型的新绘图窗口并存储在 plot 中。然后调用方法 plot->autoUpdate(0)。这意味着我们必须在绘图窗口的内容被改变之后显式地调用 PzEasyPlot 的 update 方法。当一次要改变多条曲线时,应当禁用自动更新。
与通常一样,实际的工作由compute方法执行。首先,我们取得指向标签晶格的指针。由于我们想使用接口而不是数据对象本身,我们必须把该接口的类类型ID作为 portData 的 source 方法的参数指定出来。否则,我们会得到一个指向提供该接口的对象的指针,但我们无法确定这个对象的类型。
如果不存在标签晶格,或者绘图窗口不可见且show按钮未被按下,该方法就会返回。否则,绘图窗口的内容会被从头重新计算。为此,定义了一个称为 count 的数组的动态数组。这个数组为标签晶格的每种材料、每张切片各提供一个计数器。初始时,所有计数器都被设为零。之后,在一个嵌套for循环中遍历各个体素时,它们被递增。
绘图窗口的实际初始化随后进行。首先,通过调用 plot->remData 移除旧曲线。然后,对每种材料通过调用 plot->putData 添加一条新曲线。之后,调用 plot->update。如果我们没有在构造函数中禁用”自动更新”,绘图窗口在每次调用 putData 时都会被自动更新。putData 方法创建一条具有给定名称的曲线并设置数值。如果给定名称的曲线已经存在,旧的值会被覆写。该方法返回一个指向该曲线的指针,这个指针又可以用来单独设置该曲线的属性(见下文)。最后,绘图窗口被弹出,我们先前激活的”忙”光标被再次关闭。
为了测试这个模块,首先编译示例包。说明请参见第15.1.5节(编译与调试)。然后从Avizo根目录加载 data/tutorials/chocolate-bar-labels.am。把 PlotAreaPerSlice 附加到它上面并按下show按钮。你应当得到一个类似于图15.12所示的结果。
图 15.12:由示例模块 PlotAreaPerSlice 产生的图表。
15.4.3.2 Plot API的更多特性
由 putData 调用返回的”曲线指针”对象可以用来直接访问该曲线,即操作其属性。曲线对象最重要的属性有:
- 颜色,由介于0和1之间的RGB值表示。可以通过调用下面的方法设置:
curve->setAttr("color", r, g, b); - 线宽,由一个整数表示。可以通过调用下面的方法设置:
curve->setAttr("linewidth", linewidth); - 线型,由一个整数表示。可用的线型为0=无线条、1=实线、2=虚线、3=点划线、4=点线。可以通过调用下面的方法设置:
curve->setAttr("linetype", type); - 曲线类型,由一个整数表示。可用的曲线类型为0=折线、1=直方图、2=带标记的折线、3=标记。可以通过调用下面的方法设置:
curve->setAttr("curvetype", type);
对于每个属性,都有相应的 getAttr 方法可用。为了访问”easy plot”窗口的坐标轴,你必须调用
1 | PzAxis* axis = plot->getTheAxis(); |
不要忘记包含相应的头文件 PzAxis.h。
曲线的颜色、线宽和线型属性同样适用于坐标轴。除此之外,还有一些方法可以改变坐标轴的外观:
1 | // Set the range of the axes |
如果你对绘图窗口的大小不满意,又不想每次都用鼠标去改变它,只需在创建绘图窗口之后立即调用 setSize:
1 | plot->setSize(width, height); |
正如你所预期的,方法 getMinMax、getAxisLabel 和 getSize 也可用,其参数列表与对应的 set 方法相同。
最后,还可以在图表中拥有图例或网格。在这种情况下,必须在 PzEasyPlot 的构造函数中指定更多参数:
1 | int withLegend = 1; |
与坐标轴一样,图例和网格在内部由 PzLegend 和 PzGrid 类型的独立对象表示。你可以通过调用方法 getTheLegend 和 getTheGrid 来访问这些对象。关于这些对象的成员方法的详情,列在类参考文档中。
15.4.4 GPU上的计算模块
Avizo包含了在CUDA中执行GPU编程所必需的一切。遗憾的是,为了开发具有GPU计算能力的新模块,需要安装一个专门的工具包。为了遵循本指南接下来的步骤,安装CUDA工具包是必需的。请注意,本教程仅支持Windows。
最新的CUDA工具包可以从以下URL下载:CUDA Toolkit Download page。不过,下载Avizo所使用的版本更为可靠。该版本可在此处找到:CUDA 6.5
下载完成后,运行该工具包的安装说明。安装程序通常会提供CUDA工具包、CUDA示例以及CUDA所需的最低驱动程序。请仔细遵循安装程序中给出的说明,并检查CUDA在系统上是否正常工作。
安装成功完成后,必须设置一个环境变量,以便Avizo知道到哪里去找CUDA工具包。这个环境变量必须命名为 CUDA_PATH,并包含CUDA工具包的路径(安装过程中已验证的位置)。
为了进行CUDA编程,你必须拥有一块兼容CUDA的GPU,并配备最新的驱动程序。
不过,GPU编程是复杂的,可能会很棘手。本教程并不打算成为一份GPU编程教程。本教程的目的是演示如何在Avizo内部对接一个GPU程序,并且要求具备GPU编程领域的最基本知识。
为了学习如何使用GPU编程实现一个模块,我们来看一个具体示例。具体来说,我们想编写一个模块,对一幅3D图像(即一个 HxUniformScalarField3 类型的输入对象)应用2D高斯滤波器。该模块产生另一幅3D图像作为输出,并被放入项目视图中。
我们向你展示一个实现:
- 版本1:一个使用CUDA C的版本
你可以在Avizo XPand扩展所提供的示例包中,即本地Avizo目录下的 src/gaussianfiltercudac 中,找到这两个版本的源代码。对于每个版本,都有三个文件:一个头文件、一个CPU源代码文件和一个GPU源代码文件。由于名称不同,你可以并行地编译和执行这两个版本。
这些模块给出了在Avizo中集成GPU滤波器的方法。为了具有教学意义,它们尽可能地简单,因此无法处理大量数据。
一旦你编译了示例包,就可以从Avizo的 data/tutorials 目录加载文件 motor.am 并把该模块附加到它上面。这些模块可以在数据弹出菜单的 Local 类别中找到。编译本地包的说明见第15.1.5节(编译与调试)。如果你在附加或执行该模块时遇到任何错误(例如:一个抱怨CUDA版本的对话框,或者阻止某个库被加载的未知符号……),请更新你的显卡驱动程序。
对于每个模块,你可以用TCL命令 time 来评估性能。对于 GaussianFilterCudaC 模块,勾选 auto-refresh 复选框并在Avizo控制台中执行以下命令:
1 | time {GaussianFilterCudaC fire} 5 |
它会对你的数据应用5次滤波器,并打印出每次迭代所需的平均时间量(以微秒计)。
为了创建一个新的本地Avizo目录,请遵循第15.2.2节中给出的说明。为了编译示例包,请参阅第15.1.5节(编译与调试)。
当把一个使用CUDA API调用的计算模块添加到一个已有的包中时,必须根据所使用的API来补全 Package 文件的 LIBS 条目。要添加到库列表中的关键字是:
- CUDA C API:
cudac;
例如,如果目标API是CUDA C,则 Package 文件应当包含类似下面这样的内容:
1 | set LIBS { |
15.4.4.1 版本1:CUDA C中的高斯滤波器
目前支持编写CUDA程序的主要接口是CUDA C。CUDA驱动API在Avizo中未被暴露。
CUDA C把CUDA编程模型作为对C语言的一组最小扩展来暴露。这些扩展允许程序员把一个内核(kernel)定义为一个C函数,并在每次调用该函数时使用一些新语法来指定网格和块的维度。第一个滤波器是在这个层次上实现的。
源代码分为两个独立的部分:
- 一个 CPU部分,包含一个头文件和一个C++文件:
GaussianFilterCudaC.h和GaussianFilterCudaC.cpp - 一个 GPU部分,包含一个CUDA文件:
Convolve2DCudaC.cu
为了具有教学意义,这个版本并不是该滤波器的最优实现。GaussianFilterCudaCOptim 模块是这个高斯滤波器的另一个CUDA C实现,具有更好的性能。这个实现在本文档中没有详述,但它实现在三个文件中,分别称为 GaussianFilterCudaCOptimized.h、GaussianFilterCudaCOptimized.cpp 和 Convolve2DCudaCOptimized.cu。
15.4.4.1.1 CPU部分 与大多数其他模块一样,我们的计算模块由一个包含类声明的头文件以及一个包含实际代码(即类定义)的源文件组成。我们先来看头文件 GaussianFilterCudaC.h:
1 | ///////////////////////////////////////////////////////////// |
与C++代码中的惯例一样,文件开头有一个define语句,用于防止文件内容被多次包含。然后包含了两个头文件。HxCompModule.h 包含了我们这个计算模块的基类的定义。另一个文件 HxPortDoIt.h 允许使用 Apply 按钮。
包头文件 api.h 被包含进来。这个文件为Windows系统提供导入和导出的存储类说明符。这些被编码在宏 GAUSSIANFILTERCUDAC_API 中。一个声明时不带这个宏的类,在其所定义的DLL之外将无法访问。在Unix系统上,这个宏是空的,可以省略。
applyFilter 函数用 extern "C" 限定符声明。这个函数定义在 Convolve2DCudaC.cu 文件中,将在 GPU部分 中说明。
在头文件的其余部分,仅剩的任务是从 HxCompModule 派生一个新类,并定义一个成员函数,即名为 compute 的重载虚方法。当模块被创建时,以及每当该模块的某个输入数据对象或端口发生状态变化时,compute 方法就会被调用。
相应的源文件如下所示:
1 | ///////////////////////////////////////////////////////////// |
在include语句之后,是类初始化所需的 HX_INIT_CLASS 宏。接下来是构造函数定义,它调用基类的构造函数。为此没有特殊的宏,因此使用通常的C++语法来调用基类构造函数并初始化类成员。基类 HxCompModule 的构造函数接受该模块可以连接到的输入数据对象的类类型。
我们必须实现的第二个方法是 compute 方法。我们首先通过一个名为 portData 的成员取得指向输入数据对象的指针。这个端口继承自基类 HxModule,也就是说,每个模块都有这个成员。该端口的类型为 HxConnection,在用户界面中表示为一条蓝线(如果已连接)。这个滤波器只接受 unsigned char 图像,因此我们必须检查数据类型。
要创建一个新的结果对象。之后每当range端口被改变时,已有的结果对象应当被覆写。output 文件的类型为 HxUniformScalarField3。getResult 方法检查是否存在某个数据集,其master端口连接到该计算模块。然而,它也可能是任何其他对象。因此,必须通过调用输出对象的 isOfType 成员方法来执行一次运行时类型检查。如果输出对象不是 HxUniformScalarField3 类型,变量output就会被置为空。然后再检查输出对象是否与输入对象具有相同的维度和相同的基本数据类型。如果这个测试失败,output同样会被置为空。最后,只有在结果尚不存在、或者已有结果与输入不匹配时,才会创建新的结果对象。
然后定义高斯滤波器并调用 applyFilter 函数。对 input->lattice.dataPtr() 和 output->lattice.dataPtr() 的调用允许我们获得指向 input 和 output 对象的实际数据值的指针。这个 dataPtr 方法的返回值类型为 void*。它必须被显式地强制转换为该场实际所属的数据类型。体素值本身是不带任何填充地排列的。这意味着体素 (i, j, k) 的索引为 ( k * dims[1] + j ) * dims[0] + i,其中 dims[0] 和 dims[1] 分别表示x和y方向上的体素数量。
在设备端计算之后,调用 cleanupNoFailure 函数,以便在出现CUDA错误时正确地清理设备内存。
最后,使用new运算符创建一个新的数据对象并不会自动让它出现在项目视图中。相反,我们必须显式地注册它。在计算模块中,这可以通过调用方法 setResult 来完成。如果该数据对象尚未存在于项目视图中,这个方法会把它加入项目视图。此外,它还把该对象的master端口连接到计算模块本身。与任何其他连接一样,这条链接在项目视图中会用一条蓝线表示。数据对象的master端口可以连接到一个计算模块或一个编辑器。这样的master连接表明该数据对象受某个”上游”组件控制,也就是说,它的内容可能会被它所连接到的那个对象覆写。
15.4.4.1.2 GPU部分 在GPU编程中,知道某个变量位于内存中的什么位置(具体来说是哪一段内存)是很重要的。为了更容易地跟踪这一点,CUDA文件中的变量名会加上一个标识其内存位置的前缀:
h_如果该变量位于主机内存中d_如果该变量位于设备全局内存中c_如果该变量位于设备常量内存中s_如果该变量位于设备共享内存中
CUDA文件 Convolve2DCudaC.cu 如下所示:
1 | ///////////////////////////////////////////////////////////// |
这个文件以两个全局变量的定义开始:BLOCK_SIZE 将用于确定网格和块的大小,以及 BORDER_SIZE。然后,声明了两个带 __constant__ 限定符的变量,它们将被放置在常量内存中。常量内存中的变量必须由CPU初始化,并且对所有线程以只读方式可见。c_filter 将包含卷积滤波器,c_dims 变量则包含数据体的大小。
在这些声明之后,卷积乘积在 convolve2D 函数中定义。这是一个 __device__ 函数,即它必须从设备端调用并在设备上执行。这个函数用一个 $3 \times 3$ 滤波器计算点 $(i, j)$ 处的2D卷积乘积。
getValue 函数返回某个缓冲区的体素值 $(i, j)$。
applyFilterKernel 函数会对每个体素被调用。它使用 __global__ 限定符定义。这意味着该函数从主机端调用并在设备上执行。它是一个 kernel 函数,因此必须具有 void 返回类型。
初始的3D图像被划分为若干个大小为 BLOCK_SIZE * BLOCK_SIZE 的块。声明了三个索引 i、j 和 k,用于定位 d_src 中当前切片的左上角,以及定义 d_slice。
创建 s_imageBlock 缓冲区并把它放置在共享内存中。然后填充这个缓冲区,它表示初始图像的一部分。请注意,这个缓冲区的大小为 (BLOCK_SIZE + 2) * (BLOCK_SIZE + 2),以便正确地处理边界(图像周围留出1个尺寸的边界,因为卷积滤波器是一个 $3 \times 3$ 滤波器)。
两个索引 iBlock 和 jBlock 用于填充 s_imageBlock 缓冲区。
在使用 s_imageBlock 进行计算之前,我们必须确保这个缓冲区已被填满,因此我们使用 __syncthreads() 函数。这个函数充当一道屏障,块中的所有线程都必须在此等待,然后才允许任何线程继续执行。之后,每个线程使用 imageBlock 计算一个卷积乘积。
这个文件中的最后一个函数是 applyFilter 函数,它处理CPU和GPU之间的交互。
为了管理CUDA错误,我们首先调用 cudaGetLastError 函数,它返回同一主机线程中任何运行时调用所产生的最后一个错误,并把它重置为 CUDA_SUCCESS。声明了若干指针,以便在GPU内存中分配。它们使用 cudaMalloc 函数在全局内存中分配。cudaMemcpy 函数用于从主机内存初始化 d_src 缓冲区。传输的方向由 cudaMemcpyHostToDevice 标志指明。
d_filter(它在本文件开头被声明)位于常量内存中,因此它用 cudaMemcpyToSymbol 函数初始化。
主机在CUDA设备上启动内核函数 applyFilterKernel 的执行。一个CUDA设备包含若干处理单元,每个处理单元都可以执行一个线程。若干处理单元被组织在一起形成一个块(block),而一组块构成一个网格(grid)。网格和块的维度定义在变量 nThreadsPerBlock 和 nBlocks 中。块的数量和每个块中线程的数量在内核名称之后的 <<<...>>> 之间指明。这些信息由Nvidia编译器 nvcc 获取,并在生成在CUDA设备上启动该内核的指令时使用。在此之后,一个参数列表被传递给 kernel 函数。
applyFilterKernel 函数填充 d_dst 缓冲区。计算完成后,我们必须把这个缓冲区复制到主机内存中。这是使用带 cudaMemcpyDeviceToHost 标志的 cudaMemcpy 函数完成的。
最后,使用 cudaFree 函数释放全局内存,并通过调用 cudaThreadExit 函数清理与调用主机线程相关的所有运行时资源。最后一个CUDA错误使用 cudaGetLastError 函数返回。
15.5 数据类
本节提供了Avizo数据类结构的概览。重要的类会被更详细地讨论。具体来说,将涵盖以下主题:
- 数据类导论,包括数据类的层次结构
- 规则网格上的数据,例如具有均匀或堆叠坐标的3D图像
- 四面体网格,包括定义在这类网格上的数据场
- 六面体网格,包括定义在这类网格上的数据场
- 非结构化混合模型,包括定义在这类模型上的数据场
- 与数据类相关的其他议题,包括透明数据访问
15.5.1 引言
深入了解Avizo数据对象对开发者来说至关重要。数据对象作为输入出现在写入例程和几乎所有模块中,作为输出出现在读取例程和计算模块中。在前面几节中,我们已经遇到了若干Avizo数据对象的示例,例如3D图像数据(由类 HxUniformScalarField3 表示)、三角曲面(由类 HxSurface 表示)或颜色映射表(由类 HxColormap 表示)。与模块一样,数据对象是C++类的实例。所有数据对象都派生自公共基类 HxData。数据对象在Avizo的项目视图中用绿色图标表示。
下面,让我们先给出 数据类层次结构 的概览。之后,我们将讨论其背后的一些 一般概念。
15.5.1.1 数据类的层次结构
Avizo数据类的层次结构大致如下所示(派生类以缩进表示,辅助基类被忽略):
1 | HxData 所有数据对象的基类 |
请注意,你可以在在线参考文档中找到每一个类的深入描述:Avizo根目录下的 share/devrefAvizo/Avizo.chm 或 share/devrefAvizo/index.html。参考文档不仅涵盖数据对象,而且涵盖Avizo XPand扩展所提供的全部类。正如你已经知道的,这些类被组织在包中。例如,所有从 HxField3 派生的数据类都位于包 hxfield 中,而所有与三角曲面相关的类都位于包 hxsurface 中。
15.5.1.2 关于类层次结构的说明
所有数据类都派生自基类 HxData。这个类本身又派生自 HxObject——所有可以放入Avizo项目视图的对象的基类。类 HxData 增加了对读写数据对象的支持,并提供了 HxParamBundle 类型的变量 parameters。这个变量可以用来以任意数量的嵌套参数对数据对象进行标注。任何数据对象的参数都可以使用用户手册中描述的参数编辑器进行交互式编辑。
我们注意到大多数数据类都派生自 HxSpatialData。这是所有嵌入在3D空间中的数据对象(与颜色映射表这类对象相对)的基类。HxSpatialData 增加了对用户定义的仿射变换(即平移、旋转和缩放)的支持。详情参见第15.6.2节。它还提供了虚方法 getBoundingBox,该方法被所有派生类重定义。HxSpatialData 的两个重要子类是 HxField3 和 HxVertexSet。
HxVertexSet 是所有定义在3D空间中一组非结构化顶点上的数据对象的基类,例如曲面或四面体网格。该类提供了对该对象的所有顶点应用用户定义仿射变换、或以某种方式修改点坐标的方法。
HxField3 是定义在3D域上的数据场(如3D标量场或3D矢量场)的基类。HxField3 定义了一个高效的过程式接口,用于在域内任意3D点处求取该场的值,而与后者是规则网格、四面体网格还是别的什么无关。这个过程式接口在第15.5.6.1节中有更详细的描述。
再来看继承层次结构,我们注意到在返回不同数量数据值的场之间做了一个高层次的区分。例如,所有3D标量场都派生自公共基类 HxScalarField3,而所有3D矢量场都派生自公共基类 HxVectorField3。采用这种结构的原因在于,许多模块只依赖于某个场的数据维数,而不依赖于数据的内部表示。例如,一个通过粒子追踪来可视化流场的模块可以被编写成接受任何 HxVectorField3 类型的对象作为输入。然后它就会自动作用于所有派生的矢量场,而无论它们所定义于的网格类型如何。
另一方面,把某个场的数据变量数量当作一个动态量来处理、并区分场所定义于的网格类型,往往也是有用的。例如,我们可能希望有一个定义在规则网格上的场的公共基类,以及用于规则标量场或矢量场的派生类。由于这种结构与上面勾勒的那种结构很难被纳入一个共同的类图,除非使用多重继承,Avizo中选择了另一个概念,即 接口(interfaces)。接口最初由Java编程语言引入。它们允许程序员利用那些并非通过继承相关联的类的共同属性。
在Avizo中,接口可以作为类成员来实现,也可以作为额外的基类来实现。在第一种情况下,一个数据类 包含 一个接口类;而在第二种情况下,它 派生自 HxInterface。重要的接口类有 HxLattice3、HxTetraData 和 HxHexaData,它们分别是定义在规则网格、四面体网格和六面体网格上的场的成员。另一个例子是 HxLabelLattice3,它是 HxUniformLabelField3 以及 HxStackedLabelField3 的成员。在第15.4.3.1节中,我们已经给出了一个示例,说明如何使用这个接口来编写一个作用于任何标签图像、而不论实际坐标类型如何的模块。
15.5.2 规则网格上的数据
定义在规则网格上的场出现在许多不同的应用中。例如,3D图像体就属于这一类。术语”规则”意味着网格的节点排列成一个规则的3D数组,即每个节点都可以由一个索引三元组 (i,j,k) 来寻址。一个规则场可以由三个主要属性来刻画:坐标类型、数据分量的数量,以及基本分量数据类型(例如 short 或 float)。
在 类层次结构 中,对某个场的数据分量数量做了一个主要区分。例如,有一个类 HxRegScalarField3 表示定义在规则网格上的(单分量)标量场。这个类派生自一般基类 HxScalarField3。对于定义在规则网格上的(三分量)矢量场、复标量场和复矢量场,存在类似的类。不属于这些类别之一的场,即定义在规则网格上但具有不同数据分量数量的场,由类 HxRegField3 表示,它直接派生自 HxField3。此外,对于数据分量数量与坐标的最相关组合,还有单独的子类,例如 HxStackedScalarField3 或 HxUniformVectorField3。所有规则数据类都提供一个 HxLattice3 类型的成员变量 lattice。这个变量是一个 接口。它可以用来以一种透明的方式访问具有不同分量数量的数据场。
下面会更详细地讨论 lattice接口。然后我们给出所有受支持 坐标类型 的概览。之后,讨论定义在规则坐标上的另外两类数据场,即 标签图像 和 颜色场。
请注意,所有这些场都可以借助Avizo的3D场过程式接口,在不考虑实际坐标类型或基本数据类型的情况下被求值(见第15.5.6.1节)。
15.5.2.1 Lattice接口
任何规则3D场的实际数据都存储在一个 HxLattice3 类型的成员变量 lattice 中。这个变量本质上表示一个n分量向量的动态3D数组。向量分量的数量以及基本数据类型都是可以改变的,也就是说,一个 HxLattice3 类型的数据对象可以被重新初始化,以容纳不同数量的、不同基本数据类型的分量。然而,包含在一个 HxRegScalarField3 类型对象中的lattice总是由1分量向量构成,而包含在一个 HxRegVectorField3 类型对象中的lattice总是由3分量向量构成。此外,该场的坐标存储在一个同样由lattice引用的独立坐标对象中。
访问数据 为了了解lattice类提供了哪些方法,请参阅在线参考文档,或直接查看位于包 hxfield 中的头文件 HxLattice3.h。在此,我们只给出一个简短的示例,说明如何查询lattice的维数、数据分量的数量以及基本数据类型。基本数据类型由定义在包 mclib 中的类 McPrimType 编码。具体来说,以下是Avizo所支持的一些数据类型:
McPrimType::mc_uint8(8位无符号字节)McPrimType::mc_int16(16位有符号短整型)McPrimType::mc_uint16(16位无符号短整型)McPrimType::mc_int32(32位有符号整型)McPrimType::mc_float(32位浮点型)McPrimType::mc_double(64位双精度型)- ……
无论lattice数据值的实际类型如何,指向数据数组的指针都以 void* 返回。返回值必须被显式地强制转换为正确类型的指针。下面这个示例说明了这一点,其中我们计算一个lattice的所有数据分量的最大值。请注意,数据值是一个接一个存储的,不带任何填充。第一个索引变化最快。
1 | HxLattice3& lattice = field->lattice(); |
作为一个提示,请注意:不同基本数据类型的处理往往可以通过在局部定义恰当的模板函数来简化。在我们这个示例的情况下,这样一个模板函数可能如下所示:
1 | template<class T> |
使用这个模板函数,上面的switch语句就变成如下形式:
1 | switch (lattice.primType()) { |
尽管效率较低,处理不同基本数据类型的另一种可能性是使用 eval、set、getData 或 putData 这些方法之一。如果该场的基本数据类型有此需要,这些方法总是会涉及一次到 float 的强制转换。
访问Lattice接口 设想你想编写一个作用于任何种类规则场(即 HxRegScalarField3、HxRegVectorField3 等类型的对象)的模块。实现这一点的一种方式是配置该模块的输入端口,使其可以连接到所有可能的规则场输入对象。这可以通过在该模块的构造函数中用所需的类类型ID多次调用方法 portData.addType() 来完成。此外,所有输入类型都必须列在包资源文件中。这可以通过把一个以空格分隔的类型列表指定为 module 命令的 -primary 选项的参数来完成。在该模块的compute方法中,必须先查询输入的实际类型,然后必须把输入指针强制转换为所需的类型,之后才能存储指向该对象lattice成员的指针。
当然,这种做法非常繁琐。一种简单得多的做法是利用这样一个事实:规则场的lattice成员是一个接口。可以使用 HxLattice3 的类类型ID,而不是某个真实数据类的名称,来指定一个模块可以连接到什么种类的输入对象。事实上,如果这样做,任何提供lattice接口的数据对象都将被视为一个有效的输入。为了访问输入对象的lattice接口,必须在该模块的compute方法中使用如下语句(关于如何处理接口的示例,也请查看第15.4.3.1节):
1 | HxLattice3* lattice = (HxLattice3*) |
从一个已有的Lattice创建场 在使用lattice时,我们可能想在项目视图中放入一个新的lattice,例如作为一个计算模块的结果。然而,由于 HxLattice3 不是一个Avizo数据类,这是不可能的。相反,我们必须创建一个合适的场对象,使该lattice成为它的一个成员。为此,类 HxLattice3 提供了一个静态方法 create,它创建一个规则场并把一个已有的lattice放入其中。如果该lattice包含一个数据分量,就会创建一个标量场;如果它包含三个分量,就会创建一个矢量场,以此类推。所得的场随后可以被用作一个计算模块的结果。请注意,一旦某个lattice被放入了一个场对象中,就不允许再删除它。这个概念由下面的示例说明:
1 | HxLattice3* lattice = new HxLattice3(dims, nDataVar, |
15.5.2.2 规则坐标类型
目前规则场支持四种不同的坐标类型,即均匀坐标(uniform)、堆叠坐标(stacked)、直线坐标(rectilinear)和曲线坐标(curvilinear)。这些坐标类型借助枚举类型 HxCoordType 加以区分。坐标本身存储在一个 HxCoord3 类型的独立工具类中,该类由规则场的lattice成员引用。对于每种坐标类型,都有一个 HxCoord3 的对应子类。
正如引言中已经提到的,对于更重要的一些情形,存在专用于某种特定坐标类型的规则场的特殊子类。例如 HxStackedScalarField3(派生自 HxRegScalarField3)或 HxUniformVectorField3(派生自 HxRegVectorField3)。如果这样的特殊类不存在,就应当使用规则基类。在这种情况下,坐标类型必须被动态地检查,并且指向坐标对象的指针必须被显式地向下强制转换之后才能使用。下面的示例说明了这一点:
1 | HxCoord3* coord = field->lattice.coords(); |
均匀坐标 均匀坐标是规则坐标最简单的形式。所有网格单元都是轴对齐的且大小相等。为了计算某个特定网格节点的位置,只需知道每个方向上的单元数量以及该网格的边界框即可。
均匀坐标由类 HxUniformCoord3 表示。这个类提供了一个方法 bbox,它返回一个指向由六个float构成的数组的指针,这些float描述了该网格的边界框。这六个数字依次表示最小x值、最大x值、最小y值、最大y值、最小z值和最大z值。请注意,这些值指的是网格节点,即某个网格单元的角点或某个体素的中心。为了计算一个体素的宽度,你应当使用如下代码:
1 | const int* dims = uniformcoords->dims(); |
堆叠坐标 堆叠坐标用于描述一叠具有可变切片间距的均匀2D切片。它们由类 HxStackedCoord3 表示。这个类提供了一个方法 bboxXY,它返回一个指向由四个float构成的数组的指针,这些float描述了一张2D切片的边界框。此外,方法 coordZ 返回一个指向包含每张2D切片z坐标的数组的指针。
直线坐标 与均匀坐标或堆叠坐标一样,在直线坐标的情况下网格单元也与坐标轴对齐,但网格间距在每个方向上都可以逐单元变化。直线坐标由类 HxRectilinearCoord3 表示。这个类提供三个方法 coordX、coordY 和 coordZ,分别返回指向x坐标、y坐标和z坐标数组的指针。
曲线坐标 在曲线坐标的情况下,每个网格节点的位置都以一个float型3D向量的形式显式存储。单个网格单元不再需要是轴对齐的。一个2D曲线网格的示例如图15.13所示。
曲线坐标由类 HxCurvilinearCoord3 表示。这个类提供了一个方法 pos,可以用来查询由索引三元组 (i,j,k) 指明的某个网格节点的位置。或者,也可以通过调用方法 coords 获得一个指向坐标值的指针。坐标向量是一个接一个不带填充地存储的,索引i变化最快。下面是一个示例:
1 | const int* dims = curvilinearcoords->dims(); |
图 15.13:具有曲线坐标的2D网格示例。
15.5.2.3 标签图像与Label Lattice接口
标签场用于存储图像分割过程的结果。本质上,在每个体素处存储一个数字,指明该体素属于哪种材料。因此,标签图像可以被视为标量场。事实上,目前有两种不同类型的标签图像,一种用于均匀坐标(由派生自 HxUniformScalarField3 的类 HxUniformLabelField3 表示),另一种用于堆叠坐标(由派生自 HxStackedScalarField3 的类 HxStackedLabelField3 表示)。由于这两种类型并非派生自一个公共基类,因此提供了一个专用接口 HxLabelLattice3。事实上,这个接口本身又派生自 HxLattice3。它取代了普通规则场的标准lattice变量(见第15.5.2.1节)。
标签图像的基本数据类型可以是 McPrimType::mc_uint8、McPrimType::mc_uint16 或 McPrimType::mc_int32。除了标准lattice接口之外,label lattice接口还提供对该标签场的材料的访问。材料存储在底层数据对象的一个特殊参数子目录中。在讨论plot API时,我们已经遇到了一个如何解释标签图像材料的示例(见第15.4.3.1节)。请注意,每当引入一个新标签时,也应当在材料列表中放入一个新条目。已有的材料被标记为不能从材料列表中移除(那样会破坏标注)。为了移除过时的材料,请调用 HxLabelLattice3 的方法 removeEmptyMaterials。
除了标签之外,label lattice中还可以存储特殊的权重。这些权重用于在从分割结果重建3D曲面时达到亚体素精度。指向这些权重的指针可以通过调用label lattice的 getWeights 或 getWeights2 获得。关于 HxLabelLattice3 的更多细节,请参阅在线类文档。
15.5.2.4 颜色场
颜色场是又一种类型的规则场。它们由4分量的RGBA字节四元组构成,并由派生自 HxColorField3 的类 HxRegColorField3 表示。后一个类与 HxScalarField3 或 HxVectorField3 密切相关,参见第15.5.1.1节中给出的数据类继承关系概览。对于具有均匀坐标的颜色场,有一个特殊的子类 HxUniformColorField3。与任何其他规则场一样,颜色场提供一个成员 lattice,可以用来以透明的方式访问数据。
15.5.3 非结构化四面体数据
另一类重要的数据指的是定义在非结构化四面体网格上的场。这类网格常用于有限元模拟(FEM)中。在Avizo中,四面体网格和定义在这类网格上的数据场由两个不同的类或类组来实现,并且在用户界面中也用不同的图标加以区分。之所以要把网格和数据分开,是因为在同一网格上定义了许多场的情况下(这在实践中经常出现)无需复制该网格。
在接下来的两节中,我们先介绍 网格类 HxTetraGrid,然后再讨论相应的 场类 以及接口 HxTetraData。
15.5.3.1 四面体网格
Avizo中的四面体网格由类 HxTetraGrid 及其基类 Tetra Grid 实现。查看 Tetra Grid 的参考文档,我们可以看到一个四面体网格本质上由若干个动态数组构成,例如 points、tetras 或 materialIds。
points数组是网格中所包含的所有3D点的列表。单个点存储为一个McVec3<float>类型的元素。这个类与Open Inventor类SbVec3f具有相同的布局。因此,一个指向McVec3<float>的指针可以被强制转换为指向SbVec3f的指针,反之亦然。tetras数组描述实际的四面体。对于每个四面体,存储其四个点的索引以及它所包含的四个三角形的索引。点和三角形的编号如图15.14所示。具体来说,第四个点位于由前三个点定义的三角形之上。三角形编号 $i$ 位于点编号 $i$ 的对面。materialIds数组包含8位标签,为每个四面体指定一个”材料”标识符。例如,在由分割图像数据生成的四面体网格中,这被用来区分图像分割中对应于(3D)图像数据所表示的物理对象不同材料成分的不同区段。与标签图像或曲面的情形一样,可能的材料值集合存储在该网格数据对象的参数列表中。
图 15.14:具有正体积的四面体中点的编号(左)。相应三角形的编号(右)。
points、tetras 和 materialIds 这三个数组必须由”用户”提供。网格的三角形存储在一个额外的数组 triangles 中。这个数组可以通过调用成员方法 createTriangles2 自动构造。这个方法从头计算三角形,并设置 tetras 中定义的所有四面体的三角形索引。
triangles 数组还提供了一种访问相邻四面体的方式。在为每个三角形存储的信息中(见参考文档),有它所属的两个四面体的索引。在边界三角形的情况下,其中一个索引为-1。因此,为了获得某个相邻四面体的索引,你可以使用如下代码:
1 | // Find tetra adjacent to tetra n at face 0: |
请注意,可以定义一个带有重复顶点(即具有完全相同坐标的顶点)的网格。这对于表示不连续的数据场很有用。方法 createTriangles2 会检查重复的节点,并在两个几何上相邻的四面体之间正确地只创建一个三角形,即使这些四面体引用的是重复的点。
作为可选项,除了网格的点、三角形和四面体之外,还可以通过调用 createEdges 来计算网格的边。这些边存储在一个称为 edges 的数组中,另一个数组 edgesPerTetra 则用于存储一个四面体六条边的索引。
此外,类 Tetra Grid 还提供了额外的可选数组,例如用于存储与某个特定点相邻的所有四面体索引的动态列表(tetrasPerPoint)。这些以及其他信息主要用于内部目的,例如便于四面体网格的编辑和平滑。
15.5.3.2 定义在四面体网格上的数据
在大多数应用中,你不仅要处理单个四面体网格,还要处理定义在其上的数据场,例如标量场(如温度)或矢量场(如流速)。Avizo为这些数据模态提供了特殊的类,即 HxTetraScalarField3、HxTetraVectorField3、HxTetraComplexScalarField3、HxTetraComplexVectorField3 和 HxTetraField3(见第15.5.1.1节中的类层次结构)。
与规则数据场的情形一样,实际信息存储在一个私有成员中,并可通过 tetraData() 方法访问,该方法的类型为 HxTetraData。与规则数据对应的成员类型 HxLattice3 一样,HxTetraData 是一个 接口,即派生自 HxInterface。tetraData() 方法提供对定义在四面体网格上的数据场的透明访问,而不论该场实际的数据分量数量如何。为了在一个模块内部不知道输入对象实际类型的情况下访问该接口,你可以使用如下语句:
1 | HxTetraData* data = (HxTetraData*) |
四面体网格上的数据必须始终为 float 类型。数据值可以以三种不同的方式存储,由 HxTetraData 中定义的编码类型指明:
PER_TETRA:为每个四面体存储一个数据向量。数据被假定在该四面体内部为常量。PER_VERTEX:为网格的每个顶点存储一个数据向量。数据在四面体内部被线性插值。PER_TETRA_VERTEX:为每个四面体存储四个独立的数据向量。数据同样被线性插值。PER_VERTEX_AND_EDGE:为网格的每个顶点和每条边存储一个数据向量。
最后这种编码方案对于建模不连续场很有用。为了以透明的方式在任意位置求取某个场的值,应当使用Avizo的过程式数据接口。这个接口在第15.5.6.1节中描述。
与 HxLattice3 一样,类 HxTetraData 提供了一个静态方法 create,可以用来从一个已有的 HxTetraData 实例创建一个相匹配的数据场,例如一个 HxTetraScalarField3 类型的对象。HxTetraData 对象不会被复制,而是被直接放入该场对象中。因此,之后不允许再删除它。另请参见第15.5.2.1节。
15.5.4 非结构化六面体数据
在非结构化六面体网格中,网格单元是通过指定该单元中的所有点来显式定义的。这与规则六面体网格形成对比,后者的网格单元排列成一个规则的3D数组,因此是隐式定义的。六面体网格的实现与上一节所描述的四面体网格非常相似。网格本身和定义在六面体网格上的数据场有各自独立的类。
在接下来的两节中,我们先介绍 网格类 HxHexaGrid,然后再讨论相应的 场类 以及接口 HxHexaData。
15.5.4.1 六面体网格
Avizo中的六面体网格由类 HxHexaGrid 及其基类 Hexa Grid 实现。查看 Hexa Grid 的参考文档,我们可以看到一个六面体网格本质上由若干个动态数组构成,例如 points、hexas 或 materialIds。
points数组是网格中所包含的所有3D点的列表。单个点存储为一个McVec3<float>类型的元素。这个类与Open Inventor类SbVec3f具有相同的布局。因此,一个指向McVec3<float>的指针可以被强制转换为指向SbVec3f的指针,反之亦然。hexas数组描述实际的六面体。对于每个六面体,存储其八个点的索引以及它所包含的六个面的索引。点的编号如图15.15所示。可以通过为相邻的点选择相同的索引来定义退化单元,例如棱柱或四面体。materialIds数组包含8位标签,为每个六面体指定一个材料标识符。与标签图像或曲面的情形一样,可能的材料标识符集合存储在该网格数据对象的参数列表中。
图 15.15:具有正体积的六面体中点的编号。
points、hexas 和 materialIds 这三个数组必须由用户提供。网格的面存储在一个额外的数组 faces 中。这个数组可以通过调用成员方法 createFaces 自动构造。这个方法从头计算各面,并设置 hexas 中定义的所有六面体的面索引。
请注意,与四面体网格不同,在六面体网格中允许退化单元,即一个单元中相邻角点重合的单元。通过这种方式,可以定义具有混合单元类型的网格。一个六面体的各面存储在一个称为 faces 的小型动态数组中。对于退化单元,这个数组包含少于六个面。
还请注意,尽管允许非协调网格(即在边和面上带有悬挂节点的网格),目前方法 createFaces 并不检测共享少于四个点的相邻六面体之间的连通性。因此,这类单元之间的面被视为外部面。
15.5.4.2 定义在六面体网格上的数据
在大多数应用中,你不仅要处理单个六面体网格,还要处理定义在其上的数据场,例如标量场(如温度)或矢量场(如流速)。Avizo为这些数据模态提供了特殊的类,即 HxHexaScalarField3、HxHexaVectorField3、HxHexaComplexScalarField3、HxHexaComplexVectorField3 和 HxHexaField3(见第15.5.1.1节中的类层次结构)。
与定义在四面体网格上的场一样,实际信息存储在一个私有成员中,并可通过 hexaData() 方法访问,该方法的类型为 HxHexaData。HxHexaData 是一个所谓的接口,即派生自 HxInterface。hexaData() 方法提供对定义在六面体网格上的数据场的透明访问,而不论该场实际具有多少个数据分量。为了在一个模块内部不知道输入对象实际类型的情况下访问该接口,你可以使用如下语句:
1 | HxHexaData* data = (HxHexaData*) |
六面体网格上的数据必须始终为 float 类型。数据值可以以三种不同的方式存储,由 HxHexaData 中定义的编码类型指明:
PER_HEXA:为每个六面体存储一个数据向量。数据被假定在该六面体内部为常量。PER_VERTEX:为网格的每个顶点存储一个数据向量。数据在六面体内部被三线性插值。PER_HEXA_VERTEX:为每个六面体存储八个独立的数据向量。数据同样被三线性插值。
最后这种编码方案对于建模不连续场很有用。为了以透明的方式在任意位置求取某个场的值,应当使用Avizo的过程式数据接口。这个接口在第15.5.6.1节中描述。
与 HxLattice3 一样,类 HxHexaData 提供了一个静态方法 create,可以用来从一个已有的 HxHexaData 实例创建一个相匹配的数据场,例如一个 HxHexaScalarField3 类型的对象。HxHexaData 对象不会被复制,而是被直接放入该场对象中。因此,之后不允许再删除它。另请参见第15.5.2.1节。
15.5.5 非结构化混合模型
大多数CAE和CFD模拟都在非结构化网格上进行。Avizo XWind扩展是包含Avizo Lite Edition功能集及其所有扩展的软件套件,用于分析、可视化和呈现来自CAE和CFD模拟的数值解。Avizo XWind扩展对由四面体、六面体、金字塔和楔形构成的非结构化混合网格有其自己的支持。
在接下来的两节中,我们先介绍 模型类 HxUnstructuredModel,然后再讨论相应的 场类 HxUnstructuredDataSet。
15.5.5.1 非结构化模型
非结构化模型仅在Avizo XWind扩展中可用,由类 HxUnstructuredModel 及其基类 Unstructured Model 实现。
一个模型包含:
- 所研究域的网格,其单元为2D或3D,取决于该模型的维数,
- 该域的边界,
- 该域可能由之构成的不同区域,
- 该域可能由之构成的不同材料。
取决于该模型的维数是2D还是3D,该模型的 Type 将为 VOLUME 或 SURFACE。因此,与该模型关联的网格应当是一个 HxUnstructuredVolumeMesh 或一个 HxUnstructuredSurfaceMesh。网格必须通过 setMesh 方法与该模型关联。
非结构化网格由其几何和其拓扑来刻画。几何(HxUnstructuredGeometry)包含网格节点的坐标,存储为 MbVec3d 类型的元素。拓扑(HxUnstructuredVolumeTopology 或 HxUnstructuredSurfaceTopology)包含网格单元,由其节点索引(如几何中所存储的那样)的列表定义。如果该模型是3D的,则单元是 HxVolumeCell 类型的元素。支持的单元类型有四面体(HxTetrahedronCell)、金字塔(HxPyramidCell)、楔形(HxWedgeCell)和六面体(HxHexahedronCell)。如果该模型是2D的,则单元是 HxSurfaceCell 类型的元素。支持的单元类型有三角形(HxTriangleCell)和四边形(HxQuadrangleCell)。
在3D情况下,该模型可能包含2D单元。这组单元称为 boundaries(边界),指的是CFD领域,但也可以很好地定义壳(shell)或膜(membrane)单元。HxUnstructuredSurfaceBoundary 继承自 HxUnstructuredSurfaceMesh,因此同样由其几何和拓扑定义。
如果该模型由若干个区域构成,这些区域可以用一个名称、一个索引和一种颜色来标识(见 HxModelParts),并通过 assignCellParts 方法指派给网格的每个单元。材料也是如此(HxCellMaterials、assignCellMaterials)。
15.5.5.2 定义在非结构化模型上的数据
定义在非结构化模型上的数据由类 HxUnstructuredModelDataSet 实现。场可以是 SCALARSET、VECTORSET、TENSORSET 或 SYMTENSORSET(对称张量集)类型。取决于类型,数据存储在 HxUnstructuredScalarSet、HxUnstructuredVectorSet、HxUnstructuredTensorSet 或 HxUnstructuredSymTensorSet 中,它们全都继承自 HxUnstructuredDataSet。数据必须通过 setDataSet 方法与模型数据集关联。
支持两种数据绑定:PER_NODE(数据场值存储在网格节点上)和 PER_CELL(数据场值存储在网格单元上)。使用 setBinding 方法设置 HxUnstructuredDataSet 的绑定。
在3D情况下,如果存在边界,数据可以以同样的方式存储,并使用 getBoundaries 方法与该模型数据集关联。这两种绑定在边界上都可用。
最后,把该模型数据集连接到它所定义于的非结构化模型上。
1 | HxUnstructuredModel* model = new HxUnstructuredModel; |
15.5.6 与数据类相关的其他议题
本节将涵盖以下主题:
- Avizo用于求取3D场的过程式接口
- 空间数据对象的 坐标系与变换
- 在数据对象中 定义参数和材料
15.5.6.1 3D场的过程式接口
一个数据场的内部表示在很大程度上取决于该场是定义在规则网格、四面体网格还是六面体网格上。甚至还有 HxAnnaScalarField3 或 HxAnnaVectorField3 这类数据类型,用于由解析数学表达式定义的场。为了让一个模块能够作用于任何标量场而不必费心去关注具体的数据表示,需要一个透明的接口。人们可能会想到一个像这样的函数:
1 | float value = field->evaluate(x,y,z); |
出于效率考虑,Avizo中使用了一个略有不同的接口。在任意位置求取一个定义在四面体网格上的场的值,通常涉及一次全局搜索以检测包含该点的四面体。对于其他网格类型,情况也类似。然而在大多数算法中,该场通常是在彼此相距不远的点上被求值的,例如在积分一条场线时。为了利用这一事实,引入了抽象 Location 类的概念。一个 Location 描述3D空间中的一个点。取决于底层网格,Location 可能会跟踪额外的信息,例如当前的网格单元编号。Location 类提供两种不同的搜索策略,一种全局的和一种局部的。通过这种方式,性能可以得到显著改善。下面是一个如何使用 Location 类的示例:
1 | float pos[3]; |
首先通过调用待求值场的虚方法 createLocation 创建一个location。两个方法 location->set(pos) 和 location->move(pos) 都接受一个由三个float构成的数组作为参数,它描述3D空间中的一个点。set 方法总是执行一次全局搜索以定位该点。相比之下,move 首先尝试从先前的位置出发、使用局部搜索策略来定位新的点。当新位置与先前位置只有轻微差别时,你应当调用 move。set 和 move 都可能返回0,以表明所请求的点无法被定位,即它不包含在任何网格单元中。
为了在某个特定位置求取该场的值,调用 field->eval( location, &value )。结果被写入第二个参数所指向的变量中。在内部,eval 方法做两件事。首先它对场值进行插值,例如使用当前点所在单元各角点上的值。其次,如果该场在内部由不同的基本数据类型表示,它会把结果转换为一个float值。
15.5.6.2 空间数据对象的变换
在Avizo中,所有嵌入在3D空间中的数据对象都派生自定义在子目录 hxcore 中的类 HxSpatialData(见第15.5.1.1节中的类层次结构)。一方面,这个类提供了一个虚方法 getBoundingBox,派生类应当重定义它。另一方面,它允许用户用一个任意的几何变换来变换该数据对象。该变换存储在一个Open Inventor SoTransform 节点中。这个节点会被自动应用到附加在一个已变换数据对象上的任何显示模块。
总共有三种不同的坐标系:
- 世界坐标系(world coordinate system)是3D查看器的相机所定义于的系统。
- 台坐标系(table coordinate system)通常与世界坐标系相同。然而,对于某些特殊的显示模块,例如显示放疗设备几何形状的模块,它可能有所不同。这些模块应当在其构造函数中以一个非零参数调用方法
HxBase::useWorldCoords。之后它们可以调用全局对象theController的方法HxController::setWorldToTableTransform。通过这种方式,它们可以使所有其他对象同时被变换。 - 最后,局部坐标系(local coordinate system)由为
HxSpatialData类型的对象所存储的变换节点定义。这个变换可以使用变换编辑器交互式地修改。变换可以使用方法HxBase::setControllingData在多个数据对象之间共享。通常,附加到某个数据对象的所有显示模块都会共享它的变换矩阵,从而当数据本身被变换时,这些模块所生成的几何体会被自动变换。
某个空间数据对象的变换节点可以使用 SoTransform* HxSpatialData::getTransform() 方法访问,当该数据对象未被变换时,该方法可能返回一个NULL指针。
通常改用 HxSpatialData::getTransform(SbMatrix& matrix) 更为容易,它返回当前的变换矩阵,或者在不存在变换时返回单位矩阵。这个矩阵的应用方式是:把它从右侧乘到一个向量上。它把向量从局部坐标系变换到台坐标系或世界坐标系。
如果你想把台坐标或世界坐标变换为局部坐标,请使用 HxSpatialData::getInverseTransform( SbMatrix& matrix)。例如,考虑下面这段代码,它把对象A的左下前角变换到第二个对象B的局部坐标系中:
1 | float bbox[6]; |
除了这种两步做法,两个矩阵也可以被合并:
1 | SbMatrix allInOne = matrixA; |
请注意,用下面的方式可以得到同样的结果:
1 | SbMatrix allInOne = inverseMatrixB; |
由于变换可能包含一个平移部分,在变换方向向量时应当特别注意。在这种情况下,应当使用方法 HxSpatialData::getTransformNoTranslation( SbMatrix& matrix)。
15.5.6.3 数据参数与材料
可以为每个数据对象定义任意数量的属性或参数。这些参数存储在一个 HxParamBundle 类型的成员变量 parameters 中。类 HxParamBundle 的头文件位于子目录 include/amiramesh 中。
HxParamBundle 派生自基类 HxParamBase。另一个派生自 HxParamBase 的类是 HxParameter。这个类用于实际存储一个参数值。参数值可以是一个字符串,或者是Avizo所支持的任何基本数据类型(byte、short、int、float或double)的n分量向量。束类 HxParamBundle 可以容纳任意数量的 HxParamBase 对象,即参数或其他束。通过这种方式,参数可以被层次化地组织起来。
许多数据对象,例如标签图像、曲面或非结构化有限元网格,都利用了 material(材料)列表的概念。材料参数存储在该对象参数束的一个特殊子束 Materials 中。为了访问这样一个对象的所有材料参数,可以使用下面的代码:
1 | HxParamBundle* materials = field->parameters.materials(); |
类 HxParamBundle 提供了若干个用于查找参数值的方法。如果所请求的参数无法找到,所有这些 find 方法都返回0。例如,为了取得一个称为 Transparency 的单分量浮点参数的值,可以使用下面的代码:
1 | float transparency = 0; |
为了添加一个新参数或覆写一个已有参数的值,你可以使用若干不同的 set 方法之一,例如:
1 | material->set("Transparency",transparency); |
许多模块会检查某种颜色是否与某个数据对象材料列表中的某个特定”材料”相关联。如果没有,就会在Avizo提供的全局材料数据库中查找该颜色或某个其他值。这个数据库由定义在 hxcore 中的类 HxMatDatabase 表示。它可以通过全局指针 theDatabase 访问。与普通数据对象一样,该数据库有一个 HxParamBundle 类型的成员变量 parameters,用于存储参数和材料。此外,它还提供了一些便捷方法,例如 getColor(const char* name),它返回某种材料的颜色;如果该材料尚未包含在数据库中,则定义一种新的颜色。
15.6 Avizo XPand扩展中模块的文档编写
Avizo XPand扩展允许用户为自己的模块编写文档,并把它集成到用户手册中。
文档必须以Avizo的原生文档风格编写。其语法借自 Latex 文本处理语言。文档文件可以很容易地用 createDocFile 命令创建。要为 MyModule 创建一个文档模板,在Avizo控制台中输入
1 | MyModule createDocFile |
这将在目录 AVIZO_LOCAL/src/mypackage/doc 中生成文档文件的模板以及所有端口的快照。
文件 MyModule.doc 已经提供了模块描述的骨架,并包含了端口快照。
命令 createPortSnaps 只创建模块端口的快照。当端口发生变化、其快照必须在用户手册中更新时,这很有用。
15.6.1 文档文件
这里给出文档文件的基本元素。
1 | \begin{hxmodule}{MyModule} |
这个文件描述一个模块的文档,并以下面这行开始
1 | \begin{hxmodule}{name} |
这是一个 hxmodule 描述。其他形式也可用:
1 | \begin{hxmodule2}{name}{short description to appear in the table of modules} |
该文件必须始终以相应的 end 命令闭合。
更多命令 其他一些格式允许对文档进行排版和结构化:
- ```latex
\begin{itemize}
\item This is an enumeration,
and each item starts with the key word item
\end{itemize}doc2html -A -skin AvizoSkin -d AVIZO_ROOT/share/doc/html -local AVIZO_LOCAL1
2
3
4
5
6
7
8
9
10
11
12
13
14- `{\bf This will be set in bold face}`
- `{\it This will be set in italics}`
- `{\tt This will be set in Courier}`
公式可以借助文本处理器 *LaTeX* 包含进来。它们必须用 *Latex* 语法编写。这要求用户的系统上安装了 *LaTeX* 和 *Ghostscript*。需要设置以下环境变量:
- `DOC2HTML_LATEX` 指向 *LaTeX* 可执行文件
- `DOC2HTML_DVIPS` 指向dvi到PostScript的转换器(*dvips*)
- `DOC2HTML_GS` 指向 *Ghostscript*
### 15.6.2 生成文档
所有文档文件都必须被转换为HTML文件并复制到用户手册中。为此,提供了程序 `doc2html`。在命令行shell中以下面的选项运行这个程序:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
这会转换文档,并把HTML文件复制到 `AVIZO_ROOT/share/doc/html` 目录中相应的位置。调用 "doc2html -help" 可以得到完整的选项列表。
## 15.7 杂项
本节涵盖若干对Avizo开发者有意义的额外议题。具体来说,包含以下主题:
- *时变数据的导入*,包括 `HxPortTime` 的使用
- *重要的全局对象*,例如 `theMsg` 和 `theProgress`
- *项目保存相关议题*,使保存Avizo项目对自定义模块也能工作
- *故障排查*,提供一份常见错误与解决方案的清单
### 15.7.1 时变数据与动画
本节涵盖Avizo XPand扩展的一些更高级的主题,即动态数据集的处理以及动画计算任务的实现。在阅读本节之前,你至少应当知道如何编写普通的IO例程和模块。
#### 15.7.1.1 时间序列控制模块
一般来说,处理时变数据集在3D可视化中是一项具有挑战性的任务。通常,由于主内存不足,一个动态数据序列的所有时间步无法一次性全部加载。即使所有时间步都能放入内存,把每个时间步都作为一个单独的对象加载到Avizo中通常也不是一个好主意。这会导致项目视图中出现大量图标。不同时间步之间的选择会变得困难。
一个更好的解决方案是采用专用的控制模块。一个例子是用户手册中描述的 *Time Series Control* 模块。当通过主窗口文件菜单的 *Load time series...* 选项导入存储在单独文件中的一个时间序列时,就会创建这个模块。该控制模块不是把所有时间步一起加载,而是一次只加载一个时间步。当前时间步可以通过一个时间滑块来调整。当选择一个新的时间步时,与前一个时间步关联的数据对象会被替换。
如果你想支持一种多个时间步存储在同一个文件中的文件格式,你可以为该格式编写一个特殊的时间序列控制模块。对于每种格式,都需要一个特殊的控制模块,因为加载文件、在文件内部寻找某个特定时间步的方式当然对每种格式都不相同。为方便起见,你可以从包 `hxtime` 中所包含的类 `HxDynamicDataControl` 派生一个用于新格式的控制模块。这个基类提供了一个时间滑块和一个虚方法 `newTimeStep(int k)`,每当有一个新的时间步要被加载时它就会被调用。与标准时间序列控制模块不同,在大多数控制模块中,数据对象应当只被创建一次。如果选择了一个新的时间步,已有的对象应当被更新和复用,而不是用新对象替换它们。通过这种方式,就避免了断开和重新连接下游对象的负担。
#### 15.7.1.2 HxPortTime类
原则上,一个普通的浮点滑块(`HxPortFloatSlider`)可以用来调整一个时间序列控制模块或某个其他时变数据对象的时间。不过在许多情况下,包 `hxtime` 中定义的专用类 `HxPortTime` 更为合适。这个类可以像一个普通的浮点滑块那样使用,但它提供了许多额外的特性。最突出的一个是让滑块自动播放动画的可能性。此外,`HxPortTime` 可以连接到一个 *Time* 类型的全局对象。通过这种方式,多个时变模块可以被同步。为了创建一个全局 *Time* 对象,从主窗口的 *Project > Create Object...* 菜单中选择 *Animations And Scripts / Time*。
`HxPortTime` 的另一个特性是这个类同时也是一个接口,即它派生自 *HxInterface*(对比第15.5.1.2节)。通过这种方式,就可以编写能够连接到任何包含 `HxPortTime` 实例的对象的模块。一个例子是 *Display Time* 模块。为了访问某个源对象的时间端口,应当使用下面这个C++动态强制转换构造:
```cpp
HxPortTime* time = dynamic_cast<HxPortTime*>(
portData.source(HxPortTime::getClassTypeid()));
在上一节中,我们讨论了如何使用专用的控制模块来导入时变数据。另一种替代方案是从一个已有的静态数据对象派生出一个时变数据对象。一个例子是Avizo XPand扩展示例包中所包含的类 MyDynamicColormap。查看本地Avizo目录中的头文件 src/mypackage/MyDynamicColormap.h,你会注意到这个类本质上就是一个带有额外时间端口的普通颜色映射表。以下是类声明:
1 | class MYPACKAGE_API MyDynamicColormap: public HxColormap |
这个动态颜色映射表的实现也非常简单(见文件 MyDynamicColormap.cpp)。首先,在构造函数中初始化时间滑块:
1 | portTime.setMinMax(0,1); |
第一行表明滑块应当从0走到1。下一行中设置的增量定义了当滑块的后退或前进按钮被按下时,时间值应当改变多少。再下一行取消设置discrete(离散)标志。如果这个标志被打开,滑块值将始终是增量的整数倍。最后,设置所谓的实时因子。把这个因子设为非零值意味着在动画模式下滑块与物理时间相关联。更确切地说,自上次动画更新以来所经过的微秒数会乘以该实时因子,然后把结果加到当前时间值上。
为了看到该模块的实际效果,编译示例包,启动Avizo(如果你以debug模式编译,请使用 -debug 选项或debug可执行文件),并从主窗口的 Project > Create Object… 菜单中选择 Other / DynamicColormap。给该颜色映射表附加一个 Colormap Legend 模块,并改变该颜色映射表时间滑块的值。为滑块播放动画。动画的速度可以通过用Tcl命令 DynamicColormap time setRealTimeFactor 重设实时因子的值来调整。
15.7.1.3 通过超时方法实现动画
在某些情况下,你可能希望某些方法以固定间隔被调用,而不使用时间端口。有几种方式可以做到这一点。首先,你可以使用Open Inventor类 SbTimerSensor 或相关的类。另一种可能性是使用Qt类 QTimer。然而,这两种方法都有一个缺点:如果一次发出的定时器事件太多,应用程序可能会卡住。在某些情况下,甚至可能无法按下停止按钮或某个其他按钮来关闭用户定义的动画。因此,Avizo提供了它自己的注册超时方法的方式。
相关的方法由类 HxController 实现。假设你编写了一个模块,其中有一个名为 timeOut 的成员方法。如果你希望这个方法每秒自动被调用一次,你可以使用下面的语句:
1 | theController->addTimeOutMethod( |
为了再次停止动画,使用
1 | theController->removeTimeOutMethod( |
除了使用某个Avizo对象类的成员方法之外,你也可以使用类 HxController 的方法 addTimeOutFunction 注册一个任意的静态函数。相应的移除方法称为 removeTimeOutFunction。更多信息请参见 HxController 的参考文档。
Avizo XPand扩展示例包包含模块 MyAnimateColormap,它利用了上述超时机制。这个模块的源代码相当容易理解。编译示例包之后,你可以以 DoAnimate 这个名称把该模块附加到一个已有的颜色映射表上。这个颜色映射表随后会被修改和复制。按下该模块的animate开关之后,输出颜色映射表会以固定间隔自动移位。请注意,在这个示例中,该模块的 fire 方法被用作超时方法。fire 调用模块的 compute 方法,同时也更新所有下游对象。
15.7.2 重要的全局对象
除了模块和数据对象的基类之外,Avizo内核中还有一些对开发者来说很重要的类。这些类中的许多都恰好有一个全局实例。这里给出这些全局对象的简短概述。详情请查看Avizo根目录中的文件 share/devrefAvizo/index.html 或 share/devrefAvizo/Avizo.chm,参阅在线参考文档。
HxMessage: 这个类对应于屏幕右下部分的Avizo控制台窗口。这个类只有一个全局实例,可以通过 theMsg 访问。所有文本输出都应当发往这个对象。文本可以使用函数 theMsg->printf("...",...) 打印,它支持常见的C风格 printf 语法。HxMessage 还提供了用于弹出错误和警告消息或简单问题对话框的静态方法。
HxObjectPool: 这个类维护所有当前存在的数据对象和模块的列表。在图形用户界面中,这个区域称为项目视图,包含模块和数据对象的图标。这个类只有一个全局实例,可以通过指针 theObjectPool 访问。
HxProgressInterface: 这个类在属性区域中显示所选对象的端口,并提供进度条和忙状态功能。重要的函数有 startWorking、stopWorking、wasInterrupted 以及 busy 和 notBusy。这个类只有一个全局实例,可以通过指针 theProgress 访问。
HxFileDialog: 这个类表示用于加载和保存数据的文件浏览器。通常开发者不需要使用这个类,因为标准I/O机制已在Avizo内核中完整实现。不过,对于专用模块来说,一个单独的文件浏览器可能是有用的。这个类有一个全局实例,可以通过指针 theFileDialog 访问。
HxResource: 这个类维护包资源文件中所定义的所有已注册文件格式和模块的列表。它还提供关于Avizo根目录、本地Avizo目录、版本号等的信息。通常开发者没有必要直接使用这个类。这个类没有实例,因为它的所有成员都是静态的。
HxViewer: 这个类表示一个Avizo 3D查看器。可以有多个实例,通过全局对象 theController 的 viewer 方法访问。通常你不需要使用这个类。相反,你应当使用每个模块和数据对象都提供的、用于显示几何体的成员函数 showGeom 和 hideGeom。
HxController: 这个类控制所有3D查看器和Open Inventor几何体。为了访问一个查看器,你可以使用下面的语句:
1 | HxViewer* v0 = theController->viewer(0,0); |
第一个参数表明要获取的查看器的ID号。总共最多可以有16个不同的查看器。第二个参数指定如果该查看器尚不存在,是否应当创建它。
HxColorEditor: Avizo的颜色编辑器。例如用于定义查看器的背景色。在一个标准模块中,你应当使用像 HxPortColorList 或 HxPortColorMap 这样的端口,而不是直接访问颜色编辑器。这个类有一个全局实例,可以通过指针 theColorEditor 访问。
HxHTML: 一个用于显示HTML文件的窗口。这个类用于Avizo的在线帮助。用于显示在线用户手册和在线程序员手册的全局实例可以通过指针 theBrowser 访问。
HxMatDatabase: 这个类表示Avizo的全局数据参数与材料数据库。该数据库可以通过全局指针 theDatabase 访问。关于材料数据库的详情在第15.5.6.3节中讨论。
15.7.3 项目保存相关议题
本节描述Avizo中用于保存项目的机制。对于大多数模块而言,这对开发者是透明完成的。
菜单命令”Save Project”转储一段应当能够重建当前Avizo项目的Tcl脚本。本质上,这是通过为每个数据对象写出一条 load ... 命令、为每个模块写出一条 create ... 命令、以及为模块的每个端口写出 setValue ... 命令来完成的。
如果关于某个模块状态的全部信息都只保存在该模块的端口中,这就足以正确地重建Avizo项目。如果不是这种情况,例如开发者使用了对模块当前状态很重要的额外成员变量,那些值就不会被自动恢复。如果你无法避免这一点,你必须扩展你模块的”Save Project”功能。为此,你可以覆写虚函数 savePorts,让它写出额外的Tcl命令。例如,我们来看 HxArbitraryCut 类,它是(例如)Slice 模块的基类,并且必须保存其当前的切片朝向:
1 | void HxArbitraryCut::savePorts(FILE* fp) |
请注意,这个方法要求 HxArbitraryCut 或它的某个父类实现了Tcl命令 setPlane。关于实现新Tcl命令的提示见第15.4.2.2节。
关于如何为数据对象生成load命令的一些说明见第15.3.2节。
图 15.16:加载这个Avizo项目时,Resample 模块会即时重新创建 chocolate-bar.Resampled 数据对象。
对于由计算模块创建的数据对象,有一项特殊的优化。Avizo会自动判定由其他模块创建的数据对象是否尚未被保存,并在必要时询问用户是否要这样做。Avizo还提出了两种保存项目的策略:”Minimize project size”(最小化项目大小)和 “Minimize project computation”(最小化项目计算量)。如果用户选择 “Minimize project size” 作为策略,那么数据对象在保存项目时不会被保存,而会在加载项目时被计算出来;否则它们会在保存项目时被保存,而在加载项目时不再被计算。举例来说,考虑图15.16中的Avizo项目。在这种情况下,如果用户选择 “Minimize project size”,则当该Avizo项目被加载时,resample模块可以自动重新计算 chocolate-bar.Resampled 数据对象。必须有一个算法来判定该计算模块能否创建这个数据对象。
判定一个计算模块能否创建某个数据对象可能会很棘手。通常,必须确保在计算模块实际创建该数据对象与执行保存Avizo项目命令之间的这段时间内,参数和输入都没有改变,并且所得的数据对象未被编辑过。
这一行为完全由 HxCompModule 处理,而方法 HxCompModule::setResult 用于注册输出数据对象。
15.7.4 故障排查
本节描述一些经常出现的问题以及一些与Avizo开发相关的通用问题解决方法。
本节分为两部分:编译新包时可能出现的问题,以及执行它时可能出现的问题。
15.7.4.1 编译期问题
未知标识符、奇怪的错误: C++编程中一个非常常见的问题是遗漏了必要的include语句。在Avizo中,大多数类都有它们自己的、包含类声明的头文件(.h文件)。你必须为代码中使用的每个类都包含其类声明。当你得到你不理解的奇怪错误消息时,检查一下编译器所抱怨的那一行附近所用到的所有类是否都有相应的include语句。
未解析的符号: 如果链接器抱怨未解析的符号,你可能是在库路径行中缺少了某个库。Avizo开发向导确保Avizo内核库和重要的系统库被链接。如果你使用Avizo数据类,你将需要与相应的包库 hxfield、hxcolor、hxsurface 等链接。为了添加库,编辑 Package 文件,找到以 LIBS 开头的那一行,追加你想添加的包的名称,并用Avizo的开发向导重新生成构建系统文件。详情见第15.2.10节和第15.2.9节。
15.7.4.2 运行期问题
模块未出现在弹出菜单中: 如果你的模块确实编译通过了,但在相应数据对象的弹出菜单中不可见,那么很可能是资源文件有问题。资源文件会在编译期间从你包的 share/resources 目录被复制到你本地Avizo目录中的 share/resources 目录。每次修改资源文件后都要启动一次新的构建。
如果资源文件存在,下一步是检查它是否真的被解析了。向资源文件中添加一行 echo "hello mypackage"。验证Avizo启动时该消息是否出现在Avizo控制台中。如果没有,可能是本地Avizo目录设置不正确。用Avizo中的开发向导重设它。详情见第15.2.2节。
如果该文件被解析了,但模块仍然没有出现,那么rc文件条目的语法可能有误,或者你指定了错误的主数据类型,以致该模块出现在了另一个数据类的菜单中。
弹出菜单中有条目,但模块没有被创建: 很可能是共享库(.so或.dll文件)有问题。在Avizo控制台中输入 dso verbose 1 并再次尝试创建该模块。你会看到一些错误消息,表明dll未找到、或者它无法被加载(以及为什么)、或者某个符号缺失。检查你的构建模式(debug/optimize)和执行模式是否一致。特别地,如果你编译的是debug代码,你必须使用 -debug 命令行选项或debug可执行文件来启动Avizo(见第15.1.5节)。
读取或写入例程不工作: 处理这类问题的步骤是相同的。首先检查load函数是否已注册。然后,在保存相应数据对象时,验证你的保存文件格式是否出现在文件格式列表中。对于load方法,右键单击load文件对话框中的一个文件名。选择格式,检查你的格式是否出现在列表中。如果确实出现了,那么你可能有dll问题。按照上面的步骤操作。如果库可以被加载,但符号无法被找到,那么你的方法可能有错误的签名(错误的参数类型),或者在Windows上你可能忘记了 <PACKAGE>_API 宏。这个宏表明该例程要被DLL导出。
一般来说,如果你遇到 未解析 和/或 缺失符号 的问题,你应当查看一下你库中的符号。在Unix上,输入 nm lib/arch-*-Debug/libmypackage.so。在Windows上,在Visual Studio命令提示符中输入 dumpbin /exports bin/arch-*-Debug/mypackage.dll。
15.7.4.3 调试问题
设置断点不起作用: 由于Avizo使用共享库,单个包的代码在程序启动之后并不会被加载。因此,某些调试器会拒绝在这类包中设置断点,或者会禁用先前设置的断点。为了克服这个问题,先创建你的模块,然后再设置断点。如果你想调试某个模块的构造函数或某个读取或写入例程,这当然行不通。在这些情况下,通过在Avizo控制台中输入 dso open libmypackage.so(如果你的包名为mypackage)来手工加载该库。然后设置断点并创建你的模块或加载你的数据文件。
