Fork me on GitHub

OpenCV 笔记(二):Mat 类初探

OpenCV 的很多跟图像有关的东西里,都离不开一样东西 —— Mat 类。读一张图片,返回的是一个 Mat,显示一张图片,用的也是 Mat。我们都知道这是一个矩阵类,但它到底是如何工作的呢?如何将一个矩阵跟各种图像、图像间关系、图像间变换等东西联系起来呢?其实我们多多少少都有一点了解,但概念又不是特别清晰。所以十分有必要去深入的探索一下这个 Mat 类,我们才能更有效且高效地写出想要的程序。正如我们要对 STL 有深入的理解才能更有效且高效的写出我们想要的程序。

Mat 类的结构

这里我们主要讲的是 Mat 类的储存结构,即用什么数据来表现一个 Mat。我们可以通过源码看到它的一些关键变量:

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
class CV_EXPORTS Mat
{
public:
// 一堆函数
// ...
// 以下是主要成员变量
// [1]
/*! includes several bit-fields:
- the magic signature
- continuity flag
- depth
- number of channels
*/
int flags;
//! the matrix dimensionality, >= 2
int dims;
//! the number of rows and columns or (-1, -1) when the matrix has more than 2 dimensions
int rows, cols;
//! pointer to the data
uchar* data;
// [2]
//! helper fields used in locateROI and adjustROI
const uchar* datastart;
const uchar* dataend;
const uchar* datalimit;
// [3]
//! interaction with UMat
UMatData* u;
}

这些我只列出了主要的一些变量,并且分成三个部分:

  • 第[1]部分是与矩阵相关的一些信息,flags 储存了矩阵的标识、是否连续、深度、通道数等信息,dims 代表矩阵的维数,rowscols 代表矩阵的行数与列数,data 是指向储存矩阵数据的指针。
  • 第[2]部分是与感兴趣区域有关的信息,感兴趣区域即 ROI(Region Of Interset)。
  • 第[3]部分是一个指针,指向一个 UMatData 类型,我们查看一下 UMatData 的定义,可以知道这是跟引用计数相关的。

UMatData 的定义:

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
struct CV_EXPORTS UMatData
{
// ...
// provide atomic access to the structure
void lock();
void unlock();
// 一些函数...
const MatAllocator* prevAllocator;
const MatAllocator* currAllocator;
int urefcount;
int refcount;
uchar* data;
uchar* origdata;
size_t size;
int flags;
void* handle;
void* userdata;
int allocatorFlags_;
int mapcount;
UMatData* originalUMatData;
};

这是很容易理解的:由一张图片储存成的矩阵通常不小,例如一张 1024 * 768 的三通道彩色图,储存的矩阵就占了 1024 * 768 * 3 * 1 = 2359296 Byte = 2304 KB。而 OpenCV 作为计算机视觉库,它就是负责处理一大堆的这些图像信息,所以经常拷贝大的图像,开销是非常大的。自然地,就采用了引用计数。
一个 Mat 的数据主要包含两个部分:矩阵头和指向像素数据的指针,矩阵头主要包含了:矩阵尺寸、存储方式、存储地址、引用计数等。每个 Mat 类,都会有自己的矩阵头,同时,要是通过引用计数,就可以共享同一片矩阵区域,而不用复制两片一模一样的空间啦!

图像的储存方式

一般来说,一个 M x N 的图像可以由一个 M x N 的矩阵来表示。矩阵如何储存这些像素值呢?需要指定颜色空间数据类型颜色空间就是对指定颜色的一种编码,比如最简单的灰度空间,只有黑色跟白色,以及他们的组合可以组成不同程度的灰色。还有常见的彩色空间,可能有三种或四种的基本元素,然后由这些基本元素可以组成各种各样的颜色。RGB颜色空间就是最常用的彩色空间,若再加入第四个元素 Alpha,就可以用来表示透明度。而数据类型就是指组成颜色的这些元素的大小,最小的数据类型就是 char,占一个字节,如 RGB 颜色的元素常用 unsigned char 来表示,可以表示范围 0 ~ 255 的数值。当然还可以用更大的数据类型来获得更精确的颜色分辨。

