卷2:第9章 ITK

2018-02-24 15:55 更新

原文链接: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.2:MRI脑部图像,中值滤波器,边缘检测滤波器

对于图中的每一项任务,图像处理社区都已经开发出了各种算法,并且继续开发新的算法。为什么他们继续做这些?你可能会问,答案就是图像处理是一种科学、工程、艺术、以及“烹饪”技术的组合。公然宣称某种算法组合对于一个图像处理任务来说是“对的”无异于类似宣布正餐上要备“对的”巧克力甜点一样的误导。不是追求完美,社区奋力制造出丰富的工具来确保在面对一项挑战性的图像处理任务时,不会出现可选项的短缺。当然,事情发展的状态是要付出代价的。代价就是图像分析人员有一个困难的任务,就是在从几十个不同工具中选择可用的不同组合,而这些组合可以得出类似的结果。

图像分析社区与研究社区联系紧密。某一个研究小组与某一个算法群相关联是寻常现象。“品牌命名”的风俗,以及某种程度的“市场化”,导致了一种这样的情况:软件工具箱可以尽可能好地为社区提供一个非常完整的算法实现集,然后将之混合并匹配,以创建一个满足社区需要的菜谱。

为什么ITK要被设计并实现成一个巨大的某种程度独立、且有条理的工具——图像滤波器——的原因有很多,多数滤波器用于解决类似的问题。在本文中,某种程度的“冗余”——打个比方,提供高斯滤波器的三种不同实现方法——这不应该被看做是问题,而应该是一种有价值的特性,因为不同的实现可以可交换地使用,以满足约束并且发掘与图像尺寸、处理器数量、以及可能与某个给定图像处理应用程序中的特定高斯核尺度相关的效率潜能。

该工具箱还被创建成一个成长的、不断更新自身的资源,因为新的算法和更好的实现出现了,取代了现有的;还因为为了应对不断涌现的新的医学影像处理技术而开发的新工具。

快速了解了ITK社区中的图像分析人员的每日例行公事,我们现在开始深入架构的主要特性:

  • 模块化
  • 数据管线
  • 工厂
  • IO工厂
  • 可复用性
  • 可维护性

9.2.2 模块化

模块化是ITK的主要特性之一。这个需求源于图像分析社区的人们解决问题时的工作方式。大多数图像分析问题将一幅或多幅输入图像放入处理滤波器的组合中,这些滤波器用于增强或是提取图像中的某些特定的信息片段。因此这中间就没有大的处理对象,而是许多小的。逻辑上讲,这种图像处理问题的结构性本质特征意味着要实现一个由许多图像处理滤波器组成的软件,这些滤波器就可以以不同的方式组合使用了。

将某些特定的处理滤波器聚合为一个家族也是如此,其中的某些实现上的特性可以被分解。这就导致图像滤波器自然分组为模块以及模块群。

至此,模块化存在于ITK中的三个自然层次上:

  • 滤波器层次
  • 滤波器家族层次
  • 滤波器家族群层次

在图像滤波器层次上,ITK大约拥有700个滤波器。考虑到ITK是以C++实现的,这个层次中的每一个滤波器都是以C++类辅以面向对象的设计模式来实现的。在滤波器家族层次上,ITK根据滤波器进行处理时的方式将其分组。例如,所有与傅里叶变换有关的滤波器将会放入同一个模块。在C++层次上,模块映射于源代码文件树,并且映射于软件编译成二进制形式后的库文件。ITK拥有120个这种模块。每个模块包含:

  • 属于该家族的图像滤波器的源代码。
  • 一些描述该模块如何构建并列出该模块与其他模块之间依赖关系的配置文件。
  • 对应于每个滤波器的一组单元测试。

图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 过程与数据对象

有两种主要的对象类型被设计用于存储管线的基本结构。它们是DataObjectProcessObjectDataObject是承载数据的类的抽象;例如:图像和几何网格。ProcessObject为处理上述数据的图像滤波器和网格滤波器提供抽象。ProcessObjectDataObject为输入,并对其进行某种算法变换,例如图9.2中的那些。

DataObject是由ProcessObject生成的。这个链条通常自从磁盘读取DataObject开始,例如通过使用一种ProcessObject类型的ImageFileReader。唯一能够修改某个DataObject的就是生成该DataObjectProcessObject。输出的DataObject一般连入管线中下游的滤波器作为它们的输入。

图9.5:ProcessObjectDataObject之间的关系

这种序列关系如图9.5所示。同一个DataObject可能会传给多个ProcessObject作为它们的输入,如图中所示,DataObject由管线开端的文件reader生成。在这种特定情况下,文件reader是ImageFileReader的实例,而它所生成的、作为其输出的DataObjectImage类的一个实例。某些滤波器需要两个DataObject作为输入也是很平常的现象,比如上图中右半部出现的相减滤波器就是这样的例子。

ProcessObjectDataObject连接起来 构建管线的副作用。从应用程序开发者的角度来看,管线是通过涉及到一连串的调用连接起来的,如:

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.7:UML时序图

当应用程序调用管线中最后一个滤波器的Update()方法时,整个流程即被触发;在这个具体的例子当中,这个滤波器就是ImageFileWriterUpdate()调用指向上游方向以初始化第一阶段。也就是说,从管线中的最后一个滤波器起,朝向管线中的第一个滤波器。

第一个环节的目的是为了查询这样的问题,“你能为我生成多少数据?”这个问题转化为代码就是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架构,适于减轻扩展性工作,向这样的架构中定期添加越来越多的文件格式是简单的。

,第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添加新代码的正式门户。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号