开源应用程序架构(第 2 卷)
ITK

路易斯·伊瓦涅斯 和 布拉德·金

9.1. 什么是 ITK?

ITK,即 Insight 工具包,是一个图像分析库,由该计划开发,主要由美国国家医学图书馆资助。可以将 ITK 视为一个可用的图像分析算法百科全书,特别是用于图像滤波、图像分割和图像配准。该库由一个联盟开发,该联盟包括大学、商业公司以及来自世界各地的许多个人贡献者。ITK 的开发始于 1999 年,最近在 10 周年之际,该库经历了一次重构过程,旨在去除陈旧的代码并将其重塑以应对未来十年。

9.2. 架构特征

软件工具包与其社区之间存在着非常协同的关系。它们在持续的迭代循环中相互塑造。软件会不断修改,直到满足社区的需求,而社区的行为本身也会根据软件赋予或限制他们做的事情进行调整。为了更好地理解 ITK 架构的本质,因此非常有必要了解 ITK 社区通常在处理什么样的问题,以及他们倾向于如何解决这些问题。

野兽的本质

如果你不了解野兽的本质,
了解它们的解剖力学就没有多大用处。

迪·霍克,《从多到一:VISA 和混沌组织的兴起》

在典型的图像分析问题中,研究人员或工程师会获取输入图像,通过例如降噪或增强对比度来改善图像的一些特征,然后继续识别图像中的一些特征,例如角点和强边缘。这种类型的处理自然非常适合数据管道架构,如图 9.1所示。

图 9.1:图像处理管道

为了说明这一点,图 9.2显示了来自磁共振图像 (MRI) 的大脑图像,以及使用中值滤波器处理以降低其噪声水平的结果,以及用于识别解剖结构边界的边缘检测滤波器的结果。

图 9.2:MRI 大脑图像、中值滤波器、边缘检测滤波器

对于这些任务中的每一个,图像分析社区都开发了各种算法,并且仍在继续开发新的算法。你可能会问,他们为什么要继续这样做?答案是图像处理是科学、工程、艺术和“烹饪”技能的结合。声称存在一种算法组合是图像处理任务的“正确”答案,就像声称存在一种“正确”的巧克力甜点一样具有误导性。与其追求完美,不如努力提供一套丰富的工具,以确保在面对给定的图像处理挑战时,不会缺乏可尝试的选项。当然,这种情况是有代价的。代价是图像分析师必须在数十种不同的工具中进行选择,这些工具可以以不同的组合使用以获得类似的结果。

图像分析社区与研究社区紧密结合。通常会发现,特定的研究小组会附着在他们开发的算法家族上。这种“品牌化”甚至在某种程度上“营销”的习惯导致了一种情况,即软件工具包所能为社区做的最好的事情就是提供一套非常完整的算法实现供他们尝试,然后混合搭配以创建满足其需求的方案。

这些都是 ITK 被设计和实现为大量相对独立但连贯的工具(即图像滤波器)的原因之一,其中许多工具可用于解决类似的问题。在这种情况下,一定程度的“冗余”(例如,提供三种不同的高斯滤波器实现)不被视为问题,而是一种有价值的功能,因为不同的实现可以互换使用以满足约束并利用针对图像大小、处理器数量和高斯核大小的效率,这些效率可能特定于给定的成像应用。

该工具包也被设想为一种资源,随着新的算法和更好的实现变得可用,它会不断发展和更新,取代现有的算法和实现,并且随着新工具的开发以应对新兴的医学成像技术的需要。

有了对 ITK 社区中图像分析师日常工作流程的快速了解,我们现在可以深入研究架构的主要特征了

模块化

模块化是 ITK 的主要特征之一。这是人们在图像分析社区解决问题时工作方式产生的需求。大多数图像分析问题都会将一个或多个输入图像通过一系列处理滤波器,这些滤波器会增强或提取图像中的特定信息片段。因此,不存在单个大型处理对象,而是无数个小型对象。图像处理问题的这种结构特性在逻辑上意味着将软件实现为大量可以以多种不同方式组合的图像处理滤波器。

某些类型的处理滤波器也聚集到家族中,在这些家族中,它们的一些实现特征可以被分解。这导致将图像滤波器自然地分组到模块和模块组中。

因此,模块化在 ITK 中发生在三个自然级别