例如,一个灰度图可由一个二维矩阵表示:

P(0,0) P(0,1) P(0,N-1)
P(1,0) P(1,1) P(1,N-1)
P(M-1,0) P(M-1,1) P(M-1,N-1)

P(i,j) 就表示第 i 行 j 列的像素值。
如果是RBG图像,则每个元素用三个字节表示,OpenCV 中,RGB 的储存顺序为 BGR

B(0,0) G(0,0) R(0,0) B(0,N-1) G(0,N-1) R(0,N-1)
B(1,0) G(1,0) R(1,0) B(1,N-1) G(1,N-1) R(1,N-1)
B(M-1,0) G(M-1,0) R(M-1,0) B(M-1,N-1) G(M-1,N-1) R(M-1,N-1)

Mat 对象的创建

OpenCV 为创建一个 Mat 类提供了很多方法,可以在 这里 看到完整的构造的方法,我们列出一些常用的:

1
2
3
4
5
6
7
8
Mat ()
Mat (int rows, int cols, int type)
Mat (Size size, int type)
Mat (int rows, int cols, int type, const Scalar &s)
Mat (Size size, int type, const Scalar &s)
Mat (int ndims, const int *sizes, int type)
Mat (int ndims, const int *sizes, int type, const Scalar &s)
Mat (const Mat &m)

创建空矩阵

对于默认构造函数,会创建一个矩阵头,它没有像素数据,所以如果你写出下面这样的代码:

1
2
Mat nul;
imshow("nul", nul); // running time error

是会产生一个运行时的断言错误的,因为 imshow 要求矩阵的维数必须是二维的(构造时如果构造的是一维的,它也会自动扩展为二维的,让第二个维度的大小为 1)。

创建二维矩阵

对于二维的矩阵,就是要指定矩阵的行列以及矩阵数据的类型,初始值是可选的。矩阵类型的定义如下:

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

其中,CV_[The number of bits per item][Signed or Unsigned][Type Prefix] 这个部分可以使用以下几个值之一:

  • CV_8U
  • CV_8S
  • CV_16U
  • CV_16S
  • CV_32S
  • CV_32F
  • CV_64F

而预定义的 [The channel number] 可以是 1、2、3、4,当然啦,如果你需要更多的通道数,也可以使用宏 XXX(n) 来生成:

1
2
3
4
5
6
7
CV_8UC(n) // n 通道的、无符号的、每个元素占 8 个 bit
CV_8SC(n) // n 通道的、有符号的、每个元素占 8 个 bit
CV_16UC(n) // n 通道的、无符号的、每个元素占 16 个 bit
CV_16SC(n) // n 通道的、有符号的、每个元素占 16 个 bit
CV_32SC(n) // n 通道的、有符号的、每个元素占 32 个 bit
CV_32FC(n) // n 通道的、浮点类型、每个元素占 32 个 bit
CV_64FC(n) // n 通道的、浮点类型、每个元素占 64 个 bit

比如常用的 RGB 颜色类型是 CV_8UC3,表示每个像素由三个通道组成,而每个元素占 8 个 bit。
我们可以用 cv::Scalar 来指定矩阵的初值,比如 Scalar(0) 是一个单通道的黑色的像素,Scalar(0,0,255) 是一个三通道的红色的像素。于是,我们就可以用下面的代码创建一张全红的图片:

1
2
Mat red(200, 200, CV_8UC3, Scalar(0,0,255));
imshow("red", red);

效果:
red.jpg

创建多维矩阵

如果你需要更高维数的矩阵,它也提供了相应的构造函数,首先要指定维数,然后需要一个一维数组的指针,数组里包含了各个维度的尺寸,以及矩阵数据的类型,初始值也是可选的。例如:

1
2
int m[3] = { 10,10,10 };
Mat mat(3, m, CV_8UC1, Scalar(0));

