Skip to content

Mat - 基本图像容器

我们有多种方式从现实世界获取数字图像:数码相机、扫描仪、计算机断层扫描(computed tomography)和磁共振成像(magnetic resonance imaging)等等。在每种情况下,我们(人类)看到的是图像。然而,当将其转换到我们的数字设备上时,我们记录的是图像每个点处的数值。

例如,在上图中你可以看到汽车的后视镜不过是一个包含了所有像素点强度值的矩阵。我们如何获取和存储像素值可能会根据我们的需求而有所不同,但最终,计算机世界中的所有图像都可以归结为数值矩阵以及描述矩阵本身的其他信息。OpenCV 是一个计算机视觉库,其主要重点是处理和操作这些信息。因此,你首先需要熟悉的是 OpenCV 如何存储和处理图像。

OpenCV 自 2001 年起就已经存在。在那些日子里,这个库是围绕 C 接口构建的,为了在内存中存储图像,他们使用了一个名为 IplImage 的 C 结构。这就是你在大多数较老的教程和教育材料中所看到的那个。问题在于,它把 C 语言的所有缺点都带了进来。最大的问题是手动内存管理。它建立在这样一个假设之上:用户负责处理内存的分配和释放。虽然这对较小的程序来说不是问题,但一旦你的代码库增长,处理所有这些事情将会比专注于解决你的开发目标更费力。

幸运的是,C++ 出现了,并引入了类的概念,通过自动内存管理(或多或少地)让用户更轻松。好消息是 C++ 与 C 完全兼容,因此进行这种改变不会产生任何兼容性问题。因此,OpenCV 2.0 引入了一个新的 C++ 接口,它提供了一种新的做事方式,这意味着你不需要再摆弄内存管理,使你的代码简洁(写得更少,做得更多)。C++ 接口的主要缺点是,目前许多嵌入式开发系统只支持 C。因此,除非你的目标是嵌入式平台,否则使用老方法毫无意义(除非你是个受虐狂程序员,自找麻烦)。

关于 Mat 你需要知道的第一件事是,你不再需要手动分配它的内存,并在不再需要时立即释放它。虽然这样做仍然是可能的,但大多数 OpenCV 函数会自动分配其输出数据。一个额外的好处是,如果你传入一个已经分配了矩阵所需空间的现有 Mat 对象,它将被重用。换句话说,我们始终只使用执行任务所需的那么多内存。

Mat 基本上是一个包含两部分数据的类:矩阵头(包含诸如矩阵大小、用于存储的方法、矩阵存储在哪个地址等信息)以及一个指向包含像素值的矩阵的指针(根据所选择的存储方法可以是任意维度的)。矩阵头的大小是固定的,然而矩阵本身的大小可能因图像而异,通常要大上几个数量级。

OpenCV 是一个图像处理库。它包含了大量的图像处理函数。为了解决一个计算挑战,大多数时候你最终会使用该库的多个函数。正因如此,将图像传递给函数是一种常见的做法。我们不应忘记,我们讨论的是图像处理算法,它们往往计算量很大。我们最不想做的事情就是通过不必要地复制潜在的大图像来进一步降低程序的速度。

为了解决这个问题,OpenCV 使用了引用计数系统。其思想是每个 Mat 对象都有自己的头,然而两个 Mat 对象可以通过让它们的矩阵指针指向同一个地址来共享一个矩阵。此外,复制运算符只会复制头和指向大矩阵的指针,而不会复制数据本身。

Mat A, C; // creates just the header parts
A = imread(argv[1], IMREAD_COLOR); // here we'll know the method used (allocate matrix)
Mat B(A); // Use the copy constructor
C = A; // Assignment operator

MatBasicImageForComputer.jpg

以上所有对象最终都指向同一个单一的数据矩阵,使用其中任何一个进行修改都会影响所有其他的对象。实际上,不同的对象只是提供了对同一底层数据的不同访问方法。不过,它们的头部分是不同的。真正有趣的部分是,你可以创建只引用完整数据中某个子部分的头。例如,要在图像中创建一个感兴趣区域(ROI),你只需用新的边界创建一个新的头:

Mat D (A, Rect(10, 10, 100, 100) ); // using a rectangle
Mat E = A(Range::all(), Range(1,3)); // using row and column boundaries

现在你可能会问——如果矩阵本身可能属于多个 Mat 对象,那么当不再需要它时,谁来负责清理它呢?简短的回答是:最后一个使用它的对象。这是通过使用引用计数机制来处理的。每当有人复制一个 Mat 对象的头时,该矩阵的计数器就会增加。每当一个头被清理时,这个计数器就会减少。当计数器降为零时,矩阵被释放。有时你也想复制矩阵本身,因此 OpenCV 提供了 cv::Mat::clone() 和 cv::Mat::copyTo() 函数。

Mat F = A.clone();
Mat G;
A.copyTo(G);

现在修改 F 或 G 将不会影响 A 的头所指向的矩阵。你需要从所有这些中记住的是:

  • OpenCV 函数的输出图像分配是自动的(除非另有说明)。
  • 使用 OpenCV 的 C++ 接口,你不需要考虑内存管理。
  • 赋值运算符和拷贝构造函数只复制头。
  • 图像的底层矩阵可以使用 cv::Mat::clone() 和 cv::Mat::copyTo() 函数来复制。

这涉及你如何存储像素值。你可以选择所使用的颜色空间和数据类型。颜色空间指的是我们如何组合颜色分量以便为给定颜色编码。最简单的一种是灰度,其中我们可以使用的颜色是黑色和白色。这些颜色的组合使我们能够创造出许多灰度色调。

