iOS 图像处理图像处理opencv

2015-04-05-《OpenCV 2 计算机视觉编程手册》读

2015-08-25  本文已影响3046人  YimianDai

鉴于中文语境下,学习 OpenCV 的资料其实稀少,不是主要讲解已经过时de 1.x 版内容《学习 OpenCV》,就是各路博主碎片化的学习心得,《OpenCV 2 计算机视觉编程手册》可以说是学习 OpenCV 的最佳入门途径了。最近需要将卷积神经网络的 Matlab 代码转换成 C++ 的,我也向实验室同房间的一位学弟借了此书,大致看了一下,重点看了第 1 章、第 2 章、第 6 章和附录。

因为这书是手册性质的,都是一些函数实例,所以记录下来,以便日后再用。下文是书中部分内容的摘录,夹杂一些我的理解。全文可能有点长,遂给出目录如下:

第1章 接触图像
第2章 操作像素
第6章 图像滤波
附录 OpenCV3 介绍及代码导读

勘误
我的困惑
下一步计划

<div id="Section1">第1章 接触图像</div>

OpenCV 库的结构

第 2 页讲了下怎么编译的,对于新版 OpenCV 来说已经没有必要了,解压后的 build 文件夹就是编译好的内容。

第 3 页介绍了各模块的功能,还有推荐的声明方式,为什么要用这种声明方式呢?

第 6 页提到,为了遵循 ANSI C++ 标准,在用 Visual Studio 建立工程时选择 Application Settings 时,没有勾选 Precompiled Header 选项,这是 Visual Studio 的预编译头文件特性,可以加速编译过程。

载入、显示及保存图像

cv::Mat image;

创建宽高都为0的图像,返回值是一个结构体,

image = cv::imread("img.jpg");
if (!image.data) {
    // 图像尚未创建……
}

此处的成员变量data事实上是指向已分配的内存块的指针,包括图像数据。当不存在数据时,它被简单设置为0.

cv::namedWindow("Original Image");  // 定义窗口
cv::imshow("Original Image", image); // 显示图像

显示图像的这条语句之所以还要出现窗口名称,是为了指定究竟把图像显示到哪个窗口去,因为可能存在多个窗口。

cv::Mat result;
cv::flip(image, result, 1); // 1表示水平翻转
                            // 0表示垂直翻转
                            // 负数表示既有水平也有垂直翻转
cv::waitKey(0); //括号中填的数字是毫秒数,0为一直等待

如果没有这句话,显示的图像会一闪而过。

cv::imwrite("output.bmp", result);

文件的后缀名决定了图像保存时的编码格式。

cv::Mat ima(240, 320, CV_8U, cv::Scalar(100));

CV_8U对应的是单字节的像素图象,字母U意味着无符号的(Unsigned)。对于彩色图像,需要指定3个通道(CV_8UC3)。

当 cv::Mat 对象离开作用域后,分配的内存将自动释放,从而避免内存泄漏的困扰。
另外,cv::Mat 实现了引用计数以及浅拷贝,当图像之间进行赋值时,图像数据并没有发生复制,两个对象都指向同一块内存块。这也可用于参数传值的图像,以及返回值传值的图像。引用计数的作用是当所有引用内存数据的对象都被析构后,才会释放内存块。如果你希望创建的图像拥有原始图像的崭新拷贝,那么可以使用copyTo()方法。

cv::Mat image2, image3;
image2 = result; // 两幅图像拥有同一份数据
result.copyTo(image3); // 创建新的拷贝

如果翻转output图像,并显示image2和image3,可以看到image2页翻转了,而image3没有变。

同理,函数返回其实也是一次浅拷贝过程。

cv::Mat function() {
    // 创建图像
    cv::Mat ima(240, 320, CV_8U, cv::Scalar(100));
    // 并返回它
    return ima;
}

// 得到灰度图
cv::Mat gray = function();

在函数function内,ima只是个局部变量,在离开作用域时应当被析构掉,但由于他所关联的引用计数表示内部图像正在被另一个对象gray所引用,因此内存块并不会被释放。

<div id="Section2">第2章 操作像素</div>