在图像滤波器级别,ITK 拥有大约 700 个滤波器。鉴于 ITK 是用 C++ 实现的,这是一个自然级别,其中每一个滤波器都由遵循面向对象设计模式的 C++ 类实现。在滤波器族级别,ITK 根据它们执行的处理性质将滤波器组合在一起。例如,所有与傅里叶变换相关的滤波器都将放在一个模块中。在 C++ 级别,模块映射到源代码树中的目录,并在软件编译成二进制形式后映射到库。ITK 有大约 120 个这样的模块。每个模块包含

  1. 属于该族的图像滤波器的源代码。
  2. 一组描述如何构建模块并列出此模块与其他模块之间依赖关系的配置文件。
  3. 与每个滤波器相对应的一组单元测试。
图 9.3:组、模块和类的层次结构

组级别主要是一个概念性划分,它被绘制在软件之上以帮助在源代码树中定位滤波器。组与高级概念相关联,例如过滤、分割、配准和 IO。这种层次结构在图 9.3中进行了说明。ITK 目前有 124 个模块,这些模块又聚合成 13 个主要组。这些模块的大小各不相同。这种大小分布(以字节为单位)在图 9.4中进行了展示。

图 9.4:50 个最大 ITK 模块的大小分布(以 KB 为单位)

ITK 中的模块化也适用于一组并非直接属于工具包但工具包依赖的第三方库,并且为了方便用户,这些库与其余代码一起分发。这些第三方库的具体示例包括图像文件格式库:HDF5、PNG、TIFF、JPEG 和 OpenJPEG 等。之所以在这里突出显示第三方库,是因为它们占 ITK 大小的约 56%。这反映了开源应用程序通常建立在现有平台之上的性质。第三方库的大小分布不一定反映 ITK 的架构组织,因为我们只是采用了这些在上游开发的有用库。但是,第三方代码与工具包一起分发,并且对其进行分区是模块化过程的关键驱动指令之一。

此处展示模块大小分布,因为它是对代码正确模块化的衡量标准。可以将代码的模块化视为一个连续的光谱,该光谱范围从将所有代码放在单个模块中的极端情况(即单片版本)到将代码划分为大量大小相等的模块。这种大小分布是用于监控模块化过程进展的工具,特别是为了确保没有大的代码块保留在同一个模块中,除非真正的逻辑依赖关系需要这种分组。

ITK 的模块化架构能够并促进

模块化过程使得能够在将工具包的不同部分放入模块时明确识别和声明它们之间的依赖关系。在许多情况下,此练习揭示了随着时间的推移引入到工具包中的伪依赖关系或不正确的依赖关系,并且当大多数代码放在几个大组中时,这些依赖关系未被注意到。

评估每个模块的质量指标的用途是双重的。首先,它使开发者更容易对其维护的模块负责。其次,它使开发者能够参与清理计划,在这些计划中,少数开发者在短时间内专注于提高特定模块的质量。当专注于工具包的一小部分时,更容易看到努力的效果,并让开发者保持参与和积极性。

需要重申的是,我们注意到工具包的结构反映了社区的组织,在某些情况下还反映了为软件的持续增长和质量控制而采用的流程。

数据管道

大多数图像分析任务的分阶段性质自然地导致选择数据管道架构作为数据处理的基础设施。数据管道能够

图 9.1图 9.2 已经从图像处理的角度展示了数据管道的简化表示。图像滤波器通常具有用于调节滤波器行为的数值参数。每次修改其中一个数值参数时,数据管道都会将其输出标记为“脏”,并知道此特定滤波器以及使用其输出的所有下游滤波器都应再次执行。管道的此功能在使用最少的处理能力进行每次实验实例时,有助于探索参数空间。

更新管道流程可以以每次只处理图像的子部分的方式驱动。这是一种支持流功能的必要机制。在实践中,该流程由从一个下游滤波器到其上游提供者滤波器的RequestedRegion规范的内部传递来控制。这种通信是通过内部 API 完成的,不会公开给应用程序开发人员。

举个更具体的例子,如果一个高斯模糊图像滤波器期望使用由中值图像滤波器生成的 100x100 像素图像作为输入,则模糊滤波器可以要求中值滤波器仅生成图像的四分之一,即大小为 100x25 像素的图像区域。此请求可以进一步向上游传播,但需要注意的是,每个中间滤波器可能必须向图像区域大小添加额外的边框,以便生成该请求的输出区域大小。有关数据流的更多信息,请参阅后面内容。