以上代码创建了一个 10 x 10 x 10 的三维矩阵,你仍然可以对它做矩阵运算,但是不能使用诸如 imshow 等函数了,因为必须的二维的图像才能显示出来 ^_^
这里Detailed Description 有一句话:

It passes the number of dimensions =1 to the Mat constructor but the created array will be 2-dimensional with the number of columns set to 1. So, Mat::dims is always >= 2 (can also be 0 when the array is empty).

其实这就是在上面提到的。

从函数返回值获取

OpenCV 中有很多函数,它们的返回值是 Mat,我们可以通过复制构造函数或赋值操作符来得到这样的对象,如我们常用的 imread 函数。还有许多常用的函数,现在就举一些例子。

create

这是 Mat 的一个重要的成员函数,它有以下四种签名:

1
2
3
4
void create (int rows, int cols, int type)
void create (Size size, int type)
void create (int ndims, const int *sizes, int type)
void create (const std::vector< int > &sizes, int type)

跟构造函数很类似,就是不能指定初始值。为什么说它重要呢?因为有了它,就可以我们就可以很方便、轻易的写一些函数,比如官方的 这个例子

1
2
3
4
Mat color;
...
Mat gray;
cvtColor(color, gray, COLOR_BGR2GRAY);

你不再需要去关心为输出的对象构造好行与列,因为这些函数都会调用 create 这个函数。就像它所说的一样,调用 create 时会经过以下的步骤:

  1. If the current array shape and the type match the new ones, return immediately. Otherwise, de-reference the previous data by calling Mat::release.
  2. Initialize the new header.
  3. Allocate the new data of total()*elemSize() bytes.
  4. Allocate the new, associated with the data, reference counter and set it to 1.

当创建的新对象的尺寸与原来的不同时,才会重新分配新的空间。

operator()

Mat 类重载了 operator() 操作符,以便于获取图像的一部分(感兴趣区域)。它具有以下四种签名:

1
2
3
4
Mat operator() (Range rowRange, Range colRange) const
Mat operator() (const Rect &roi) const
Mat operator() (const Range *ranges) const
Mat operator() (const std::vector< Range > &ranges) const

如通过以下代码来获取一个图像的感兴趣区域:

1
2
3
4
5
6
7
8
Mat A = imread("opencv.jpg", 1);
// 以下两种写法等效
Mat B = A(Range(0,100),Range(20,200));
// Mat B = A(Rect(20, 0, 180, 100));
imshow("A", A);
imshow("C", B);

运行效果如下:
1.jpg

复制 Mat

因为 Mat 使用了引用计数,当你使用复制构造或赋值操作符的时候,它们实际上只是把矩阵数据的指针指向了相同的地址,共用同一片内存,所以对任意一个 Mat 对象做修改,也会影响到其它指向相同矩阵数据区域的对象(如果有)。我们运行一下以下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <opencv2/opencv.hpp>
using namespace cv;
int main()
{
Mat A = imread("opencv.jpg", 1), C;
// 复制构造 与 赋值操作符
Mat B(A);
C = A;
// 显示三个图像
imshow("A1", A);
imshow("B1", B);
imshow("C1", C);
// 对 A 进行归一化模糊后,再显示三个图像
blur(A, A, Size(9, 9));
imshow("A2", A);
imshow("B2", B);
imshow("C2", C);
waitKey();
}

运行结果如下:
2.jpg
我们可以看到,我们对 A 进行了模糊操作,而 B 与 C 也同时“变得”模糊了!这里加了个引号,是因为它们仨本身就是共用了同一个矩阵数据呀!当然是相同的啦!注意:即使是用一个矩阵的一部分去构造另一个矩阵,它们的矩阵数据的指针指向的也是同一片区域。也就是说,对原图像的改变,或对部分图像的改变,都会使矩阵本身的数据改变。例如我们对部分进行模糊处理:

1
2
3
4
5
6
7
8
9
10
11
Mat A = imread("opencv.jpg", 1);
Mat B = A(Range(0,100),Range(20,200));
imshow("A", A);
imshow("B", B);
// 对部分图像进行模糊处理
blur(B, B, Size(9, 9));
imshow("A2", A);
imshow("B2", B);

运行效果如下:
3.jpg
看到了吧?原图的那个部分最后也变模糊了。
那么如果我们真的想复制一份 Mat,让他们各自有各自的矩阵数据怎么办呢?很简单,Mat 为我们提供了两个成员函数:clonecopyTo

  • clone

    1
    Mat clone () const
  • copyTo

    1
    2
    void copyTo (OutputArray m) const
    void copyTo (OutputArray m, InputArray mask) const

他们都会将整个 Mat 的信息包括矩阵数据复制一份,所以对复制出来的新对象进行操作,就不会影响到原来的矩阵数据了。
值得一提的是 copyTo 的第二个版本,多了一个 mask 参数,这个有什么用呢?当然有用啦,这就是为了方便我们处理那些感兴趣区域(ROI)呀!官方文档 给的说明是:

Operation mask. Its non-zero elements indicate which matrix elements need to be copied. The mask has to be of type CV_8U and can have 1 or multiple channels.

按照我目前的理解,就是把你需要的颜色抠出来。比如我们把 OpenCV 图标上面那个红色的圈圈抠出来,我们就用一张灰度图,背景是黑色(0),要抠的地方是白色(255),这样要抠的地方就会原封不动的复制出来了。部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
Mat src = imread("opencv.jpg", 1);
Mat out;
Rect r1(Rect(50, 0, 120, 100));
Mat mask = Mat(src.rows, src.cols, CV_8UC1, Scalar(0));
mask(r1).setTo(255);
src.copyTo(out, mask);
imshow("src", src);
imshow("mask", mask);
imshow("out", out);

运行效果如下:
4.jpg

元素值的读写

前面我们讲的都是整个 Mat 的创建或复制,那如果我们想对 Mat 里的矩阵的元素值进行访问或修改,该怎么做呢?

矩阵的输出

首先,我们简单的了解一下矩阵的输出,这有助于我们直观的感受矩阵中的数据。 Mat 重载了 operator<< 输出操作符,所以我们可以很方便的就得到格式化输出的结果:

1
2
3
// 声明一个 3x2 的三通道的矩阵
Mat mat(3, 2, CV_8UC3, Scalar(0, 128, 255));
std::cout << "mat = " << mat << "\n";

输出如下:

1
2
3
mat = [ 0, 128, 255, 0, 128, 255;
0, 128, 255, 0, 128, 255;
0, 128, 255, 0, 128, 255]

OpenCV 还提供了其它很多格式化输出的形式,如 PythonCSVnumpy 等,这里不作为重点,如有兴趣可以看 这里 的例子,自己尝试。

使用 at() 函数

Mat 提供了许多的 at 的重载版本对元素值进行读写,就不一一列举了,可以在 这里 查看。需要注意的是,at 需要我们指定一个 _Tp 模板类型,即如文档所说,你需要这样使用:

  • If matrix is of type CV_8U then use Mat.at<uchar>(y,x).
  • If matrix is of type CV_8S then use Mat.at<schar>(y,x).
  • If matrix is of type CV_16U then use Mat.at<ushort>(y,x).
  • If matrix is of type CV_16S then use Mat.at<short>(y,x).
  • If matrix is of type CV_32S then use Mat.at<int>(y,x).
  • If matrix is of type CV_32F then use Mat.at<float>(y,x).
  • If matrix is of type CV_64F then use Mat.at<double>(y,x).

我们可以这样来创建一张黑白过渡的图片:

1
2
3
4
5
6
7
8
9
Mat img(255, 255, CV_8UC1);
for (int r = 0; r < img.rows; ++r)
{
for (int c = 0; c < img.cols; ++c)
{
img.at<uchar>(r, c) = r % 255;
}
}
imshow("black2white", img);