void salt(cv::Mat &image, int n) {
    for (int k = 0; k < n; k++) {
        // rand() 是随机数生成函数
        int i = rand() % image.cols;
        int j = rand() % image.rows;
        if (image.channels() == 1) { // 灰度图
            image.at<uchar>(j,i) = 255;
        } else if (image.channels() == 3) { // 彩色图
            image.at<cv::Vec3b>(j,i)[0] = 255;
            image.at<cv::Vec3b>(j,i)[1] = 255;
            image.at<cv::Vec3b>(j,i)[2] = 255;
        }
    }
}
image.at<cv::Vec3b>(j,i)[channel] = value;

索引值 channel 标明了颜色通道号。
类似的,还有二元素向量类 cv::Vec2b 和四元素向量类 cv::Vec4b,s 代表 short,i 代表 int,f 代表 float,d 代表 double。所有这些类型都是使用模板类 cv::Vect<T, N> 定义的,其中 T 代表类型,N 代表向量中的元素个数。

cv::Mat_<uchar> im2 = image; // im2 指向 image
im2(50, 100) = 0; // 存取第 50 行,100列

由于 cv::Mat_ 的元素类型在创建实例的时候已经声明,操作符 () 在编译期就知道要返回的数据类型。使用操作符 () 得到返回值和使用 cv::Mat 的 at 方法得到的返回值是完全一致的,而且写起来更加简洁。

void colorReduce(cv::Mat &image, int div = 64) {
    int nl = image.rows; // 行数
    int nc = image.cols * image.channels();
    for (int j = 0; j < nl; j++) {
        // 得到第 j 行的首地址
        uchar* data = image.ptr<uchar>(j);
        for (int i = 0; i < nc; i++) {
            data[i] = data[i] / div * div + div / 2;
        }
    }
}
uchar* data = image.ptr<uchar>(j);

等效地使用指针运算从一列移到下一列,所以,也可以这么些:

*data++ = *data / div * div + div / 2;

出于效率的考虑,OpenCV 可能会给矩阵的每行填补一些额外元素。这是因为,如果行的长度是 4 或 8 的倍数,一些多媒体处理芯片(如 Intel 的 MMX 架构)可以更高效地处理图像。这些额外的像素不会被显示或者保存,填补的值将被忽略。OpenCV将填补后一行的长度指定为关键字。如果图像没有对行进行填补,那么图像的有效宽度就等于图像的真实宽度。
当不对行进行填补的时候,图像可以被视为一个长为 W*H 的一维数组。我们可以通过 cv::Mat 的一个成员函数 isContinuous 来判断这幅图像是否对行进行了填补。如果 isContinuous 方法返回值为真的话,说明这幅图像没有对行进行填补。在一些图像处理算法中,我们可以利用图像的连续性,把整个处理过程使用一个循环完成;

void colorReduce(cv::Mat &image, int div = 64) {
    int nl = image.rows; // 行数
    int nc = image.cols * image.channels();
    if (image.isContinuous()) {
        // 没有额外的填补像素
        nc = nc * nl;
        nl = 1; // it is now a 1D array
    }
    // 对于连续图像,本循环只执行一次
    for (int j = 0; j < nl; j++) {
        // 得到第 j 行的首地址
        uchar* data = image.ptr<uchar>(j);
        for (int i = 0; i < nc; i++) {
            data[i] = data[i] / div * div + div / 2;
        }
    }
}

当我们通过 isContinuous 函数得知图像没有对行进行填补之后,我们就可以将宽设置为 1,高度设置为 W*H,从而消除外层循环。注意,我们也可以使用 reshape 方法来重写这段代码:

if (image.isContinous()) {
    // no padded pixels
    image.reshape(1, image.cols*image.rows); // 分别是行数和通道数
}
int nl = image.rows; // 列数
int nc = image.cols * image.channels();

reshape 不需要内存拷贝或者重新分配就能改变矩阵的维度。两个参数分别为新的通道数和新的行数。矩阵的列数可以根据新的通道数和行数来自适应。
在这些视线中,内存循环一次处理图像的全部像素。这个方法在同时处理若干个小图像时会很有优势。

底层指针运算

在类 cv::Mat 中,图像数据以 unsigned char 形式保存在一块内存中。这块内存的首地址可以通过 data 成员变量得到。data 是一个 unsigned char 型的指针,uoyi循环可以以如下方式开始:

uchar *data = image.data;