给定滤波器参数的更改或要由该滤波器处理的特定请求区域的更改,都会导致管道被标记为“脏”并指示需要通过管道中的下游滤波器重新执行该滤波器。

流程和数据对象

设计了两种主要类型的对象来保存管道的基本结构。它们是DataObjectProcessObjectDataObject是承载数据的类的抽象;例如,图像和几何网格。ProcessObject为处理此类数据的图像滤波器和网格滤波器提供了抽象。ProcessObjectDataObject作为输入,并对它们执行某种类型的算法转换,例如图 9.2中所示的那些。

DataObjectProcessObject生成。此链通常从磁盘读取DataObject开始,例如,通过使用ImageFileReader,它是一种ProcessObject。创建给定DataObjectProcessObject是唯一应该修改此类DataObjectProcessObject。此输出DataObject通常作为输入连接到管道中下游的另一个ProcessObject

图 9.5:ProcessObjectDataObject之间的关系

图 9.5说明了此序列。相同的DataObject可以作为输入传递给多个ProcessObject,如图所示,其中DataObject由管道开头的文件读取器生成。在这种特定情况下,文件读取器是ImageFileReader类的实例,它生成的输出DataObjectImage类的实例。一些滤波器也通常需要两个DataObject作为输入,例如图右侧所示的减法滤波器。

ProcessObjectDataObject作为构建管道的一个副作用连接在一起。从应用程序开发人员的角度来看,管道通过调用涉及ProcessObject的一系列调用来链接在一起,例如

writer->SetInput( canny->GetOutput() );
canny->SetInput( median->GetOutput() );
median->SetInput( reader->GetOutput() );

但是,在内部,作为这些调用的结果连接的不是一个ProcessObject到下一个ProcessObject,而是下游ProcessObject到由上游ProcessObject生成的DataObject

管道的内部链式结构由三种类型的连接保持在一起

此内部链接集合随后被利用以在管道中向上游和下游传播调用。在所有这些交互过程中,ProcessObject保留对其生成的DataObject的控制权和所有权。下游滤波器通过作为对SetInput()GetOutput()方法的调用的结果而建立的指针链接访问有关给定DataObject的信息,而无需控制该输入数据。出于实际目的,滤波器应将其输入数据视为只读对象。这在 API 中通过在SetInput()方法的参数中使用 C++ const 关键字来强制执行。作为一般规则,ITK 采用 const 正确的外部 API,即使在内部,这种 const 正确性被一些管道操作覆盖。

管道类层次结构

图 9.6:ProcessObjectDataObject的层次结构

ITK 中数据管道的初始设计和实现源自可视化工具包 (VTK),这是一个成熟的项目,当时 ITK 开发开始。(参见开源应用程序架构,第 1 卷。)

图 9.6显示了 ITK 中管道对象的面向对象层次结构。特别是,请注意基本ObjectProcessObjectDataObject以及滤波器系列和数据系列中的一些类之间的关系。在此抽象中,任何期望作为输入传递给滤波器或由滤波器作为输出生成的任何对象都必须从DataObject派生。所有生成和使用数据的滤波器都应从ProcessObject派生。通过管道移动数据所需的数据协商部分在ProcessObject中实现,部分在DataObject中实现。

LightObjectObject类位于ProcessObjectDataObject二分法的上方。LightObjectObject类提供通用功能,例如Events通信的 API 和对多线程的支持。

管道的内部工作原理

图 9.7显示了一个 UML 序列图,描述了由ImageFileReaderMedianImageFilterImageFileWriter组成的最小管道中ProcessObjectDataObject之间的交互。

完整的交互由四个阶段组成

图 9.7:UML 序列图

当应用程序调用管道中最后一个滤波器(在本例中为ImageFileWriter)的Update()方法时,整个过程将被触发。Update()调用启动第一个向上游方向的阶段。也就是说,从管道中的最后一个滤波器到管道中的第一个滤波器。

