第1章 接触图像
第2章 操作像素
第6章 图像滤波
附录 OpenCV3 介绍及代码导读
勘误
我的困惑
下一步计划
第 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所引用,因此内存块并不会被释放。
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(j,i) = 255;
} else if (image.channels() == 3) { // 彩色图
image.at(j,i)[0] = 255;
image.at(j,i)[1] = 255;
image.at(j,i)[2] = 255;
}
}
}
类 cv::Mat 有若干成员函数可以获取图像的属性。公有成员变量 cols 和 rows 给出了图像的宽和高。成员函数 at(int y, int x) 可以用来存取图像元素。 但是必须在编译期知道图像的数据类型,因为 cv::Mat 可以存放任意数据类型的元素。这也是这个函数用模板函数来实现的原因。所以 at 方法要指定数据类型,而且 at 方法本身不会进行任何数据类型转换。
cv::Vec3b,即由三个 unsigned char 组成的向量。
image.at(j,i)[channel] = value;
索引值 channel 标明了颜色通道号。
类似的,还有二元素向量类 cv::Vec2b 和四元素向量类 cv::Vec4b,s 代表 short,i 代表 int,f 代表 float,d 代表 double。所有这些类型都是使用模板类 cv::Vect
cv::Mat_ 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(j);
for (int i = 0; i < nc; i++) {
data[i] = data[i] / div * div + div / 2;
}
}
}
OpenCV 默认使用 BGR 的通道顺序,而且 size 成员函数返回的先是宽,然后是高,成员变量 cols 代表图像的宽度(列数),rows 代表图像的高度,step 代表以字节为单位的图像的有效宽度,即使你的图像元素类型不是 uchar,step 仍然带代表着行的字节数。图像的通道数可以由 channels 方法得到,total 函数返回矩阵的像素个数,像素大小可以从 elemSize 函数得到,对于一个三通道的 short 型矩阵 CV_16SC3, elemSize 返回 6。
为了简化指针运算,cv::Mat 提供了 ptr 函数可以得到图像任意行的首地址。 ptr 函数是一个模板函数,它返回第 j 行的首地址:
uchar* data = image.ptr(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(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_ it;
另外一种方式是使用定义在 Mat_ 内部的迭代器类型:
cv::Mat_::iterator it;
这样就可以通过常规的 begin 和 end 这两个迭代器方法来遍历所有像素。值得指出的是,如果使用后一种方式,那么 begin 和 end 方法也必须要使用对应的模板化的版本。这样,颜色缩减函数就可以重写为:
void colorReduce(cv::Mat &image, int div = 64) {
// 得到初始位置的迭代器
cv::Mat_::iterator it = image.begin();
// 得到终止位置的迭代器
cv::Mat_::iterator itend = image.end();
// 遍历所有像素
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_
然后,使用集合初始位置(图像的左上角)的迭代器对其进行初始化。初始位置的迭代器通常是通过 begin 方法得到的。对于一个 cv::Mat 的实例,你可以通过 image.begin
while (it != itend) {
// do something
...
++it;
}
操作符 ++ 用来将迭代器从当前位置移动到下一个位置,你也可以使用更大的补偿,比如,用it+=10将迭代器每次移动 10px。
在循环体内部,你可以使用解引用操作符 * 来读写当前元素。都操作使用 element = *it,写操作使用 *it = element。注意:如果你的操作对象是 const cv::Mat,或者你想强调当前循环不会对 cv::Mat 的实例进行修改,那么你就应该创建常量迭代器。常量迭代器的声明如下:
cv::MatConstIterator_ it;
或者
cv::Mat_::const_iterator it;
在本例中,迭代器的开始位置和终止位置是通过模板函数 begin 和 end 得到的。如果我们在本章第一则秘诀中所做的那样,我们可以通过 cv::Mat_ 的实例来得到他们。这样可以避免在使用 begin 和 end 方法的时候还要置顶迭代器的类型。之所以可以这样,是因为一个 cv::Mat_ 引用在创建的时候就隐式声明了迭代器的类型。
cv::Mat_ cimage = image;
cv::Mat_::iterator it = cimage.begin();
cv::Mat_::iterator itend = cimage.end();
之所以这个例子可以而前面那个例子不可以是因为,前面那个例子的图像类型是 cv::Mat, 而这个例子的图像类型是 cv::Mat_。
获取代码运行时间
OpenCV 有一个非常实用的函数 cv::getTickCount() 可以用来测量一段代码的运行时间。这个函数返回从上次开机算起的时钟周期数。由于我们需要的是某个代码段运行的毫秒数,因此还需要另外一个 cv::getTickFrequency()。此函数返回没秒内的时钟周期数,用于统计函数(或一段代码)耗费时间的方法如下:
double duration;
duration = static_cast(cv::getTickCount());
colorReduce(image); // 被测试的函数
duration = static_cast(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 |
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(j-1); // 上一行
const uchar* current = image.ptr(j); // 当前行
const uchar* next = image.ptr(j+1); // 下一行
for(int i = 1; i < image.cols - 1; i++) {
*output++ = cv::saturate_cast(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(1,1) = 5.0;
kernel.at(0,1) = -1.0;
kernel.at(2,1) = -1.0;
kernel.at(1,1) = -1.0;
kernel.at(1,2) = -1.0;
// 对图像进行滤波
cv::filter2D(image, result, image.depth(), kernel);
}
// 创建一个图像向量
std::vector 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。
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);
把附录的内容全部敲下来,因为让你更好地理解OpenCV的组织架构,以及它是什么,能做到什么?还有就是samples/cpp/ 文件夹中的范例介绍,应该有最纯正的OpenCV编程风格,可以用于学习。
OpenCV3的改动在哪?
C风格的API很快将会消失,完全被C++的API替代,代码风格更加简洁,不易出错。读者如果想借助OpenCV最新的功能,记得清理代码中C风格API
C++ API将更加简洁
所有的算法都将继承自 cv::Algorithm 接口
大型的模块拆分为小模块,模块将在后面继续讲解。
OpenCV 3 的源代码文件夹:
这些模块的名称都以 cuda 开始,cuda 是显卡制造商 NVIDIA 推出的通用计算语言,在 OpenCV 3 中有大量的模块已经被移植到了 cuda 语言。让我们依次看一下。
3calibration.cpp/: 同时标定三台水平放置的相机。
bagofwords_classification.cpp/: 使用图像检测实现简易的图像搜索功能。
bgfg_gmg.cpp/: 演示 GMG 背景检测算法的使用方式。
bgfg_segm.cpp/: 演示高斯混合背景检测算法的使用方式。
brief_match_test.cpp/: 使用 BRIEF 特征值来匹配两张图像。
build3dmodel.cpp/: 演示如何使用基础矩阵和特征值来创建三维模型。
calibration.cpp/: 完整的多用途标定程序。
calibration_artificial.cpp/: 在程序中生成一个虚拟的相机,并进行标定。
camshiftdemo.cpp/: 读取实时的摄像头数据,并演示基于均值偏移算法的视频跟踪。
chamfer.cpp/: 使用 Chamfer 算法匹配两副边缘图像。
cloning_demo.cpp/: 命令行模式的图像克隆。
cloning_gui.cpp/: 图形界面交互的图像克隆。
connected_components.cpp/: 查找并绘制图像中的连通区域。
contours2.cpp/: 查找并绘制图像中的轮廓。
convexhull.cpp/: 查找并绘制由点的集合组成的凸包。
cout_mat.cpp/: 使用 cout 来输出各种格式化的 Mat 对象。
create_mask.cpp/: 演示如何创建黑白掩码图像。
dbt_face_detection.cpp/: 基于检测的人脸跟踪代码。
delaunay2.cpp/: 通过鼠标交互式地生成 Delaunay 三角形。
demhist.cpp/: 演示直方图的用法。
descriptor_extractor_matcher.cpp/: 演示 features2d 检测框架的用法。
detection_based_tracker_sample.cpp/: 与 dbt_face_detection.cpp 类似。
detector_descriptor_evaluation.cpp/: 评估各种特征检测器和描述子。
detector_descriptor_matcher_evaluation.cpp/: 评估各种特征检测器和匹配器。
dft.cpp/: 演示一幅图像的离散傅里叶变换。
distrans.cpp/: 显示边缘图像的距离变换值。
drawing.cpp/: 演示绘画和文字显示功能。
edge.cpp/: 演示 Canny 边缘检测。
em.cpp/: 对随机生成的数据点进行 EM 聚类。
fabmap_sample.cpp/: 演示 FAB-MAP 图像检索算法。
facerec_demo.cpp/: 人脸识别。
fback.cpp/: 实时的 Farneback 光流跟踪。
ffilldemo.cpp/: 演示 floodFill() 像素填充算法。
filestorage.cpp/: 演示序列化到外部文件,如yml、xml等。
fitellipse.cpp/: 将轮廓点匹配到椭圆。
freak_demo.cpp/: 演示 FREAK 特征值的用法。
gencolors.cpp/: 演示 generateColors()。
generic_descriptor_match.cpp/: 基于 SURF 的两幅图像间的匹配。
grabcut.cpp/: 演示 GrabCut 分割算法。
houghcircles.cpp/: 用霍夫算法检测圆。
houghlines.cpp/: 用霍夫算法检测直线。
hybridtrackingsample.cpp/: 混合跟踪算法(Hybrid Tracker)的演示。
image.cpp/: 来回转换 cv::Mat 和 IplImage。
image_alignment.cpp/: 演示 findTransformECC() 函数。
image_sequence.cpp/: 使用 VideoCapture 对象读取序列帧。
imagelist_creator.cpp/: 创建图像列表到 xml 文件。
inpaint.cpp/: 使用鼠标交互地进行图像修补。
intelperc_capture.cpp/: Intel 感知计算设备相关的函数。
kalman.cpp/: 使用卡尔曼滤波进行二维跟踪。
kmeans.cpp/: Kmeans 聚类算法的演示。
laplace.cpp/: 拉普拉斯边缘检测。
latentsvm_multidetect.cpp/: latentSVM 检测器。
letter_recog.cpp/: 字母识别。
linemod.cpp/: 基于 OpenNI 的体感设备应用。
lkdemo.cpp/: 演示Lukas-Kanade 光流法。
logpolar_bsm.cpp/: 演示 LogPolar 盲点模型。
lsd_lines.cpp/: LSD 线段检测。
matcher_simple.cpp/: SURF 特征检测。
matching_to_many_images.cpp/: 一对多的特征检测。
meanshift_segmentation.cpp/: 演示基于均值漂移的色彩分割函数——meanShiftSegmentation()。
minarea.cpp/: 寻找最小包围盒、包围圆。
morphology2.cpp/: 形态学图像处理。
npr_demo.cpp/: 演示各种非真实感渲染效果。
opencv_version.cpp/: 输出 OpenCV 库的版本号。
openni_capture.cpp/: 演示 OpenNI 相关的体感设备。
pca.cpp/: 基于 PCA 的人脸识别。
peopledetect.cpp/: 基于 cascade 或 hog 进行物体(人)检测。
phase_corr.cpp/: 演示 phaseCorrelate() 函数。
points_classifier.cpp/: 演示各种机器学习算法。
rgbdodometry.cpp/: 对深度传感器如 Kinect 的数据进行处理。
segment_objects.cpp/: 实时地在视频或相机画面中检测前景物体。
shape_example.cpp/: 比较并检索形状。
shape_transformation.cpp/: 用 SURF 特征值检测形状并进行变换。
squares.cpp/: 检测图像中的方块形状。
starter_imagelist.cpp/: 一个 “hello worl” 性质的入门范例。
starter_video.cpp/: 另一个 “hello worl” 性质的入门范例。
stereo_calib.cpp/: 双目视觉的标定。
stereo_match.cpp/: 计算左右视觉的图像的差异,生成点云文件。
stitching.cpp/: 演示图像拼接算法。
stitching_detailed.cpp/: 演示更多参数的图像拼接算法。
textdetection.cpp/: 实时场景中的文字定位与识别。
train_HOG.cpp/: 训练 HOG 分类器。
ufacedetect.cpp/: 人脸检测。
video_homography.cpp/: 使用 FAST 特征值来跟踪平面物体。
videostab.cpp/: 演示 videostab 中各个参数的用法。
watershed.cpp/: 演示著名的分水岭图像分割算法。
本书程序代码及彩图下载:
http://www.sciencep.com/downloads/
https://github.com/ITpublishing