从当前行到下一行可以通过对指针加上行宽完成:

data += image.step; // 下一行

step 代表图像的行宽(包括填补像素)。通常而言,你可以通过如下方式获得第 j 行、第 i 列像素的地址:

// (j, i) 处像素的地址为 &image.at(j, i)
data = image.data + j * image.step + i * image.elemSize();

但是,即使这种方式确实行之有效,我们依然不建议使用这种处理方式。因为这种方式除了容易出错,还不适用于带有“感兴趣区域”的图像。

使用迭代器遍历图像

在面向对象的编程中,遍历数据集合通常是通过迭代器来完成的。迭代器是一种特殊的类,它专门用来遍历集合中的各个元素,同时隐藏了在给定的集合上元素迭代的具体实现方式。这种信息隐蔽原则的使用使得遍历集合更加容易。另外,不管数据类型是什么,我们都可以使用相似的方式遍历集合。标准模板库 STL 为每个容器类型都提供了迭代器,OpenCV 同样为 cv::Mat 提供了与 STL 迭代器兼容的迭代器。
一个 cv::Mat 实例的迭代器可以通过创建一个 cv::MatIterator_ 的实例来得到。类似于子类 cv::Mat_,下划线意味着 cv::MatIterator_ 是一个模板类。之所以如此是由于通过迭代器来存取图像的元素,就必须在编译期知道图像元素的数据类型。一个图像迭代器可以用如下方式声明:

cv::MatIterator_<cv::Vec3b> it;

另外一种方式是使用定义在 Mat_ 内部的迭代器类型:

cv::Mat_<cv::Vec3b>::iterator it;

这样就可以通过常规的 begin 和 end 这两个迭代器方法来遍历所有像素。值得指出的是,如果使用后一种方式,那么 begin 和 end 方法也必须要使用对应的模板化的版本。这样,颜色缩减函数就可以重写为:

void colorReduce(cv::Mat &image, int div = 64) {
    // 得到初始位置的迭代器
    cv::Mat_<cv::Vec3b>::iterator it = image.begin<cv::Vec3b>();
    // 得到终止位置的迭代器
    cv::Mat_<cv::Vec3b>::iterator itend = image.end<cv::Vec3b>();    
    // 遍历所有像素
    for (; it != itend; ++it) {
        (*it)[0] = (*it)[0] / div * div + div / 2;
        (*it)[1] = (*it)[1] / div * div + div / 2;
        (*it)[2] = (*it)[2] / div * div + div / 2;
    }
}

注意,因为我们这里处理的彩色图像,所以迭代器返回的是 cv::Vec3b,每个颜色分量可以通过操作符 [] 得到。

使用迭代器遍历任何形式的集合都遵循同样的模式。首先,创建一个迭代器特化版本的实例。在我们的示例代码中,就是 cv::Mat_<cv::Vec3b>::iterator (或者 cv::MatIterator_<cv::Vec3b>).
然后,使用集合初始位置(图像的左上角)的迭代器对其进行初始化。初始位置的迭代器通常是通过 begin 方法得到的。对于一个 cv::Mat 的实例,你可以通过 image.begin<cv::Vec3b>() 来得到图像左上角位置的迭代器。你也可以通过对迭代器进行代数运算。例如:如果你想从图像的第二行开始,那么你可以用 image.begin<cv::Vec3b>() + image.rows 来初始化迭代器。集合终止位置的迭代器可以通过 end 方法得到。但是end 方法得到的迭代器其实已经超出了集合。这也意味着迭代过程必须在迭代器到达这个位置时结束。end 方法得到的迭代器也可以进行代数运算。如果,你希望迭代过程在图像最后一行之前停止,那么迭代器的终止位置应该是 image.end<cv::Vec3b>() - image.rows。一旦迭代器初始化完成之后,你就可以创建一个循环遍历所有的元素知道到达终止位置。一个典型的 while 循环如下所示:

while (it != itend) {
    // do something
    ...
    ++it;
}

操作符 ++ 用来将迭代器从当前位置移动到下一个位置,你也可以使用更大的补偿,比如,用it+=10将迭代器每次移动 10px。
在循环体内部,你可以使用解引用操作符 * 来读写当前元素。都操作使用 element = *it,写操作使用 *it = element。注意:如果你的操作对象是 const cv::Mat,或者你想强调当前循环不会对 cv::Mat 的实例进行修改,那么你就应该创建常量迭代器。常量迭代器的声明如下:

cv::MatConstIterator_<cv::Vec3b> it;

或者

cv::Mat_<cv::Vec3b>::const_iterator it;

在本例中,迭代器的开始位置和终止位置是通过模板函数 begin 和 end 得到的。如果我们在本章第一则秘诀中所做的那样,我们可以通过 cv::Mat_ 的实例来得到他们。这样可以避免在使用 begin 和 end 方法的时候还要置顶迭代器的类型。之所以可以这样,是因为一个 cv::Mat_ 引用在创建的时候就隐式声明了迭代器的类型。

cv::Mat_<cv::Vec3b> cimage = image;
cv::Mat_<cv::Vec3b>::iterator  it = cimage.begin();
cv::Mat_<cv::Vec3b>::iterator  itend = cimage.end();

之所以这个例子可以而前面那个例子不可以是因为,前面那个例子的图像类型是 cv::Mat, 而这个例子的图像类型是 cv::Mat_。

获取代码运行时间

OpenCV 有一个非常实用的函数 cv::getTickCount() 可以用来测量一段代码的运行时间。这个函数返回从上次开机算起的时钟周期数。由于我们需要的是某个代码段运行的毫秒数,因此还需要另外一个 cv::getTickFrequency()。此函数返回没秒内的时钟周期数,用于统计函数(或一段代码)耗费时间的方法如下:

double duration;
duration = static_cast<double>(cv::getTickCount());
colorReduce(image); // 被测试的函数
duration = static_cast<double>(cv::getTickCount()) - duration;
duration /= cv::getTickFrequency(); // 运行时间,以 ms 为单位
访问方式 时间
data[i] = data[i] / div * div + div / 2; 37ms
*data++ = *data / div * div + div / 2; 37ms
*data++ = v - v % div + div / 2; 52ms
*data++ = *data&mask + div / 2; 35ms
colorReduce(input, output); 44ms
i<image.cols*image.channels()>; 65ms
MatIterator 67ms
.at(j,i) 80ms
3-channel loop 29ms

当输出图像需要被重新分配而不是以原地(in-place)方式处理时(第5行),运行时间为44ms,比 in-place的要慢。额外的时间消耗来自于内存分配。在循环体内存,对于可提前计算的变量应避免重复计算。

图像邻域操作的一个例子

void sharpen(const cv::Mat &image, cv::Mat &result) {
    // 如有必要则分配内存
    result.create(image.size(), image.type());
    for(int j = 1; j < image.rows-1; j++) { // 处理除了第一行和最后一行之外的所有行
        const uchar* previous = image.ptr<const uchar>(j-1); // 上一行
        const uchar* current = image.ptr<const uchar>(j); // 当前行
        const uchar* next  = image.ptr<const uchar>(j+1); // 下一行
        for(int i = 1; i < image.cols - 1; i++) {
            *output++ = cv::saturate_cast<uchar>(5*current[i]-current[i-1]-current[i+1]-previous[i]-next[i]);
        }
    }
    // 将未处理的像素设置为0
    result.row(0).setTo(cv::Scalar(0));
    result.row(result.rows-1).setTo(cv::Scalar(0));
    result.col(0).setTo(cv::Scalar(0));
    result.col(result.cols-1).setTo(cv::Scalar(0));
}

在计算输出像素值时,模板函数 cv::saturate_cast 被用来对计算结果进行阶段。
setTo 函数可以用来设置矩阵的值,这个函数会将矩阵的所有元素都设为指定的值。对于一个三通道的彩色图像,需要用 cv::Scalar(a,b,c) 来指定像素三个通道的目标值。

void sharpen2D(const cv::Mat &image, cv::Mat &result) {
    // 构造核(所有项都初始化为 0)
    cv::Mat kernel(3, 3, CV_32F, cv::Scalar(0));
    // 对核元素进行赋值
    kernel.at<float>(1,1) = 5.0;
    kernel.at<float>(0,1) = -1.0;
    kernel.at<float>(2,1) = -1.0;
    kernel.at<float>(1,1) = -1.0;
    kernel.at<float>(1,2) = -1.0;
    // 对图像进行滤波
    cv::filter2D(image, result, image.depth(), kernel);
}
// 创建一个图像向量
std::vector<cv::Mat> planes;
// 讲一个三通道图像分离为三个单通道图像
cv::split(image1, planes);
planes[0] += image2;
// 将三个单通道图像重新合并为一个三通道图像
cv::merge(planes, result);