此第一阶段的目标是提出问题:“你能为我生成多少数据?”此问题在UpdateOutputInformation()方法中进行了编码。在此方法中,每个滤波器计算使用作为输入提供给它的给定数据量可以生成的图像数据量。鉴于必须首先知道数据输入量,然后滤波器才能回答有关数据输出量的问题,因此问题必须传播到上游滤波器,直到它到达可以自行回答第一个问题的源滤波器。在本例中,该源滤波器是ImageFileReader。此滤波器可以通过从其被分配读取的图像文件收集信息来确定其输出的大小。一旦管道中的第一个滤波器回答了这个问题,那么随后下游的滤波器就可以依次计算它们各自的输出量,直到它们到达管道中的最后一个滤波器。

第二个阶段也向上游方向传播,告知滤波器在管道执行期间请求它们生成的输出量。请求区域的概念对于支持 ITK 的流功能至关重要。它可以告诉管道中的滤波器不要生成整个完整图像,而是专注于图像的子区域,即请求区域。当手头的图像大于系统中可用的 RAM 时,这非常有用。调用从最后一个滤波器传播到第一个滤波器,并且在每个中间滤波器中,请求区域的大小都会被修改以考虑任何滤波器可能在其输入中需要的额外边框,以便它可以生成给定区域大小的输出。在我们的具体示例中,中值滤波器通常必须在其自身输入的大小上添加 2 像素的边框。也就是说,如果写入器请求中值滤波器生成大小为 500 x 500 像素的区域,则中值滤波器反过来将请求读取器生成 502 x 502 像素的区域,因为默认情况下,中值滤波器需要 3 x 3 像素的邻域区域来计算一个输出像素的值。此阶段在PropagateRequestedRegion()方法中进行编码。

第三阶段旨在触发请求区域内数据的计算。此阶段也向上游方向传播,并在UpdateOutputData()方法中进行编码。由于每个滤波器在计算其输出数据之前都需要其输入数据,因此调用首先传递到相应的上游滤波器,因此是上游传播。返回后,当前滤波器实际上开始计算其数据。

第四个也是最后一个阶段向下游传播,包括每个滤波器实际执行计算。调用在GenerateData()方法中进行编码。下游方向不是由一个滤波器对其下游伙伴进行调用导致的,而是由于UpdateOutputData()调用从第一个滤波器到最后一个滤波器按顺序执行的事实。也就是说,该序列由于调用的时间顺序而向下游发生,而不是由于哪个滤波器驱动调用而发生。此澄清很重要,因为 ITK 管道本质上是拉取管道,其中数据从末端请求,逻辑也由末端控制。

工厂

ITK 的一项基本设计要求是提供对多个平台的支持。此要求源于希望通过使其可供广泛社区使用(无论他们选择的平台如何)来最大程度地提高工具包的影响力。ITK 采用了工厂设计模式来应对在众多硬件和软件平台之间支持根本差异的挑战,同时不会牺牲解决方案对每个单独平台的适用性。

ITK 中的工厂模式使用类名作为类构造函数注册表的键。工厂的注册在运行时发生,可以通过简单地将动态库放置在 ITK 应用程序在启动时搜索的特定目录中来完成。此最后一个功能为以干净透明的方式实现插件架构提供了一种自然机制。结果是促进可扩展图像分析应用程序的开发,满足提供不断增长的图像分析功能集的需求。

IO 工厂

工厂机制在执行 IO 时特别重要。

拥抱多样性与外观

图像分析领域已经发展出一套非常庞大的文件格式来存储图像数据。许多这些文件格式的设计和实现都针对特定的用途,因此针对特定类型的图像进行了微调。结果,新的图像文件格式定期在社区中构思和推广。ITK 开发团队意识到这种情况,设计了一个易于扩展的 IO 架构,可以轻松地定期添加对更多文件格式的支持。

图 9.8:IO 工厂依赖关系

这个可扩展的 IO 架构建立在前面章节描述的工厂机制之上。主要区别在于,在 IO 的情况下,IO 工厂注册在一个专门的注册表中,该注册表由ImageIOFactory基类管理,如图 9.8左上角所示。从图像文件格式读取和写入数据的实际功能是在一系列ImageIO类中实现的,如图 9.8右侧所示。这些服务类旨在在用户请求读取或写入图像时按需实例化。服务类不会暴露给应用程序代码。相反,应用程序应该与外观类交互

这两个类是应用程序将调用代码的两个类,例如

reader->SetFileName("../image1.png");
reader->Update();

writer->SetFileName("../image2.jpg");
writer->Update();