对于色彩丰富的方式,我们有更多方法可供选择。它们中的每一种都将其分解为三或四个基本分量,我们可以使用这些分量的组合来创建其他颜色。最流行的一种是 RGB,主要是因为这也是我们眼睛构建颜色的方式。它的基色是红、绿、蓝。为了对颜色的透明度进行编码,有时会添加第四个元素 alpha(A)。

然而,还有许多其他颜色系统,每种都有自己的优势:

  • RGB 是最常见的,因为我们的眼睛使用类似的东西,但请记住,OpenCV 标准显示系统使用 BGR 颜色空间来组合颜色(红色和蓝色通道互换了位置)。
  • HSV 和 HLS 将颜色分解为色调(hue)、饱和度(saturation)和明度/亮度(value/luminance)分量,这对我们来说是一种更自然的描述颜色的方式。例如,你可以忽略最后一个分量,使你的算法对输入图像的光照条件不那么敏感。
  • YCrCb 被流行的 JPEG 图像格式所使用。
  • CIE L*a*b* 是一种感知均匀的颜色空间,如果你需要测量给定颜色与另一种颜色之间的距离,它会很有用。

每个构成分量都有自己有效的取值域。这就引出了所使用的数据类型。我们如何存储一个分量决定了我们对其取值域的控制程度。可能的最小数据类型是 char,即一个字节或 8 位。它可以是无符号的(因此可以存储 0 到 255 的值)或有符号的(-127 到 +127 的值)。尽管在三分量(如 RGB)的情况下,这个宽度已经能表示 1600 万种可能的颜色,但我们可以通过为每个分量使用 float(4 字节 = 32 位)或 double(8 字节 = 64 位)数据类型来获得更精细的控制。不过,请记住,增加一个分量的大小也会增加整幅图像在内存中的大小。

在显示图像的教程中,你已经学会了如何使用 cv::imwrite() 函数将矩阵写入图像文件。然而,出于调试目的,查看实际值要方便得多。你可以使用 Mat 的 << 运算符来做到这一点。请注意,这只适用于二维矩阵。

虽然 Mat 作为图像容器工作得非常好,但它也是一个通用的矩阵类。因此,可以创建和操作多维矩阵。你可以通过多种方式创建一个 Mat 对象:

  • cv::Mat::Mat 构造函数

    (构造函数的代码实现见源代码。)

    对于二维和多通道图像,我们首先定义它们的大小:即行数和列数。

    然后我们需要指定用于存储元素的数据类型以及每个矩阵点的通道数。为此,我们有多个按以下约定构造的定义:

CV_[The number of bits per item][Signed or Unsigned][Type Prefix]C[The channel number]

MatBasicContainerOut1.png MatBasicContainerOut2.png MatBasicContainerOut3.png MatBasicContainerOut6.png MatBasicContainerOut7.png MatBasicContainerOut8.png MatBasicContainerOut16.png MatBasicContainerOut10.png MatBasicContainerOut9.png MatBasicContainerOut11.png MatBasicContainerOut12.png MatBasicContainerOut13.png MatBasicContainerOut14.png MatBasicContainerOut15.png

例如,*CV_8UC3* 表示我们使用 8 位长的 unsigned char 类型,每个像素有三个这样的类型来构成三个通道。有最多四个通道的预定义类型。`cv::Scalar` 是一个四元素的短向量。指定它,你就可以用一个自定义值初始化所有矩阵点。如果你需要更多,你可以用上面的宏创建该类型,在括号中设置通道数,如下所示。
  • 使用 C/C++ 数组并通过构造函数初始化

    (初始化的代码实现见源代码。)

    上面的例子展示了如何创建一个多于二维的矩阵。指定它的维数,然后传递一个包含每个维度大小的指针,其余保持不变。

  • cv::Mat::create 函数:

    (create 的代码实现见源代码。)

    你不能用这种构造方式初始化矩阵值。只有在新的尺寸放不进旧的尺寸时,它才会重新分配其矩阵数据内存。

  • MATLAB 风格的初始化器:cv::Mat::zeros、cv::Mat::ones、cv::Mat::eye。指定要使用的尺寸和数据类型:

    (matlab 风格初始化的代码实现见源代码。)

  • 对于小矩阵,你可以使用初始化列表:

    (初始化列表的代码实现见源代码。)

  • 为一个现有的 Mat 对象创建一个新头,并对它进行 cv::Mat::clone 或 cv::Mat::copyTo。

    (clone 的代码实现见源代码。)

    注: 你可以使用 cv::randu() 函数用随机值填充一个矩阵。你需要为随机值给出一个下限和上限:

    (随机填充的代码实现见源代码。)

在上面的例子中,你可以看到默认的格式化选项。然而,OpenCV 允许你格式化你的矩阵输出:

  • 默认(Default) (默认输出的代码实现见源代码。)

  • Python (Python 输出的代码实现见源代码。)

  • 逗号分隔值(CSV) (CSV 输出的代码实现见源代码。)

  • Numpy (Numpy 输出的代码实现见源代码。)

  • C (C 输出的代码实现见源代码。)

OpenCV 也通过 << 运算符支持其他常见 OpenCV 数据结构的输出:

  • 2D 点 (2D 点输出的代码实现见源代码。)

  • 3D 点 (3D 点输出的代码实现见源代码。)

  • 通过 cv::Mat 的 std::vector (vector 输出的代码实现见源代码。)

  • 点的 std::vector (点 vector 输出的代码实现见源代码。)

这里的大部分示例都已包含在一个小型控制台应用程序中。你可以从此处或 cpp 示例的 core 部分下载它。

你还可以在 YouTube 上找到相关的快速视频演示。