提取兴趣区域(其实就是slicing)

imageROI = image(cv::Rect(colId, rowId, logo.cols, logo.rows));

定义ROI的一种方法是使用 cv::Rect,顾名思义,cv::Rect 表示一个矩形区域。指定矩形的左上角坐标(构造函数的前两个参数)和矩形的长宽(构造函数的后两个参数)就可以定义一个矩形区域。
另一种定义ROI的方式是指定感兴趣行或列的范围(Range)。Range是指从起始索引到终止索引(不包含终止索引)的一段连续序列。cv::Range 可以用来定义Range。如果用 cv::Range 来定义 ROI,那么前例中定义 ROI 的代码可以重写为:

cv::Mat imageROI = image(cv::Range(270,270+logo.rows), cv::Range(385,385+logo.cols));

cv::Mat 的 () 操作符返回另一个 cv::Mat 实例,这个实例可以用在接下来的函数调用中,因为ROI和原始图像共享数据缓冲区,对ROI的任何变换都会影响到原始图像的对应区域。由于创建ROI时不会拷贝数据,所以不论ROI的大小如何,创建ROI的运行时间都是常量。
如果想创建包含原始图像特定行的ROI,可以使用如下代码:

cv::Mat imageROI = image.rowRange(start, end);

类似地,对于列:

cv::Mat imageROI = image.colRange(start, end);

在秘诀“遍历图像和邻域操作”中使用到的row方法和col方法其实是rowRange和colRange方法的特例,即起始索引等于终止索引,等于是定义了一个单行或单列的ROI。

<div id="Section6">第6章 图像滤波</div>

cv::blur(image, result, cv::Size(5,5));
cv::GaussianBlur(image, result, cv::Size(5,5), 1.5);

这个 1.5 就是高斯函数的$\sigma$,决定高斯函数平坦与否。

cv::Mat gauss = cv::getGaussianKernel(9, sigma, CV_32F);

9就是一维高斯核向量的长度。

cv::Mat reducedImage; // 包含缩小后的图像
cv::pyrDown(image, reducedImage); //将图像尺寸减半

同理,还存在 cv::pyrUp 函数将图像尺寸放大一倍。

cv::Mat reducedImage; // 包含改变尺寸后的图像
cv::resize(image, reducedImage, cv::Size(image.cols/3, image.rows/3)); // 改变为 1/3 大小

还提到了 cv::boxFilter 和 cv::filter2D 函数

cv::medianBlur(image, result, 5);

<div id="Section7">附录

把附录的内容全部敲下来,因为让你更好地理解OpenCV的组织架构,以及它是什么,能做到什么?还有就是samples/cpp/ 文件夹中的范例介绍,应该有最纯正的OpenCV编程风格,可以用于学习。

OpenCV3的改动在哪?
C风格的API很快将会消失,完全被C++的API替代,代码风格更加简洁,不易出错。读者如果想借助OpenCV最新的功能,记得清理代码中C风格API
C++ API将更加简洁
所有的算法都将继承自 cv::Algorithm 接口
大型的模块拆分为小模块,模块将在后面继续讲解。

OpenCV 3 的源代码文件夹:

CPU模块

CUDA模块

这些模块的名称都以 cuda 开始,cuda 是显卡制造商 NVIDIA 推出的通用计算语言,在 OpenCV 3 中有大量的模块已经被移植到了 cuda 语言。让我们依次看一下。

samples/ 文件夹

samples/cpp/ 文件夹中的范例介绍

本书程序代码及彩图下载:
http://www.sciencep.com/downloads/
https://github.com/ITpublishing

<div id="Section8">勘误</div>

<div id="Section9">我的困惑</div>

<div id="Section10">下一步计划</div>

初写于 2015-04-05,未完待续。
首发于 Yimian Dai's Homepage,转载请注明出处。

上一篇 下一篇

猜你喜欢

热点阅读