在这两种情况下,对Update()的调用都会触发此ProcessObject连接到的上游管道执行。读取器和写入器都像管道中的另一个过滤器一样工作。在读取器的特定情况下,对Update()的调用会触发将相应的图像文件读取到内存中。在写入器的情况下,对Update()的调用会触发提供写入器输入的上游管道执行,并最终导致图像被写入磁盘到特定的文件格式。

这些外观类向应用程序开发者隐藏了每个文件格式固有的一些内部差异。它们甚至隐藏了文件格式本身的存在。外观类设计的方式使得大多数时候应用程序开发者不需要知道应用程序期望读取哪些文件格式。典型的应用程序只需调用类似以下的代码:

std::string filename = this->GetFileNameFromGUI();
writer->SetFileName( filename );
writer->Update();

这些调用将正常工作,无论filename变量的内容是以下任何字符串:

其中文件名扩展名在每种情况下都标识不同的图像文件格式。

了解你的像素类型

尽管文件读取器和写入器外观类提供了帮助,但应用程序开发者仍然需要了解应用程序需要处理的像素类型。在医学影像的背景下,可以合理地预期应用程序开发者会知道输入图像是否包含 MRI、乳腺X光片或 CT 扫描,因此会注意为这些不同的图像模态选择合适的像素类型和图像维度。对于用户希望读取任何图像类型的应用程序设置,图像类型的这种特殊性可能不方便,这在快速原型设计和教学场景中最常见。然而,在临床环境中部署医学图像应用程序以进行生产时,预计图像的像素类型和维度将根据要处理的图像模态进行明确定义和指定。一个具体的例子,其中一个应用程序管理 3D MRI 扫描,如下所示:

typedef itk::Image< signed short, 3 >  MRImageType;
typedef itk::ImageFileWriter< MRImageType > MRIWriterType;
MRIWriterType::Pointer writer = MRIWriterType::New();
writer->Update();

然而,可以向应用程序开发者隐藏图像文件格式的特殊性是有限度的。例如,当从 DICOM 文件读取图像或读取 RAW 图像时,应用程序开发者可能需要插入额外的调用以进一步指定手头文件格式的特性。DICOM 文件将是临床环境中最常见的,而 RAW 图像对于在研究环境中交换数据仍然是必要的。

在一起但分开

每个 IO 工厂和 ImageIO 服务类的自包含特性也反映在模块化中。通常,ImageIO 类依赖于一个专门的库,该库专门用于管理特定的文件格式。例如,PNG、JPEG、TIFF 和 DICOM 就是这种情况。在这些情况下,第三方库被作为自包含模块管理,并且将 ITK 与该第三方库连接的专用 ImageIO 代码也单独放在一个模块中。这样,特定应用程序可以禁用与其领域无关的许多文件格式,并且可以专注于仅提供对预期场景有用的文件格式。

与标准工厂一样,IO 工厂可以在运行时从动态库加载。这种灵活性有利于使用专门的和内部开发的文件格式,而无需将所有这些文件格式直接合并到 ITK 工具包本身中。可加载的 IO 工厂一直是 ITK 架构设计中最成功的特性之一。它使得能够轻松地管理一个具有挑战性的情况,而不会给代码带来负担或模糊其实现。最近,相同的 IO 架构已被调整以管理读取和写入包含由Transform类族表示的空间变换的文件的过程。

流式传输

ITK 最初被设想为一组用于处理可见人体项目获取的图像的工具。当时,很明显,如此庞大的数据集无法放入医学影像研究界通常可用的计算机的 RAM 中。今天我们使用的典型台式计算机仍然无法容纳该数据集。因此,开发 Insight Toolkit 的要求之一是能够通过数据管道传输图像数据。更具体地说,能够通过将图像的子块推送到数据管道中来处理大型图像,然后在管道的输出端组装结果块。

图 9.9:图像流式处理过程的示意图

