第9章 ITK
原文链接:http://www.aosabook.org/en/itk.html
作者:Luis Ibanez 和 Brad King
译按:原文的二级和三级标题并无章节号,只有字号区别。
9.1 ITK是什么?
ITK,又名Insight Toolkit,是一种用于图像分析的库,它由美国国家医学图书馆(US National Libraray of Medicine)发起并资助开发的。ITK可以看作是一个方便使用的图像分析算法百科全书,特别是它包含了图像滤波、图像分割和图像配准。该库由一个大学和商业公司组成的联合组织、以及来自世界各地的代码提交者共同开发。ITK的开发工作始于1999年,在其最近的十周年纪念过后不久,全库经历了一次重构过程,这次重构旨在去除代码库中的顽固代码;并对其重塑,以适应下一个十年的发展。
9.2 架构特性
软件工具箱与他们的社区有一种密切的关系。他们以一个持续迭代的周期塑造彼此。软件被持续改进,直到它能满足社区的需要,与此同时,社区基于软件准许或者约束他们要怎样做来制约他们自身的行为。因此,为了更好地理解ITK架构的天性,了解ITK社区经常提出何种问题,以及他们如何着手解决这些问题,是非常有用的。
9.2.1 野兽的天性
如果你不了解野兽的天性,那这就对理解它们解剖结构的机制毫无用处。
-- Dee Hock, One from Many: VISA and the Rise of Chaordic Organization
一个典型的图像分析问题中,研究者或者工程师会取一个输入图像,通过降噪或是提高对比度来改善图像的某些特性,然后进行处理以辨别图像中的某些特征,比如拐角和强边缘。这种类型的处理很自然地符合一种数据管线架构,如图9.1所示。
图9.1:图像处理管线
为了说明这一点,图9.2展示了一幅人脑的核磁共振图像(Magnetic Resonance Imaging,MRI),以及使用中值滤波器对其进行降噪处理的结果、还有利用边缘检测滤波器来辨别其中解剖结构的结果。
图9.2:MRI脑部图像,中值滤波器,边缘检测滤波器
对于图中的每一项任务,图像处理社区都已经开发出了各种算法,并且继续开发新的算法。为什么他们继续做这些?你可能会问,答案就是图像处理是一种科学、工程、艺术、以及“烹饪”技术的组合。公然宣称某种算法组合对于一个图像处理任务来说是“对的”无异于类似宣布正餐上要备“对的”巧克力甜点一样的误导。不是追求完美,社区奋力制造出丰富的工具来确保在面对一项挑战性的图像处理任务时,不会出现可选项的短缺。当然,事情发展的状态是要付出代价的。代价就是图像分析人员有一个困难的任务,就是在从几十个不同工具中选择可用的不同组合,而这些组合可以得出类似的结果。
图像分析社区与研究社区联系紧密。某一个研究小组与某一个算法群相关联是寻常现象。“品牌命名”的风俗,以及某种程度的“市场化”,导致了一种这样的情况:软件工具箱可以尽可能好地为社区提供一个非常完整的算法实现集,然后将之混合并匹配,以创建一个满足社区需要的菜谱。
为什么ITK要被设计并实现成一个巨大的某种程度独立、且有条理的工具——图像滤波器——的原因有很多,多数滤波器用于解决类似的问题。在本文中,某种程度的“冗余”——打个比方,提供高斯滤波器的三种不同实现方法——这不应该被看做是问题,而应该是一种有价值的特性,因为不同的实现可以可交换地使用,以满足约束并且发掘与图像尺寸、处理器数量、以及可能与某个给定图像处理应用程序中的特定高斯核尺度相关的效率潜能。
该工具箱还被创建成一个成长的、不断更新自身的资源,因为新的算法和更好的实现出现了,取代了现有的;还因为为了应对不断涌现的新的医学影像处理技术而开发的新工具。
快速了解了ITK社区中的图像分析人员的每日例行公事,我们现在开始深入架构的主要特性:
- 模块化
- 数据管线
- 工厂
- IO工厂
- 流
- 可复用性
- 可维护性
9.2.2 模块化
模块化是ITK的主要特性之一。这个需求源于图像分析社区的人们解决问题时的工作方式。大多数图像分析问题将一幅或多幅输入图像放入处理滤波器的组合中,这些滤波器用于增强或是提取图像中的某些特定的信息片段。因此这中间就没有大的处理对象,而是许多小的。逻辑上讲,这种图像处理问题的结构性本质特征意味着要实现一个由许多图像处理滤波器组成的软件,这些滤波器就可以以不同的方式组合使用了。
将某些特定的处理滤波器聚合为一个家族也是如此,其中的某些实现上的特性可以被分解。这就导致图像滤波器自然分组为模块以及模块群。
至此,模块化存在于ITK中的三个自然层次上:
- 滤波器层次
- 滤波器家族层次
- 滤波器家族群层次
在图像滤波器层次上,ITK大约拥有700个滤波器。考虑到ITK是以C++实现的,这个层次中的每一个滤波器都是以C++类辅以面向对象的设计模式来实现的。在滤波器家族层次上,ITK根据滤波器进行处理时的方式将其分组。例如,所有与傅里叶变换有关的滤波器将会放入同一个模块。在C++层次上,模块映射于源代码文件树,并且映射于软件编译成二进制形式后的库文件。ITK拥有120个这种模块。每个模块包含:
- 属于该家族的图像滤波器的源代码。
- 一些描述该模块如何构建并列出该模块与其他模块之间依赖关系的配置文件。
- 对应于每个滤波器的一组单元测试。
图9.3:家族群、模块和类的层次结构
家族群层次是最为概念性的划分,它表述软件的顶层,有助于在源文件树中定位滤波器。家族群与高层次的概念相关联,例如:滤波、分割、配准、和IO。这种层次化结构如图9.3所示。ITK目前拥有124个模块,这些模块又聚合成了13个主要家族群。这些模块大小不一。这种大小的分布,在图9.4中以字节为单位表示:
图9.4:ITK中50个最大的模块的大小分布,单位:字节
ITK中的模块化也应用于其中的第三方库,这些库并不是工具箱的直接组成部分,但是工具箱依赖它们,因此将这些第三方库与工具箱中的其余代码一起发行,以方便使用者。尤其是图像文件格式库:HDF5、PNG、TIFF、JPEG、OpenJPEG等。这里强调第三方库是因为约占ITK总大小的56%。这一点反映了开源应用建立在现有平台之上的自然特征。第三方库的大小分布固然不能反映ITK的组织架构,因为我们采用了这些有用的库,仅仅是由于它们属于上游开发产物。然而, 第三方库的代码与工具箱一并发行、并且将之分割,是模块化过程的关键驱动因素之一。
这里给出模块大小的分布,因为它是一种代码合理模块化的量度。可以把代码的模块化看做是一个连续谱,分布于从只有一个巨大的、单体的模块的一端,到将代码分割成许多相等大小的模块的另一端。大小分布是一种工具,它用于显示模块化过程的进展,尤其是确保同一个模块中没有大块的代码,除非有真实的逻辑依赖关系需要这样的分组。
ITK的模块化架构使下面的事项成为可能或有助于它们实现:
- 减少和澄清交叉依赖关系
- 采用社区贡献的代码
- 评估各模块的质量指标(如:代码覆盖率)
- 构建工具箱的某个子集
- 将工具箱的某个子集打包用于再发行
- 通过添加新的模块来维持持续成长
模块化过程使显式地辨别并声明工具箱中不同部分的依赖关系成为可能,当然,这些不同的部分是要放在模块中的。在许多情况下,这种做法暴露了做作的以及不正确的依赖关系,随着时间的变化,这些依赖关系被引入工具箱,当大多数代码被放入一些大的家族群中的时候,这些依赖关系就会被忽视。
评估各模块的质量指标的用处是双重的。首先,它使开发者对其维护的模块负责变得容易。其次,它使得参与由若干开发者集中短期时间来提高某个特定模块的质量的清理行动成为可能。当集中精力于工具箱的一小部分的时候,它使我们更容易看到我们的努力、并且更容易地保持开发者的参与、受到激励。
对于重新迭代,我们发现工具箱架构反映了社区的组织,以及在有些情况下,被用于软件的持续成长和质量控制的过程。
9.2.3 数据管线
多数图像分析任务具有的阶段性特征很自然地导致我们选择了数据管线架构作为数据处理的基础设施。数据管线是下列成为可能:
- 滤波器串联:若干图像滤波器可以一个接一个的串联起来,组成一个处理链,它可以对输入图像进行一系列的操作。
- 参数探测:一旦处理链组合在一起,改变链中任何一个滤波器的参数就会很容易,并且可以探测改变参数会对最终的输出图像产生什么影响。
- 内存动态载入:数据量大的图像可以通过每次只处理该图像的一部分来管理。利用这种方法,处理数据量大的图像就成为可能,否则,这种图像将无法载入内存。
图9.1和9.2已经从图像处理的角度展示了一种数据管线的简化表示。图像滤波器一般都具有数值型参数,用于调整滤波器的行为。每次有参数发生变更,数据管线就会将其输出标记为“脏的”,并且知道这个滤波器及其下游使用它的输出的各滤波器应该重新执行。管线设施的特性减少了探测参数空间的困难,同时为实验中的各个示例分配最少的处理能力。
更新管线的过程可以通过每次只处理图像的一部分的方式来驱动。这是一种对于支持动态载入处理功能来说很有必要的机制。实践中,该过程被一种从一个RequestedRegion
规范的内部传递所控制,这种传递过程将规范从下游的滤波器传向其上游的滤波器。这种通信过程是通过一个内部API来实现的,并且可供应用程序开发者直接调用。
举一个更具体些的例子,如果一个高斯模糊图像滤波器以一幅由中值滤波器处理过的100×100像素的图像作为输入,那么该模糊滤波器可以向中值滤波器请求只处理原图像的四分之一,也就是说,一个大小为100×25像素的图像区域。该请求还会继续向上游传播,同时警告沿途各滤波器为了生成请求中规定大小的图像区域,将不得不向图像区域的尺寸附加边界。后面还将讲述更多关于数据流的内容。
不论是对给定滤波器的参数做出的改变,还是该滤波器所要处理的特定请求区域所做的改变,都会将管线标记为“脏的”、并提示管线的下游滤波器需要重新执行。
9.2.3.1 过程与数据对象
有两种主要的对象类型被设计用于存储管线的基本结构。它们是DataObject
和ProcessObject
。DataObject
是承载数据的类的抽象;例如:图像和几何网格。ProcessObject
为处理上述数据的图像滤波器和网格滤波器提供抽象。ProcessObject
以DataObject
为输入,并对其进行某种算法变换,例如图9.2中的那些。
DataObject
是由ProcessObject
生成的。这个链条通常自从磁盘读取DataObject
开始,例如通过使用一种ProcessObject
类型的ImageFileReader
。唯一能够修改某个DataObject
的就是生成该DataObject
的ProcessObject
。输出的DataObject
一般连入管线中下游的滤波器作为它们的输入。
图9.5:ProcessObject
与DataObject
之间的关系
这种序列关系如图9.5所示。同一个DataObject
可能会传给多个ProcessObject
作为它们的输入,如图中所示,DataObject
由管线开端的文件reader生成。在这种特定情况下,文件reader是ImageFileReader
的实例,而它所生成的、作为其输出的DataObject
是Image
类的一个实例。某些滤波器需要两个DataObject
作为输入也是很平常的现象,比如上图中右半部出现的相减滤波器就是这样的例子。
ProcessObject
和DataObject
连接起来 构建管线的副作用。从应用程序开发者的角度来看,管线是通过涉及到一连串的调用连接起来的,如:
writer->SetInput(canny->GetOutput());
canny->SetInput(median->GetOutput());
median->SetInput(reader->GetOutput());
然而在内部,连接在一起的并非以这中一连串的ProcessObject
,而是下游的ProcessObject
与其上游ProcessObject
生成的DataObject
。
管线内部的链条结构通过三种类型的连接维持在一起:
ProcessObject
保有一系列指向其输出的DataObject
的指针。ProcessObject
拥有并控制着其生成的DataObject
。ProcessObject
保有一系列指向作为其输入的DataObject
的指针。输入的DataObject
由上游的ProcessObject
拥有。DataObject
保有指向生成它的ProcessObject
的指针。该ProcessObject
正好还拥有和控制着这个DataObject
。
这些内部链接随后被用于在管线内部向上游或下游传递调用信息。在所有这些互动过程中,ProcessObject
都保持对其所生成的DataObject
的控制和所有权。下游的滤波器通过指针的链接来获得对一个给定DataObject
的信息的访问权限,这种链接是由一连串的对SetInput()
和GetOutput()
的调用建立起来的,它甚至无需获得对输入数据的控制。出于实践的目的,滤波器应当将其各自的输入数据看作是只读的对象。这一点在API中通过在SetInput()
方法的变量中使用C++的const
关键字得到了加强。作为一个通用的规则,ITK还是包含了一个const-correct的外部API,尽管从内部来看,这种const-correctness被某些管线操作重载。
9.2.3.2 管线类层次
图9.6:ProcessObject
和DataObject
的类层次
ITK中的数据管线的最初设计与实现是从VTK衍生而来的,其时,VTK已然是一个成熟的项目,而ITK的开发才刚刚开始(见《开源软件架构,第I卷》)。
图9.6展示了ITK管线对象面向对象的类层次。特别注意基础的Object
,ProcessObject
,DataObject
,以及滤波器家族和数据家族的一些类。在这个抽象层次上,任何被用来做某一滤波器的输入、或是某一滤波器的输出的对象,必须派生自DataObject
。所有产生数据和消耗数据的对象,都应该派生自ProcessObject
。数据通过管线的流动一部分由ProcessObject
实现,一部分由DataObject
实现。
LightObject
和Object
位于ProcessObject
和DataObject
形成的二叉树之上。LightObject
和Object
提供诸如用于Events
通信的API的公共功能,以及对多线程的支持。
9.2.3.3 管线的内部工作原理
图9.7给出了UML时序图,描述了在一条由ImageFileReader
,MedianImageFilter
和ImageFileWriter
所组成的最小管线中,ProcessObject
和DataObject
之间的交互。
完整的交互过程由四个环节组成:
- 更新输出信息(上游的调用时序)
- 更新请求的区域(上游的调用时序)
- 更新输出数据(上游的调用时序)
- 生成数据(下游的调用时序)
图9.7:UML时序图
当应用程序调用管线中最后一个滤波器的Update()
方法时,整个流程即被触发;在这个具体的例子当中,这个滤波器就是ImageFileWriter
。Update()
调用指向上游方向以初始化第一阶段。也就是说,从管线中的最后一个滤波器起,朝向管线中的第一个滤波器。
第一个环节的目的是为了查询这样的问题,“你能为我生成多少数据?”这个问题转化为代码就是UpdateOutputInformation()
。这个方法中,各个滤波器根据其输入中的可用数据量来计算可作为输出的图像数据量。考虑到必须在该滤波器回应输出数据量之前获知输入数据量,这个问题就得传导至上游的滤波器,一直传至某个能够回应该问题的源滤波器。在这个示例中,源滤波器就是ImageFileReader
。它能够通过从其所读入的图像文件收集信息,得出其输出的数据大小。一旦管线中的第一个滤波器对问题做出了回应,该滤波器下游的一系列滤波器就能够依次计算其各自的输出数据量,并一直运行至管线中的最末一个滤波器。
第二个环节的处理方向也是向上游方向的,用于告知各滤波器应该输出的数据量,此数据量是管线运行过程中所需要的。Requested Region是支持ITK的流处理能力的基本概念。它使“告知滤波器不要生成整个完整图像、而只是关注图像的某个子区域(即:Requested Region)”成为可能。这在手头的图像大于系统内存的时候是非常有用的。调用请求从最后一个滤波器传导至第一个,在途中的每个滤波器,requested region的尺寸都会被修正,这些修正要考虑到该滤波器输入中可能需要的任何附加的边界,这样该滤波器才能生成给定区域尺寸的输出。在我们的这个示例中,中位数滤波器一般会向其输入中加入2-像素的边界。也就是说,如果writer向中位数滤波器请求一个500×500尺寸的区域,那么中位数滤波器就会相应地向reader请求一个502×502尺寸的区域,因为中位数滤波器在缺省情况下计算一个输出像素,需要一个3×3像素的邻域。这个环节被写入PropagateRequestedRegion()
方法。
第三个环节要触发Requested Region内的数据的计算。该环节的处理方向也是向上游,它被定义为UpdateOutputData()
方法。由于各个滤波器在其计算出输出结果之前都需要输入数据,本环节的调用请求先向其上游的滤波器传递,然后再向上游传导。然后返回到实际进行数据计算的当前滤波器。
第四个环节(最后一个环节)的处理方向是向下游的,它由每个实际执行运算的滤波器组成。该环节被写为GenerateData()
。下游方向并不是一个滤波器向其下游发送调用请求的结果,而是UpdateOutputData()
的调用以从管线中的第一个滤波器到最后一个的顺序执行。也就是说,所发生的下游方向的顺序,要归因于调用的时机,而不要归因于什么滤波器在驱动这一调用。这个说明是很重要的,因为ITK的管线从本质上讲是Pull Pipeline,其中的数据是管线的末端所请求的,而且这种逻辑也是由管线的末端来控制的。
9.2.4 工厂
ITK的基础设计需求之一是提供多平台支持。这一需求出现于追求使该工具箱的影响最大化,通过使工具箱能够为社区所广泛使用,而无需考虑其各自的平台。ITK采用工厂设计模式来应对这样的挑战:支持多种不同硬件和软件平台、而不牺牲一个解决方案在不同平台上的实用性。
ITK中的工厂模式使用类的名称作为向构造函数注册的键值。工厂的注册在运行时进行,这一过程可以在ITK应用程序启动时,通过简单地将动态链接库放入指定路径来完成。后一种特性提供了一种以干净、透明的方式实现插件架构的基本机制。其影响是减少可扩展图像分析应用程序的开发难度,同时满足了提供持续成长的图像分析能力的需要。
9.2.5 IO工厂
工厂机制对于IO操作尤为重要。
9.2.5.1 以外观模式拥抱多样性
图像分析社群开发了非常多的文件格式来储存图像数据。这些文件格式中的大多数都是为了满足特定的需要而设计和实现的,因此为支持特定类型的图像而进行了微调。结果,新的文件格式定期涌现并推广到这个社群。注意到这一形势,ITK开发团队设计了一个IO架构,适于减轻扩展性工作,向这样的架构中定期添加越来越多的文件格式是简单的。
图9.8:IO工厂的依赖关系
这个IO可扩展架构建立在上一部分所述的工厂机制的基础上。主要的不同点就是在IO情形中,IO工厂在一个由基类ImageIOFactory
所管理的特殊的注册机制中实现注册,如图9.8中左上角所示。从图像文件格式中读写数据的实际功能又类族ImageIO
来实现,见图9.8中的右侧。这些服务类在使用者请求读入或者写出一个图像时被初始化。这些服务类不直接暴露给应用程序代码。反之,我们希望应用程序与下列外观类(facade)进行交互:
ImageFileReader
ImageFileWriter
应用程序可以通过类似下列的代码来调用这两个类:
reader->SetFileName("../image1.png");
reader->Update();
或者,
writer->SetFileName("../image2.png");
writer->Update();
这两种情形中,对Update()
的调用触发了这些ProcessObject
所连接的管线上游的执行。reader和writer的行为类似管线中有多了一个滤波器。在reader的特例中,对Update()
的调用触发了将相应图像文件读取到内存的操作。在writer的情形中,对Upadate()
的调用触发了为writer提供输入的上游管线的执行、并最终将图像结果以一种特定的文件格式写到磁盘中。
这些外观类将应用程序开发人员与各文件格式所固有的、内部的不同隔离开来。这些外观类甚至把文件格式的存在性本身也隔离了起来。这些外观以这样一种方式设计:应用程序开发人员大多数时候无需了解应用程序需要读入的文件格式。典型的应用会简单地调用如下代码:
std::string filename = this->GetFileNameFromGUI();
writer->SetFileName( filename );
writer->Update();
不管变量filename
的内容是否是下列串中的任何一个,这些调用都能正常工作:
- image1.png
- image1.jpeg
- image1.tiff
- image1.dcm
- image1.mha
- image1.nii
- image1.nii.gz
其中文件扩展名标识各种情形中不同的图像文件格式。
9.2.5.2 获知像素类型
尽管有文件reader和writer外观所提供的支持,我们还是得依赖应用程序开发人员来确定应用程序所需处理的像素的类型。在医学影像处理工作中,指望应用程序开发人员知道输入图像是否含有MRI(核磁共振成像,Magnetic Resonance Imaging)、乳腺透视(mammogram)、还是CT(计算断层扫描,Computed Tomography)扫描是合理的,因此,为不同成像模态要选择合适的像素类型和图像几何维度都要铭记于心。在使用者想要读取任意类型图像的场合中,图像类型的特异性对于应用程序想的设置就可能带来不便,这种现象经常在快速样机开发和教学的环境中出现。然而,在临床上,部署医学成像应用程序时,我们希望像素类型和图像的几何维度都根据成像模态清楚的定义出来。举一个具体的例子,对于一个管理三维MRI扫描的应用程序,形如:
typedef itk::Image< signed short, 3 > MRImageType;
typedef itk::ImageFileWriter< MRImageType > MRIWriterType;
MRIWriterType::Pointer writer = MRIWriterType::New();
writer->Update();
然而,这里存在一个限度,那就是:能有多少图像文件格式的特性与应用程序开发人员隔离。例如,当我们从DICOM文件中读取图像时,或者读取RAW图像时,应用程序开发人员可能插入额外的调用,以进一步指明手头文件格式的特点。DICOM文件是在临床环境中最为普遍的,而RAW图像对于研究领域中交流数据,仍然是“食之无味、弃之可惜”。
9.2.5.3 和而不群
每一个IO工厂和ImageIO服务类的自包含特性也反映在模块化上。典型地,一个ImageIO类依赖一个特定的库,这个库专门管理某个特定的文件格式。例如,PNG,JPEG,TIFF,以及DICOM。在那些情形中,第三方库以自包含的模块的形式被管理,而特定的、用于为ITK提供该第三方库接口的ImageIO代码也加入到这个模块中来。这样,特定的应用程序可能会禁掉一部分许多与其所在领域无关的文件格式,以集中提供那些对该应用程序的既定场合有用的文件格式。
正如标准的工厂一样,IO工厂可以在运行时从动态链接库中载入。这种灵活性促进了特定场合和内部开发文件格式的使用,而无需将所有这样的文件格式都直接整合到ITK工具包本身中去。可载入的IO工厂一直是ITK架构设计中最为成功的特性之一。它使不用向代码添加负担或者使其实现变得含混、就能便捷地管理一个富有挑战性的情况成为可能。最近,类似的IO架构已经被采用,以管理那些读取和写出含有空间变换的文件的过程,这些空间变换由类族Transform
所表示。
9.2.6 流
ITK最初的诞生是一组图像处理工具,为了满足“可视人计划”的需要。那时很明确的是,这样巨大的数据集是不能像医学影像处理研究社群的典型做法那样、被载入计算机的RAM中的。这样的数据集同样也无法载入到我们今天所使用的台式机的内存中。于是,开发ITK的需求之一就是能够使图像数据在数据管线中流动。更具体点,就是能够以压入图像的子块通过数据管线的方式处理巨大的图像,然后将管线输出端得到的块再组装起来。
图9.9:图像流处理示意图
这种对图像区域的划分展现在图9.9中的中位数滤波器的例子。中位数滤波器计算一个输出像素,并将其作为输入图像中该像素某邻域内像素值的统计中位数。该邻域的大小是滤波器的一个数值参数。我们将其设定为2像素,意思就是我们将取那个输出像素周围以2个像素为半径的区域作为邻域。这样就得到了一个5×5像素的邻域,该输出像素就位于这个邻域的正中,被一个边长为2像素的的矩形包围。这个半径通常被称为Manhattan半径。当中位数滤波器收到计算输出图像中一个指定的Requested Region的请求时,它就会向其所在管线的上游滤波器发出请求,要其提供一个将Requested Region边界(在我们这个例子当中,就是2像素)放大一倍的区域。在图9.9中的情形中,当被要求处理尺寸为100×25像素的Region 2时,中位数滤波器将这个请求传递到其上游滤波器,要求对方提供尺寸为100×29像素的区域。这个29像素的纵向尺寸是由25像素加上该尺寸两端各加2个像素得到。注意到横向尺寸并没有被放大,这是由于在该示例中,100像素的横向尺寸已经是输入图像所能提供的最大尺寸了;于是,104像素的横向尺寸放大请求(100像素加上该尺寸两端各加2个像素)就被裁减为图像在该方向的最大尺寸,也就是100像素的横向尺寸。
在邻域上计算的ITK滤波器通过三种典型的方法来处理边界条件:看图像外部是否存在null值,或者将像素值关于图像边界做镜像,或者重复赋予图像外部以边界值。在中位数滤波器的情形中,我们采用了零通量Neumann边界条件,意思只是区域边界以外的像素被假设为边界上最后一个像素的重复。
在图像处理文献中,有一个心照不宣的小秘密,那就是图像滤波器的实现难点通常是与对边界条件的恰当处理相关的。这是切断多数教科书中所提供的理论训练与图像处理的软件实践的一个特殊症状。在ITK中,这个问题通过实现一大批图像迭代器和相关的边界条件计算器族来解决。这两组helper类将图像滤波器与处理N维边界条件问题的复杂性隔离开来。
流处理过程由滤波器之外的设施驱动,典型的驱动来源就是ImageFileWriter
和StreamingImageFilter
。这两个类实现了将完整尺寸的图像投入处理、并将其划分为一系列由应用程序开发人员所要求的分区的流功能。而后,当调用它们的Update()
的过程中,它们会进入查询图像的每一个处于中间阶段的分区的迭代循环。在该过程中,它们利用图9.7所述的SetRequestedRegion()
API。这样就将管线上游的计算约束在图像的一个子区域中。
驱动流处理过程的应用程序代码如下:
median->SetInput( reader->GetOutput() );
median->SetNeighborhoodRadius( 2 );
writer->SetInput( median->GetOutput() );
writer->SetFileName( filename );
writer->SetNumberOfStreamDivisions( 4 );
writer->Update();
其中,唯一一个新要素是SetNumberOfStreamDivisions()
的调用,它定义了为了将图像进行流处理而进行的分区的数量。为了与图9.9中的示例对应,我们把要划分的区域的数量设定为4。这就是说writer
将要四次触发mdian
滤波器开始执行,每次一个不同的Requested Region。
在流处理过程和给定滤波器的并行执行过程之间,有些很有趣的相似之处。两者都依赖于将图像处理工作分解为将大块的图像数据进行划分,然后再分别进行处理的可能性。在流处理过程中,大块的图像数据依照时间的先后顺序,依次进行处理;而在并行处理过程中,大块的图像数据被分配给不同的线程,再挨个分配给CPU的各核。最终,这一切将由滤波器的算法特性来决定是否能够将输出图像划分为成块的数据、从而得以进行基于对应输入图像的块数据的独立计算。在ITK中,有API处理流过程、还有独立的API专门为基于多线程和共享内存的并行计算的实现提供支持,从这个意义上讲,流和并行事实上是正交的。
不走运的是,流并不能应用于所有类型的算法。不适用流的特例有:
迭代算法,为了在每步迭代中都计算一个像素值,作为其输入像素邻域的像素值。多数基于PDE求解的算法就是这种情况,比如:各向异性扩散,demons形变配准,以及稠密水平集。
需要所有输入像素的像素值以计算其中一个输出像素的像素值的算法。Fourier变换和IIR(Infinite Impulse Response,IIR)滤波器,像是递归高斯滤波器就是这种类型。
区域传导或者前线传导算法中对像素的修改也以迭代的方式进行,但是这些算法不能通过可预测的方式将区域或者前线的位置系统地划分给分块的数据。区域生长分割,稀疏水平集,一些数学形态学操作的实现,以及某些形式的分水岭,就是典型的例子。
图像配准算法,注意,它们需要在其优化循环的每步迭代中访问整个输入图像来计算度量值。
所幸的是,另一方面,所有滤波器都创建其自身的输出,于是它们就不会覆盖内存中的输入图像,ITK的数据管线结构通过利用这一事实带来的优势,能够支持多种变换滤波器的流功能。这是以消耗内存为代价的,因为管线得同时为输入图像和输出图像分配内存空间。象翻转,轴排列,以及几何重采样均属此列。在这些情形中,数据管线通过要求每个滤波器都提供一个名为GenerateInputRequestedRegion()
的方法来管理输入区域与输入区域的匹配,该方法以矩形的输出区域为参数。这个方法计算矩形输入区域,这是滤波器为计算指定的矩形输出区域所需要的。
更精确点,我们必须说ITK支持流——但仅仅支持那些具有“可成流”特性的算法。就是说,从积极意义上讲,考虑其余的算法的时候,我们应当在这里修饰一下我们的声明,不能是“不可能在这些算法上用流”,而是“我们处理流的典型方法不适用于这些算法”,同时我们期待将来社群中能够设计出新的技术来解决这些问题。
9.3 经验教训
9.3.1 可复用性
可复用性的原则也可以理解为“避免冗余”。在ITK中,这一原则通过如下三个方面来实现:
首先,采用面向对象编程,尤其是类层次结构的合理创建,其中的共同功能都被分解组织到各个基类当中。
第二,采用泛型编程,这通过C++模板的大量使用来实现,同时将那些以模式来表示的行为分解开来。
第三,C++宏的放开使用也使整个工具包中无数地方都需要的标准代码段的重用成为可能。
许多这些事项听起来像是老生常谈,而且在今天看来是显然的,但是当1999年ITK的开发工作开始时,其中的某些事项并非如此明显。尤其是,那时大多数C++编译器对模板的支持并没有严格遵循一个一贯的标准。即使今天,在社群中做出采用泛型编程并且使用广泛模板化的实现的决策仍然是充满争议的。在社群中,这表现于对通过Python,Tcl,或者Java的包裹层来使用ITK的偏好。
9.3.1.1 泛型编程
泛型编程的采用是ITK定义的实现特点之一。在1999年,这是一个艰难的决定,那时编译器对C++模板的支持是相当碎片化的,而标准模板库(STL)看起来仍然有些不容易被接受。
在ITK中,泛型编程的采用,是通过拥抱使用C++模板来实现概念的泛化来实现的,这种方式还能提高代码的重用。ITK中C++模板参数化的典型是类Image
,可以通过下面的方式实例化:
typdef unsigned char PixelType;
const unsigned int Dimension = 3;
typedef itk::Image< PixelType, Dimension > ImageType;
ImageType::Pointer image = ImageType::New();
这种表达式中,应用程序开发人员选择用于表示图像像素的数据类型,还有图像的几何维度,这个图像就像是空间中的一个网格。在这个特例中,我们选择使用unsigned char
来代表三维图像中的8位的像素。多亏有为此相关的泛型实现,才可能实例化ITK中的任意像素类型和任意几何维度的图像。
为了能够写这些表达式,ITK开发人员得非常谨慎地根据对像素类型的假设来实现类Image
。一旦应用程序开发人员实例化了图像类型,开发者就能够创建那种类型的对象,或者继续实例化图像滤波器——它们的类型也依赖图像的类型。例如:
typedef itk::MedianImageFilter< ImageType, ImageType > FilterType;
FilterType::Pointer median = FilterType::New();
不同图像滤波器的算法特性限制其能够支持的实际像素类型。例如,某些图像滤波器预期图像像素类型为整数标量类型,而另外一些滤波器则期望像素类型是由浮点数组成的矢量。当这些滤波器被不当的像素类型实例化时,它们就会生成编译错误或者得出错误的计算结果。为防止不正确的实例化并使编译错误的故障排除变得容易一些,ITK采用了概念检查,它基于对类型的某些预期的特定功能强制进行检查,以生成早期错误、并具有便于人类阅读的错误消息为目的。
在工具包的一些部分中,C++模板也以模板元编程的方式被使用,这样做是以提高代码的运行时速度性能、尤其是展开那些控制低维矢量和矩阵的计算的循环为目的的。讽刺的是,我们已经多次发现一些编译器在判断何时展开循环方面已经变得更加灵巧,并且在一些案例中,已经不再需要模板元编程表达式的帮助。
9.3.1.2 知道何时停止
在这里,也存在着“好事过头”的风险,就是说,过度使用模板和过度使用宏的风险。很容易走得太远,而后以在C++的基础上生造了一门新的语言收场,这门语言本质上是基于模板和宏的使用而产生的。这是一条恰当的边界,它要求来自开发团队持久的关注,以确保语言特性的合理使用而非滥用。
举一个具体的例子,通过C++的关键字typedef
显式地命名类型这一做法的广泛使用,已经证明是尤其重要的。这种实践起两种作用:一方面,它提供利于人类阅读的富有信息的名称来描述类型的天然属性及其目的;另一方面,它确保类型在整个工具包中的使用是相容的。例如,在为其4.0版进行重构的过程中,在收集诸如int
,unsigned int
,long
和unsigned long
等C++整数类型在何处使用、并将其取代上投入了大量的精力,取代这些整数类型的类型名称,根据与该类型所表示的变量相关联的概念命名。这是任务中代价最大的部分,为的是确保工具包能够利用64位数据类型的优势,在所有平台上处理大于4G的图像数据。这个任务对于ITK在显微镜和遥感领域的推广至关重要,在这些领域中,数十G的图像数据是很普遍的。
9.3.2 可维护性
ITK的架构满足使维护成本最小化的约束。
模块化(类级别)
许多小文件
代码复用
重复性模式
这些特性以下列方式降低维护成本:
模块化(类级别)使强制执行图像滤波器级别,或ITK的类级别的测试驱动的开发技术成为可能。应用于小规模、模块化的代码片段的严格的测试规定具有减少bug可能隐藏其中的代码的优势,而且具有模块化所带来的自然的解耦性,这样,定位并消除缺陷的工作就变得非常容易了。
许多小文件使将某部分代码布置给某个开发人员变得容易,并且简化了缺陷的跟踪,它们都与版本控制系统中的某个特定的commit相关联。保持小文件的规定也引起了函数和类的金规则的强制执行:Do on thing, and do it right。
代码复用:当代码被复用(而不是被“复制-粘帖”和再实现)时,代码本身就从更高层次的审查中获益,这种审查是由于其(被复用的代码——译注)在多种不同的环境被使用所引起的。这使更多的眼睛能够注视着这些代码,或者至少注视着这些代码的效果,因此这些代码得益于Linus定律:“Given enough eyeballs, all bugs are shallow.”
重复性模式简化了维护人员的工作,在现实中,他们负责项目生命期中超过75%的软件开发成本。使用相互兼容地在代码中的不同位置重复的编码模式,使开发人员打开一个文件就能够快速理解这段代码在做什么、或者想做什么变得非常容易。
当开发人员牵涉到常规维护活动中时,他们会面临一些“常见失败”,尤其是:
某些滤波器为其输入和输出图像制造相应的特定像素类型的假设,但是这并不是通过类型或者概念检查所强制进行的,而且也没有在文档中指出。
代码的可读性不强。这是对任何新算法实现的软件的最普遍挑战之一,这些新算法源自于研究社群。在那种环境中,写出“能工作”的代码,而无视代码的目的不仅仅在于运行时的执行、更在于其非常便于下一个开发人员阅读是很正常的。典型的书写“干净的代码”的良好规则——举个例子,写较小的做且仅做一件事的函数(单一责任原则和最小意外原则),以及对变量和函数的恰当命名——往往容易被研究人员因看到他们崭新的算法能够正常工作而忽视。
忽视失败情形和错误管理。关注于数据处理的“nice cases”而未能提供用于管理所有可能出错的情形的代码是很普遍的。采用了工具包的人一旦开始开发和部署实际的应用程序,就迅速地扎到这些情形中去。
不充分的测试。它需要许多规则来跟进测试驱动的开发实践,尤其是先写测试以及只实现那些你要测试的功能的观点。代码的bug几乎总是隐匿在这些在实现测试代码过程中所略过的情形中。
多亏了开源社群的传播实践,这些问题中的多数已经由于通过邮件列表中经常被问及的问题所暴露出来,或者由使用者直接报告bug而终结。在处理了许多这类问题后,开发人员认识到应该编写“利于维护”的代码。某些这类特性同时应用于代码风格和代码的实际组织。我们的看法是,一个开发人员只能通过花费时间——至少一年——做维护工作并暴露在“所有的东西都有可能出错”这一事实面前,来达到掌握的程度。
9.3.3 无形的手
软件应该看起来像是由一个人写出来的那样。最好的开发人员是这样的人,should they be hit by the proverbial bus,他们写的代码能够被他人顺利接管。我们逐渐认识到任何“人身接触”的蛛丝马迹都是软件中被引入缺陷的迹象。
为了强制推行代码风格的统一,下列工具已被证实是非常有效的:
用于源代码风格自动检查的
KWStyle
。这是一种简化的C++解析器,用于检查代码风格并标出任何与该风格冲突的地方。用于常规代码审查的
Gerrit
。该工具服务于两个目的:一方面,它防止不成熟的代码进入代码库,这是以抽取其在迭代审查周期过程中的错误、缺陷、和不完善的方式来实现的,在这个周期当中,其他开发人员通过贡献以提高代码的质量。另一方面,它提供一个虚拟的训练营,在其中新的开发人员能够向更有经验(这里所谓的“有经验”是指犯过所有的错误、并且知道尸体埋在哪儿……)的开发人员学习如何提高代码的质量,和如何避免那些在维护周期中被发现的已知问题。Git hooks促使了KWStyle和Gerrit的强制推行,并且还能够承担一些本该由它们自己负责的检查工作。例如,ITK利用Git hooks来防止带有tab和结尾空格的代码提交。
团队也已摸索了
Uncurstify
的使用,它作为强制使用一个相容风格工具。
值得强调的是风格的统一性并不简单地处于审美情趣,它实际上是一种经济方面的考虑。对软件项目的所有者的总成本(TCO,Total Cost of Ownership)的研究估计,在一个项目的的生命周期中,维护的成本大约是75%的TCO,而考虑到维护成本主要以年度为基准,它一般会超出初始开发成本计划所给出的成本,这个初始开发成本是一个软件项目的生命周期中前五年的总成本。(见"Software Development Cost Estimating Handbook",第I卷,Naval Center for Cost Analysis, Air Force Cost Analysis Agency, 2008。)维护估计要占到一个软件开发人员实际工作的大约80%,而当忙于维护的时候,开发人员的大部分时间都被用于阅读他人的代码,试图看懂这些代码的意图(见Clean Code, A Handbook of Agile Software Craftsmanship,Robert C. Martin,Prentice Hall,2009)。统一的风格确实想减少开发人员将自己沉浸于一个新近的开源文件、并在对该文件做出任何修改之前理解其中的代码的工作中所花费的时间。出于同样的原因,统一的风格降低了开发人员尝试修复旧有的bug时、由于对代码的误解并随之做出的引入新的bug的修改的概率(The Art of Readable Code,Dustin Boswell,Trevor Foucher, O'Reilly,2012)。
使这些工具有效的关键在于确保它们:
能够为每一个开发人员所使用,因此我们倾向于开源工具。
能够运行于一个正规的基础上。在ITK中,这些工具已被整合到由CDash管理的每日构建和Continuous Dashboard构建中去。
尽可能紧密地运行于代码所写的地方,这样变动就能被立即修复,开发人员就能快速查到哪种做法破坏了风格规则。
9.3.4 重构
ITK始于2000年,并持续发展至2010年。2011年,幸亏融入了联邦资助基金,开发团队才有了真正的专门的机会进行重构的努力。该基金由国家医学图书馆提供,作为美国恢复和再投资法案(ARRA,American Recovery and Reinvestment Act)所发倡议的一部分。这不是一个小小的承诺。想象一下你一直致力于一个软件超过十年时间,然后你获得了一个把它清理干净的机会;你该改动些什么呢?
这个做广泛重构的机会十分难得。在之前的十年里,我们依赖于每天的努力来进行小规模的、局部的重构,清理那些我们走进的特殊的角落。这个持续的清理和提高过程利用了开源社群的大规模协作的优势,该过程由CDash驱动的测试基础设施确保安全,此基础设施通常进行工具包中84%的代码的测试。注意,与此相反,软件工业的平均测试覆盖率估计只有50%。
在重构的努力过程里被改动的许多事物当中,与架构最为相关的有:
工具包中引入了模块化
整型被标准化
typedef被修复,从而能够在所有平台上进行大于4GB的图像数据的处理
软件过程被修正:
从CVS迁移到Git
利用Gerrit引入代码审查
根据CDash@home的要求引入测试
用于下载单元测试所需数据的改进方法
废弃对过时的编译器的支持
对许多IO图像文件格式的改进支持,包括:
DICOM
JPEG2000
TIFF(BigTIFF)
HDF5
引入支持GPU计算的框架
引入视频处理的支持
加入OpenCV桥
加入VXL桥
基于递增修正的维护——诸如为滤波器添加特性、提高一个给定算法的性能等任务——对于特定的C++类的局部改进很奏效。然而,基础设施的修改需要大规模的重构,这会影响整个工具包中大量的类,像是上面所讲到的那些。举个例子,这些为支持大于4GB图像的处理所需的变动有可能是迄今为止给ITK所打的最大的补丁之一。它要求对数以百计的类进行修改,并且无法在不经受巨大的痛苦的情况下完成。模块化是这个任务中的另一个实例,它并没有增量地完成。这确实影响了整个工具包的组织,它的测试基础设施是如何工作的、测试数据是如何被管理的、工具包是如何被打包并发行的、以及新的代码贡献将如何被封装以添加的未来的工具包中。
9.3.5 可再生性
ITK在其早期所接受的教训之一,就是发表在这个领域的许多论文的实现并不像我们所了解的那么容易。计算领域倾向于过度褒奖算法,而轻视作为“只是实现细节”的这一编写软件的实际工作。
那种轻视的态度对这个领域具有相当的破坏性,因为它贬低了通过编写代码和恰当的使用它而获得的第一手经验的重要性。后果是大多数发表的论文就是不能重现,而且当研究人员和学生想使用这些技术的时候,他们都以花费了大量时间在这一(重现)过程中、并且引入了对原作的变动而结束。在实践中,要验证一个实现是否与一篇文章中所描述的内容是否契合,确实是相当困难的。
ITK出于良善的目的,破坏了那种环境,并且在这样一个领域恢复了一种DIY文化,这个领域已经变得习惯于理论推理、并且已经树立起轻视实验工作的风气。由ITK带来的新文化是一种实践的、实用主义的文化,这种文化中,软件的性能的判定是基于其自身的实践结果的,而不是基于其自身看起来所具有的复杂性,这种复杂性被许多科学出版物推崇备至。事实证明,在实践中,最有效的处理方法恰恰是那些看起来太简单而不能以科学论文的形式被接受的方法。
可重现的文化是测试驱动型开发哲学的一种延续,并且有条不紊地做出更好的软件;更高的清晰度,可读性,鲁棒性,以及专注的方向。
为了填补缺乏可重现出版物的空白,ITK社群创建了Insight Journal。它是可以公开访问的、完全在线的出版物,它要求投稿都要包含代码,数据,参数,和测试,使可重现性的验证成为可能。文章在提交后的24小时内发表上线。然后社群中的任何成员就能够对这些文章进行同行评审。读者能够获得随文章一起的所有材料:源代码,数据,参数,和测试脚本。这个期刊一直提供一个多产的空间,用于共享新的代码贡献,这些代码贡献将会在这里走上进入代码主仓库的道路。期刊最近收到了它的第500篇投递文章,还将继续作为向ITK添加新代码的正式门户。