运行效果如下:
5.jpg
这是由上至下过渡的,我们还可以弄成从左到右的,只需要把 r % 255 改成 c % 255 就可以啦!
当然,at 的模板参数可不是只能用上面所说的那些,我们要保证的是,数据类型能够一一对应。比如我们在弄一个三通道的图像:

1
2
3
4
5
6
7
8
9
10
11
12
13
Mat img(510, 510, CV_8UC3);
for (int i = 0; i < img.rows; ++i)
{
for (int j = 0; j < img.cols; ++j)
{
Vec3b pixel;
pixel[0] = (i + j) % 255;
pixel[1] = j % 255;
pixel[2] = i % 255;
img.at<Vec3b>(i, j) = pixel;
}
}
imshow("what", img);

运行效果如下:
6.jpg
你还可以继续更多有趣的尝试!

使用迭代器

如果你熟悉 STL,那么你也一定很喜欢迭代器的读写方式——安全,易写,不易出错。Mat 类也向我们提供了它的迭代器类型 MatIterator_,接下来我们就使用迭代器的方法来生成一张随机色彩的图像吧!

1
2
3
4
5
6
7
8
9
10
11
Mat img(400, 400, CV_8UC3);
MatIterator_<Vec3b> iter;
for (iter = img.begin<Vec3b>(); iter != img.end<Vec3b>(); ++iter)
{
Vec3b pixel;
pixel[0] = rand() % 255;
pixel[1] = rand() % 255;
pixel[2] = rand() % 255;
*iter = pixel;
}
imshow("what", img);

运行效果如下:
7.jpg
有点像电视雪花啊哈哈,每次运行的结果肯定都一样的,不过都是乱七八糟的了。

使用指针

Mat 同样提供了一个 ptr 函数及其的许多重载版本,可以在 这里 查看。使用指针确实是更容易出错,因为 C-style 指针是不带范围检查的。不过我觉得这点小事也怕干脆就别学了。听说用指针的效率会更高,当然我现在还没有进行充足的测试,有兴趣的朋友可以自己测试一下。我就举跟使用 at 一样的例子吧:

1
2
3
4
5
6
7
8
9
10
Mat img(255, 255, CV_8UC1);
for (int r = 0; r < img.rows; ++r)
{
uchar* p = img.ptr<uchar>(r);
for (int c = 0; c < img.cols; ++c)
{
p[c] = r % 255;
}
}
imshow("black2white", img);

上述代码同样是实现了一个黑白过渡的图像生成。

Mat_ 类

有没有觉得上面很多 at<uchar>ptr<uchar> 的写法很烦?而且更重要的是,这是不能做编译器检查的,也就算说,你写成以下代码:

1
2
3
4
5
6
7
8
9
Mat img(255, 255, CV_8UC1);
for (int r = 0; r < img.rows; ++r)
{
for (int c = 0; c < img.cols; ++c)
{
// 这里应为 uchar
img.at<double>(r, c) = r % 255;
}
}

编译期是没法检查这个类型错误的,直到运行期才会炸掉。Mat_ 类就是来解决这个问题的,Mat_ 类就是对 Mat 的一个封装,加上了一个模板参数用于指定类型,这样就可以免去很多麻烦和造成错误的可能。例如以下一个例子:

1
2
3
4
5
6
7
8
9
10
11
Mat img(255, 255, CV_8UC1);
Mat_<uchar> img2(img); // Mat_ 对象
for (int r = 0; r < img2.rows; ++r)
{
auto p = img2.ptr(); // 不需要指定类型了
for (int c = 0; c < img2.cols; ++c)
{
img2(r, c) = r % 255; // 可以使用 Matlab 风格
}
}
imshow("black2white", img2);

小结

Mat 是整个 OpenCV 中非常重要的一个数据结构,它还有许多操作,以上只提到了冰山一角,可以从 这里 获取更多的信息。不断的练习与思考,就是进步的唯一途径。

-------------------------------- 全文完 感谢您的阅读 --------------------------------
「写的那么辛苦,连一块钱都不打赏吗/(ㄒoㄒ)/~~」