对于中值滤波器的具体示例,图 9.9说明了图像域的这种划分。中值滤波器将一个输出像素的值计算为输入图像中围绕该像素的邻域内像素值的统计中值。该邻域的大小是滤波器的数值参数。在这种情况下,我们将其设置为 2 个像素,这意味着我们将获取一个以输出像素为中心、半径为 2 个像素的邻域。这导致了一个 5x5 像素的邻域,输出像素位于中间,周围有一个 2 像素的矩形边界。这通常称为曼哈顿半径。当要求中值滤波器计算输出图像的特定请求区域时,它会转向并要求其上游滤波器提供一个更大的区域,该区域由请求区域加上一个边界(在本例中为 2 个像素)组成。在图 9.9的具体案例中,当请求大小为 100x25 像素的区域 2 时,中值滤波器将该请求传递给其上游滤波器,以获取大小为 100x29 像素的区域。垂直方向上的 29 像素大小计算为 25 像素加上两个半径为 2 像素的边界。请注意,在这种情况下,水平尺寸没有扩大,因为它已经达到了输入图像可以提供的最大值;因此,104 像素(100 像素加上两个 2 像素的边界)的扩展请求被裁剪到图像的最大尺寸,即水平方向上的 100 像素。

在邻域上操作的 ITK 滤波器将通过使用三种典型方法之一来处理边界条件:考虑图像外部的空值、跨边界镜像像素值或在外部重复边界值。在中值滤波器的情况下,使用零通量诺依曼边界条件,这意味着区域边界外的像素假定为边界内最后一个像素值的重复。

图像处理文献中一个鲜为人知的秘密是,图像滤波器的大多数实现困难都与边界条件的正确管理有关。这是许多教科书中发现的理论训练与图像处理软件实践脱节的特定症状。在 ITK 中,这是通过实现一系列图像迭代器类和相关的边界条件计算器系列来管理的。这两个辅助类系列向图像滤波器隐藏了在 N 维中管理边界条件的复杂性。

流式处理过程由过滤器外部驱动,通常由ImageFileWriterStreamingImageFilter驱动。这两个类实现了获取图像总大小并将其划分为应用程序开发者请求的多个部分的流式处理功能。然后,在它们的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将触发median过滤器执行四次,每次使用不同的请求区域。

流式处理过程和并行化给定过滤器执行的过程之间存在有趣的相似之处。它们都依赖于将图像处理工作划分为单独处理的图像块的可能性。在流式处理的情况下,图像块按时间顺序依次处理,而在并行化的情况下,图像块分配给不同的线程,而这些线程又分配给单独的处理器核心。最后,是过滤器的算法性质将决定是否可以将输出图像划分为可以基于输入图像的对应图像块集独立计算的块。在 ITK 中,流式处理和并行化实际上是正交的,这意味着有一个 API 来处理流式处理过程,还有一个单独的 API 专用于支持基于多线程和共享内存的并行计算的实现。

不幸的是,流式处理不能应用于所有类型的算法。不适合流式处理的具体情况包括:

幸运的是,另一方面,ITK 的数据管道结构通过利用所有滤波器都创建自己的输出这一事实,从而能够支持各种变换滤波器的流式传输,因此它们不会覆盖输入图像的内存。这是以内存消耗为代价的,因为管道必须同时在内存中分配输入和输出图像。翻转、轴置换和几何重采样等滤波器属于此类。在这些情况下,数据管道通过要求每个滤波器提供一个名为GenerateInputRequestedRegion()的方法来管理输入区域与输出区域的匹配,该方法将矩形输出区域作为参数。此方法计算此滤波器计算该特定矩形输出区域所需的矩形输入区域。数据管道中这种持续的协商使得能够为每个输出块关联计算所需的相应输入图像部分。

更准确地说,因此我们必须说 ITK 支持流式传输——但仅限于本质上“可流式传输”的算法。也就是说,本着对剩余算法逐步改进的精神,我们不应声称“无法对这些算法进行流式传输”,而应说“我们目前典型的流式传输方法不适用于这些算法”,并希望社区将来能够设计出新的技术来解决这些情况。

9.3. 经验教训

可重用性

可重用性原则也可以理解为“避免冗余”。在 ITK 的情况下,这是通过三管齐下的方法实现的。

这些项目中的许多可能听起来像是老生常谈,并且在今天看来是显而易见的,但在 ITK 开发于 1999 年开始时,其中一些并不那么明显。特别是,当时大多数 C++ 编译器对模板的支持并不完全遵循一致的标准。即使在今天,诸如采用泛型编程和使用广泛模板化实现的决策在社区中仍然存在争议。这体现在那些更喜欢通过 Python、Tcl 或 Java 的包装层使用 ITK 的社区中。

泛型编程

泛型编程的采用是 ITK 的一个决定性实现特性。1999 年这是一个艰难的决定,当时编译器对 C++ 模板的支持相当分散,标准模板库 (STL) 仍然被认为有点奇特。

ITK 通过拥抱使用 C++ 模板来实现概念的泛化,并以此来提高代码重用率,从而采用了泛型编程。ITK 中 C++ 模板参数化的典型示例是Image类,它可以如下方式实例化

typedef unsigned char PixelType;
const unsigned int Dimension = 3;
typedef itk::Image< PixelType, Dimension > ImageType;
ImageType::Pointer image = ImageType::New();

在此表达式中,应用程序开发人员选择用于表示图像中像素的类型,以及图像作为空间网格的维度。在这个特定的示例中,我们选择使用8 位像素,用unsigned char类型表示,用于 3D 图像。由于底层的泛型实现,可以在 ITK 中实例化任何像素类型和任何维度的图像。

为了能够编写这些表达式,ITK 开发人员必须实现Image类,同时非常小心地考虑对像素类型做出的假设。一旦应用程序开发人员实例化了图像类型,开发人员就可以创建该类型的对象,或者继续实例化图像滤波器,这些滤波器的类型反过来又取决于图像类型。例如

typedef itk::MedianImageFilter< ImageType, ImageType> FilterType;
FilterType::Pointer median = FilterType::New();

不同图像滤波器的算法特性限制了它们可以支持的实际像素类型。例如,一些图像滤波器期望图像像素类型为整数标量类型,而另一些滤波器期望像素类型为浮点数向量。当使用不合适的像素类型实例化时,这些滤波器将产生编译错误或导致错误的计算结果。为了防止不正确的实例化并促进编译错误的故障排除,ITK 采用了基于强制执行类型某些预期特征的概念检查,目的是尽早产生错误并结合人类可读的错误消息。

C++ 模板还在工具包的某些部分以模板元编程的形式被利用,目的是提高代码的运行时速度性能,特别是对于展开控制低维向量和矩阵计算的循环。具有讽刺意味的是,我们发现随着时间的推移,某些编译器在确定何时展开循环方面变得更加智能,在某些情况下不再需要模板元编程表达式的帮助。

知道何时停止

还存在“好事做过头”的普遍风险,这意味着,存在过度使用模板或过度使用宏的风险。很容易过火,最终在 C++ 之上创建一种本质上基于模板和宏使用的新的语言。这是一个微妙的界限,它需要开发团队持续关注,以确保正确使用语言功能,而不会被滥用。

作为一个具体的例子,通过 C++ typedefs显式命名类型的广泛使用已被证明特别重要。这种做法扮演着两个角色:一方面,它提供了一个人类可读的信息名称,描述了类型的本质及其用途;另一方面,它确保在整个工具包中一致地使用该类型。例如,在为 4.0 版本重构工具包期间,投入了大量精力来收集使用 C++ 整数类型(如intunsigned intlongunsigned long)的情况,并将其替换为以相关变量所代表的正确概念命名的类型。这是确保工具包能够利用 64 位类型来管理所有平台上大于 4GB 的图像的最昂贵的部分。这项任务对于促进 ITK 在显微镜和遥感领域的使用至关重要,在这些领域中,大小为数十 GB 的图像很常见。

可维护性

该架构满足了最大程度地降低维护成本的约束条件。

这些特性通过以下方式降低了维护成本

随着开发人员参与定期的维护活动,他们接触到了一些“常见故障”,特别是

由于开源社区的沟通实践,许多这些项目最终通过邮件列表中常见的问题暴露出来,或者被用户直接报告为错误。在处理了许多这样的问题后,开发人员学会编写“易于维护”的代码。其中一些特性既适用于编码风格,也适用于代码的实际组织。我们认为,开发人员只有在花了一些时间(至少一年)进行维护并接触到“所有可能出错的事情”之后才能达到精通水平。

看不见的手

软件应该看起来像是由一个人编写的。最好的开发人员是那些编写代码的人,如果他们被比喻中的公共汽车撞倒,其他人也可以接手他们的工作。我们已经认识到,任何“个人风格”的痕迹都表明软件中存在缺陷。

为了强制执行和促进代码风格的统一性,以下工具已被证明非常有效

值得强调的是,风格的统一不仅仅是美观问题,它实际上是一个经济问题。关于软件项目总拥有成本 (TCO) 的研究估计,在项目的生命周期中,维护成本将占 TCO 的约 75%,并且鉴于维护成本是按年计算的,它通常在软件项目生命周期的前五年超过初始开发成本。(参见“软件开发成本估算手册”,第一卷,海军成本分析中心,空军成本分析机构,2008 年)。据估计,维护工作约占软件开发人员实际工作量的 80%,并且在从事此类活动时,开发人员的大部分时间都用于阅读其他人的代码,试图弄清楚它的本意是什么(参见代码整洁之道:敏捷软件工艺实践,Robert C. Martin,Prentice Hall,2009 年)。统一的风格对于减少开发人员沉浸在刚打开的源文件中并理解代码以进行修改所需的时间大有裨益。同样,它减少了开发人员误解代码并在试图修复旧错误时引入新错误的可能性(代码阅读艺术,Dustin Boswell,Trevor Foucher,O'Reilly,2012 年)。

使这些工具有效的主要关键在于确保它们

重构

ITK 于 2000 年启动,并持续发展到 2010 年。2011 年,由于联邦资金投资的注入,开发团队获得了真正独特的机会来开展重构工作。该资金由美国国家医学图书馆提供,作为美国复苏与再投资法案 (ARRA) 的一部分。这不是一项小工程。想象一下,你已经在一个软件项目上工作了十多年,现在有机会清理它;你会改变什么?

这种大规模重构的机会非常罕见。在之前的十年里,我们一直依靠每天进行少量局部重构的工作,在我们遇到特定问题时清理工具包的特定角落。这种持续的清理和改进过程利用了开源社区的大规模协作,并且通过 CDash 驱动的测试基础设施安全地实现,该基础设施定期执行工具包中约 84% 的代码。请注意,相比之下,软件行业测试的平均代码覆盖率估计仅为 50%。

在重构工作中所做的许多更改中,与架构最相关的更改包括

基于增量修改的维护——例如为图像滤波器添加功能、提高给定算法的性能、解决错误报告以及改进特定图像滤波器的文档等任务——对于特定 C++ 类别的本地改进效果很好。但是,对于影响大量类的基础设施修改(如上面列出的那些)需要进行大规模的重构。例如,支持大于 4 GB 图像所需的一组更改可能是应用于 ITK 的最大补丁之一。它需要修改数百个类,并且无法以增量方式完成,否则会带来很大的痛苦。模块化是另一个无法以增量方式完成的任务的例子。它确实影响了工具包的整个组织方式、其测试基础设施的工作方式、测试数据管理方式、工具包的打包和分发方式以及未来如何封装新的贡献以添加到工具包中。

可重复性

ITK 在早期吸取的一个教训是,该领域发表的许多论文并不像我们被引导相信的那样容易实现。计算领域倾向于过度赞美算法,并将编写软件的实际工作斥为“仅仅是实现细节”。

这种轻蔑的态度对该领域造成相当大的损害,因为它降低了对代码及其正确使用的一手经验的重要性。结果是,大多数发表的论文根本无法重现,当研究人员和学生尝试使用这些技术时,他们最终会花费大量时间并交付原始作品的变体。实际上,在实践中,很难验证实现是否与论文中描述的内容相符。

ITK 有益地打破了这种环境,并在一个习惯于理论推理并学会轻视实验工作的领域恢复了一种 DIY 文化。ITK 带来的新文化是一种实用而务实的文化,其中软件的优点由其实际结果来判断,而不是由某些科学出版物中所推崇的复杂性外观来判断。事实证明,在实践中,最有效的处理方法是那些看起来过于简单而无法被科学论文接受的方法。

可重复性文化是测试驱动开发理念的延续,并系统地带来更好的软件;更高的清晰度、可读性、鲁棒性和重点。

为了填补缺乏可重复性出版物的空白,ITK 社区创建了 Insight Journal。这是一个开放获取的、完全在线的出版物,其中要求投稿包含代码、数据、参数和测试,以实现通过可重复性进行验证。文章在提交后不到 24 小时即可在线发布。然后,它们将供社区任何成员进行同行评审。读者可以完全访问文章附带的所有材料,即源代码、数据、参数和测试脚本。该期刊为共享新的代码贡献提供了一个富有成效的空间,这些贡献从那里进入代码库。该期刊最近发表了第 500 篇文章,并继续被用作将新代码添加到 ITK 的官方入口。