OpenCV相关图形操作整合

这里是关于OpenCV图像处理相关内容的学习笔记,该部分内容是曾经在2019年发布的。

当前博客显示的发布时间非真实时间,而是这些内容在当时发布时的最后发布时间。

机械臂项目组中一个比较关键的地方就是视觉处理,通过摄像头获取点位信息传给机械臂。这之中的视觉核心要通过OpenCV完成。这里把简单的流程记录一下。

前期的准备

OpenCV 3.1.0(最新版本已经更新到了4.x,但是官网下载速度奇慢无比,无奈用了学长给的旧版本)

Visual Studio 2015及更高版本

OpenCV下载好后打开会发现其实是一个自动解压的压缩包,它会把所需文件释放在一个目录里。这里我选择D盘的openCV文件夹。

等待它解压完毕就可以了。

配置环境变量

首先找到配置环境变量的位置(如果你不知道在哪里,可以看我之前写的配置Java环境变量的文章,也可以自行百度),找到系统变量下面的Path,点击编辑。

这里找到之前释放文件的位置,依次找到opencv\build\x64\vc14\bin,比如我解压到了D:\openCV文件夹,我就找到D:\openCV\opencv\build\x64\vc14\bin目录下,把这个目录添加到环境变量里。

配置Visual Studio

打开Visual Studio,新建一个空项目,然后点击上方菜单栏视图→属性管理器。

依次找到Debug|x64Microsoft.Cpp.x64.user,右键属性。

在这个界面里面我们需要修改的有三处,分别是VC++目录下的包含目录库目录,还有链接器目录下的输入目录

点击包含目录,会出现编辑按钮,点击入编辑界面。

在包含目录里点击上方对话框右上角的“新建”图标(黄色的那个),依次选择文件目录\opencv\build\include\opencv2文件目录\opencv\build\include\opencv文件目录\opencv\build\include添加到里面,保存退出。

下面的库目录也是一样的做法。但是库目录的目录变成了文件目录\opencv\build\x64\vc14\lib

然后在链接器目录下,展开目录,选择输入,在附加依赖项里面的最后加入;opencv_world310d.lib(一定注意最前面的分号不要忘)

这样,Visual Studio也配置好了。在这个工程下就可以用OpenCV编写程序了。

简单的示例

新建一个源文件,键入以下代码做测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <opencv2/opencv.hpp>  
#include <iostream>
using namespace cv;
int main() {
Mat src = imread("D:/QQ_files3/MobileFile/kksk.png");
if (src.empty()) {
std::cout << "could not load image" << std::endl;
return -1;
}
namedWindow("test opencv setup", CV_WINDOW_AUTOSIZE);
imshow("test opencv setup", src);
waitKey(0);
return 0;
}

这里用imread加载一个图像并显示,路径是我电脑上的路径。

运行出来是这样的:

Mat对象

保存图像使用的类型为自定义的Mat类型。计算机中把图像储存为一个二维数组。系统为Mat对象自动分配内存,不存在内存泄漏问题,它是一个面向对象的数据结构。Mat对象分为两部分:
头部分和数据部分

在引进Mat对象之前,人们使用IplImage对象来管理图片。这是一个C风格的数据结构,在内存管理上有诸多不便,容易引起一些内存泄漏问题,它自2001年openCV发布后就一直存在,一直到openCV2.0后引进Mat对象,人们就开始把关注点更多的转向使用更方便的Mat。

常用的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
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, const Scalar &s)

简单举一个构造函数的例子:

Mat M(2, 2, CV_8UC3, Scalar(0, 0, 255)),前两个参数分别表示行(row)和列(column),第三个参数常数CV_8UC3表示的含义可以这样解读:8表示每个通道占8位,U表示无符号,C表示char类型,3表示通道数目是3,第四个参数是一个向量,表示初始化每个像素值是多少,向量长度对应通道数目一致。

常用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
void copyTo(Mat mat) // 复制图片  

void convertTo(Mat dst, int type) // 负责转换数据类型不同的Mat,保证其他函数的兼容性

Mat clone() // 也是复制图片

int channels() //获取图像的通道数

int depth() // 获取图片的深度

bool empty() // 判断是否为空

uchar* ptr(i = 0) // 获取按行的图像指针

此外,还有一些其他的方法。像是Scalar()用来颜色赋值,cvtColor()转换颜色空间。

关于部分复制与完全复制。一般情况下只会复制Mat对象的头和指针部分,不会复制数据部分。像是下面这样:

1
2
Mat A = imread(imgFilePath);  
Mat B(A); // 这就只复制头和指针

如果想把Mat对象的头和数据部分一起复制,可以这样写:

1
2
3
Mat F = A.clone();  
Mat G;
A.copyTo(G);

使用Mat对象的四个要点:

  • 输出图像的内存是自动分配的
  • 使用openCV的C++接口,不需要考虑内存分配问题
  • 赋值操作和拷贝函数只会复制头部分
  • 使用clone与copyTo两个函数实现数据完全复制

矩阵掩膜操作

掩膜(mask,又称为kernel)一般用于提高图片的对比度。做出来的效果类似下图:

这个操作可以自己实现,但是openCV已经为我们封装好了。掩膜操作十分简单,用它可以重新计算每个像素的像素值。这个操作可以通过一个公式表示,下面给出,但这个公式其实并不需要记忆。

I(i,j) = 5*I(i,j)-[I(i-1,j) + I(i+1,j) + I(i,j-1) + I(i,j+1)]
自己实现的话也就是用代码把这个公式跑一遍,用图像指针遍历整张图片做一遍操作就可以了。

在openCV里面这个操作被封装成了filter2D这个函数,只需要按要求传入几个参数就可以了。不过之前需要建立一个掩膜。这样写就好:Mat kernel = (Mat_<char>(3, 3) << 0, -1, 0, -1, 5, -1, 0, -1, 0);

最后还有一个小知识点,使用getTickCount()计算运行时间。代码里写的比较明白。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <opencv2/opencv.hpp>  
#include <iostream>
using namespace cv;
int main() {
Mat src = imread("D:/QQ_files3/MobileFile/kksk.png");
Mat dst;
if (src.empty()) {
std::cout << "could not load image" << std::endl;
return -1;
}
namedWindow("src image", CV_WINDOW_AUTOSIZE);
imshow("src image", src);

int offsetx = src.channels();
std::cout << offsetx << std::endl;
// src.channels()获取原图片的通道数。
// 灰度图片的通道为1,RGB图片用三种颜色描述所以通道是3
// 还有4通道为RGBA,A为透明度,2通道(实通道,虚通道)不常见
//dst = Mat::zeros(src.size(), src.type());
//namedWindow("test init", CV_WINDOW_AUTOSIZE);
//imshow("test init", dst);
/*
// 掩膜操作实现图像对比度调整。
int cols = (src.cols - 1) * src.channels();
int offsetx = src.channels();
int rows = src.rows;
dst = Mat::zeros(src.size(), src.type());
// 创建一个黑色的图,每个像素的通道都为0
for (int row = 1; row < (rows - 1); row++) {
const uchar* previous = src.ptr<uchar>(row - 1);
// 获取图像像素矩阵的指针,括号内表示行数
const uchar* current = src.ptr<uchar>(row);
const uchar* next = src.ptr<uchar>(row + 1);
uchar* output = dst.ptr<uchar>(row);
for (int col = offsetx; col < cols; col++) {
output[col] = saturate_cast<uchar>(5 * current[col] - (current[col - offsetx] + current[col + offsetx] + previous[col] + next[col]));
}
// saturate_cast<uchar>()函数为像素范围处理函数
// 它确保RGB值的范围控制在0到255之间,小于0返回0,大于0返回255
}
*/
double t = getTickCount();
Mat kernel = (Mat_<char>(3, 3) << 0, -1, 0, -1, 5, -1, 0, -1, 0);
// 创建小数组
filter2D(src, dst, src.depth(), kernel);
/*
对图像应用可分离线性滤波器(矩阵掩膜操作)。
该函数对图像应用可分离线性滤波器,src的每一行都是用一维内核过滤。
然后,用1D对结果的每一列进行过滤内核核心。最终的结果移位后存储在dst中。
*/
double timeconsume = (getTickCount() - t) / getTickFrequency();
/*
通过查阅原型解释能知道,getTickCount()返回一个“Tick”值,用来表示时间点
getTickFrequency()是一个比较难理解的东西,原文是这样解释:

该函数返回某些架构(如x86、x64、PowerPC)。在其他平台上,该函数相当于getTickCount。
它也可以用来非常精确的时间测量,以及RNG初始化。
getTickCount通常是较好的测量解决方案执行时间。

总之记住它就好了。。用两个时间点之差可以计算运行时间。
*/
std::cout << "time:" << timeconsume << std::endl;

namedWindow("output image", CV_WINDOW_AUTOSIZE);
imshow("output image", dst);
waitKey(0);
return 0;
}

图像的逐像素操作

使用逐像素操作可以整体修改图像的颜色。以取反色为例,核心语句是gray_src.at<uchar>(row, col) = 255 - gray;,这是灰度图的操作,对BGR彩色图的操作后面会提到。首先定义一个gray变量为gray_src.at<uchar>(row, col),它代表gray_src这张图的(row,col)坐标的像素。然后用255减去这个值得到反色。整个过程通过一个二重循环控制rowcol完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Mat src, gray_src;  
int height = src.rows; // 获取图片的高度
int width = src.cols; // 获取图片的宽度
// ps: 外国人所理解的行和列与中国人正好相反,对于他们来说,纵列为行,横列为列。

cvtColor(src, gray_src, CV_BGR2GRAY); // 使用cvtColor函数把原图转化成灰度图

for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
int gray = gray_src.at<uchar>(row, col);
gray_src.at<uchar>(row, col) = 255 - gray;
// 用255减去原来的灰度值得到灰度反色
}
}

下面说说彩色图片的反色操作,尽管你可以直接用bitwise_not(src, notsrc);直接完成反色操作,不过这里还是说一下如果要自己写的话应该怎么写。

我们知道,BGR图像的通道是3。在二重循环取像素的时候,每一个像素点里是包含了三个值的颜色,需要对这三个值都进行255 - x的操作才算是可以。其实这里用的类型就从<uchar>变成了<Vec3b>,通过下标[0] [1] [2]进行访问。下面是核心代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Mat src, dst_src;  
int height = src.rows;
int width = src.cols;
dst_src = src.clone();

for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
// 三通道做法,设定bgr,对三个通道各自进行取反色。
int b = dst_src.at<Vec3b>(row, col)[0];
int g = dst_src.at<Vec3b>(row, col)[1];
int r = dst_src.at<Vec3b>(row, col)[2];
dst_src.at<Vec3b>(row, col)[0] = 255 - b;
dst_src.at<Vec3b>(row, col)[1] = 255 - g;
dst_src.at<Vec3b>(row, col)[2] = 255 - r;
}
}

源代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, gray_src, dst_src;

src = imread("C:/Users/ssdrt/Pictures/pic.jpg");
if (src.empty()) {
cout << "could not load this image" << endl;
return -1;
}

int height = src.rows;
int width = src.cols;
dst_src = src.clone();

cvtColor(src, gray_src, CV_BGR2GRAY);

for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
int gray = gray_src.at<uchar>(row, col);
gray_src.at<uchar>(row, col) = 255 - gray;
// 用255减去原来的灰度值得到灰度反色
}
}

for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
// 三通道做法,设定bgr,对三个通道各自进行取反色。
int b = dst_src.at<Vec3b>(row, col)[0];
int g = dst_src.at<Vec3b>(row, col)[1];
int r = dst_src.at<Vec3b>(row, col)[2];
dst_src.at<Vec3b>(row, col)[0] = 255 - b;
dst_src.at<Vec3b>(row, col)[1] = 255 - g;
dst_src.at<Vec3b>(row, col)[2] = 255 - r;
}
}

namedWindow("test src", CV_WINDOW_AUTOSIZE);
imshow("test src", src);

namedWindow("test gray src", CV_WINDOW_AUTOSIZE);
imshow("test gray src", gray_src);

namedWindow("test reverse src", CV_WINDOW_AUTOSIZE);
imshow("test reverse src", dst_src);

Mat notsrc;

bitwise_not(src, notsrc);

namedWindow("test not src", CV_WINDOW_AUTOSIZE);
imshow("test not src", notsrc);

waitKey(0);
return 0;
}

图像的线性混合操作

简单来讲,是把 两张相同尺寸的图片 (这个一定注意,必须是尺寸相同)按照一定的透明比例混合在一起达到一个融合的效果。它可以通过一个公式来表示。

其中,g(x)代表混合之后的图像,f 0 (x)和f 1
(x)代表两张图片。这里的图片是用函数形式表示的,道理倒是也讲得通:图片可以看成一个二维的矩阵,对于一个给定的x值,总能通过一个对应关系找到对应的y值,就能确定图中一个点的位置。至于那个α,它代表的是混合的时候两张图片的透明度,RGBA格式应该不陌生吧。这里α的取值为(0,1)

在openCV中,给出了混合操作的APIaddWeighted,它接收六个参数,分别为图像1,图像1的α值,图像2,图像2的α值,γ值,输出位置。需要知道的是γ值在这里充当一个校验值,当混合之后图像整体较暗,通过调整γ值就可以把图片的亮度调整回正常。同样的,当亮度较高时将γ值取负就能降低亮度。如果有什么不明白的看代码即可。

我写好之后使用闪4的结局插图测试了一下,效果意外的还不错。

用这两张图进行合成:


合成之后的效果(α=0.4, γ=-10.0):

合成之后的效果(α=0.5, γ=0.0):

源代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src1, src2, dst;
src1 = imread("C:/Users/ssdrt/Pictures/新建文件夹/sen4ed1.png");
src2 = imread("C:/Users/ssdrt/Pictures/新建文件夹/sen4ed2.png");
if (!src1.data) {
cout << "could not load image1!" << endl;
return -1;
}

if (!src2.data) {
cout << "could not load image2!" << endl;
return -1;
}

double alpha = 0.4;
if (src1.rows == src2.rows && src1.cols == src2.cols && src1.type() == src2.type()) {
addWeighted(src1, alpha, src2, (1.0 - alpha), -10.0, dst);
namedWindow("demo", CV_WINDOW_FULLSCREEN);
imshow("demo", dst);

char c;
cout << "save or not?(y/n): ";
cin >> c;
if (c == 'y') {
imwrite("C:/Users/ssdrt/Pictures/新建文件夹/sen4ed4.png", dst);
cout << "image saved!" << endl;
}
else {
cout << "image not saved!" << endl;
}

}
else {
cout << "size error!" << endl;
return -1;
}

waitKey(0);
return 0;
}

调整图像亮度和对比度

对图像的变换可以看作两种方式。一种是像素变换,对点的操作;另一种是邻域变换,对区域的操作。调整图像的亮度和对比度是基于像素变换的。至于邻域变换,它可以用来做图像的卷积,图像特征的提取,图像梯度的计算,模糊操作,平滑操作等等。。。

这里给出调整亮度和对比度的一般公式。

其中β是增益变量,且满足α>0(对于常规图像)。

思考一下,对亮度的提高是怎样体现在公式上呢?不难想到,当图像亮度提高,图像的整体颜色是会发白的,整张图的颜色会越来越向白色靠拢。思考至每个像素点,当图像亮度提高,其RGB值的三个数值也会提高。为什么?因为(255,255,255)是白色,根据之前的讨论,图像向白色靠拢也就意味着每个像素点都像(255,255,255)靠拢。

图像的对比度简单来理解是像素值之间的绝对差距。它的严格定义是这样的:对比度指的是一幅图像中明暗区域最亮的白和最暗的黑之间不同亮度层级的测量。那么,让这个最亮的白和这个最暗的黑之间的差距变大,反映在图中也就是对比度增大了。反映在公式上,我们可以调整α的值改变图像的像素,当像素值成倍增大时,对比度也就增大了。举个不太严谨的例子,假设说原来的最小值和最大值为[2,102],差值为100,通过调整α把它扩大一倍,就变成了[4,204],差值变成了200,则对比度也变成了原来的一倍。

复习一下可能会用到的API:

1
2
3
4
5
6
Mat new_image = Mat::zeros(image.size(),image.type());  
// 创建一张和原图大小一致的空白图像,由于使用了Mat::zeros,确保了所有像素都是(0,0,0)
saturate_cast<uchar>(value);
// 确保像素值不会低于0或高于255
Mat.at<Vec3b>(y,x)[index] = value;
// 给每个像素点通道赋值

做出来的效果是这样的(alpha = 1.2, beta = 30):

代码思路和之前类似,核心语句dst.at<uchar>(row, col) = saturate_cast<uchar>(v * alpha + beta);,即对每个像素点跑一遍之前的公式。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
src = imread("F:/blog配图/avatar1.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_title[] = "input image";
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
imshow(input_title , src);

int height = src.rows;
int width = src.cols;
dst = Mat::zeros(src.size(), src.type());
float alpha = 1.2;
float beta = 30;

for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
if (src.channels() == 1) {
float v = src.at<uchar>(row, col);
dst.at<uchar>(row, col) = saturate_cast<uchar>(v * alpha + beta);
}
else if (src.channels() == 3) {
float b = src.at<Vec3b>(row, col)[0];
float g = src.at<Vec3b>(row, col)[1];
float r = src.at<Vec3b>(row, col)[2];

dst.at<Vec3b>(row, col)[0] = saturate_cast<uchar>(b * alpha + beta);
dst.at<Vec3b>(row, col)[1] = saturate_cast<uchar>(g * alpha + beta);
dst.at<Vec3b>(row, col)[2] = saturate_cast<uchar>(r * alpha + beta);

}
}
}

char output_title[] = "contrast and brightness change demo";
namedWindow(output_title, CV_WINDOW_AUTOSIZE);
imshow(output_title, dst);

waitKey(0);
return 0;
}

模糊图像

Smooth/Blur是图像处理中最简单和常用的操作之一,使用该操作的原因之一就是为了给图像预处理的时候减低噪声。该操作背后是一个卷积计算。通常这些卷积算子计算都是线性操作,所以又叫线性滤波。

计算过程可以这样直观的看一下:

这是一个6×6网格,计算卷积的算子为一个3×3的区域,该区域从左向右,从上向下移动,计算方式为所有黄色块的像素点值求和取平均赋给中间的红色块。每次移动一个像素格。

这种操作叫做归一化盒子滤波,也叫均值滤波。公式如下。

此外还有一种比较有用的滤波器叫高斯滤波。 高斯滤波是将输入数组的每一个像素点与高斯内核卷积,将卷积和当作输出像素值。一维的高斯函数像下图这样:

给出二维高斯函数的表达式。

其中 μ为均值 (峰值对应位置),σ代表标准差 (变量x和变量y各有一个均值,也各有一个标准差),A是归一化系数。

可能会用到的API:

blur(Mat src, Mat dst, Size(xraius, yradius), Point(-1, -1));   
// 均值模糊,Point(-1, -1)为默认中心位置,一般不用改  
GaussianBlur(Mat src, Mat dst, Size(11, 11), sigmax, sigmay);  
// 高斯模糊,其中Size(x, y)中的x和y必须是正奇数  

使用均值模糊之后的效果:

其实很简单。源代码:

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
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
src = imread("F:/blog配图/avatar1.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_title[] = "input image";
char output_title[] = "blur image";
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
namedWindow(output_title, CV_WINDOW_AUTOSIZE);
imshow(input_title, src);

blur(src, dst, Size(5, 5), Point(-1, -1));
imshow(output_title, dst);

waitKey(0);
return 0;
}

稍微修改一下代码得到高斯模糊:

1
2
GaussianBlur(src, dst, Size(111, 111), 1919, 810);  
imshow(output_title, dst);

不过这两种模糊都有一些缺陷。均值模糊无法克服边缘像素信息丢失缺陷,原因是均值滤波是基于平均权重的。高斯模糊部分克服了这种缺陷,但是无法完全避免,因为没有考虑到像素值的不同。


此外还有中值滤波和双边滤波。中值滤波是一种统计排序滤波器,中值对椒盐噪声有很好的抑制作用。双边滤波是边缘保留的滤波方法,避免了边缘信息丢失,保留了图像轮廓不变。

相关API:

medianBlur(Mat src, Mat dest, size); // 中值模糊  
// 中值模糊的size大小必须是正奇数  
bilateralFilter(src, dest, d = 15, 150, 3); // 双边模糊  
/*  
15为计算的半径,半径之内的像素都会被纳入计算,如果提供-1则会根据sigma space参数取值  
150为sigma color,它决定多少差值之内的像素会被计算  
3为sigma space,如果d>0则声明无效,否则根据它来计算d值  
*/  

(这张插图其实不太能很好的体现中值滤波的作用,我这里找不到椒盐噪点比较多的图片)

一行代码即可:medianBlur(src, dst, 3);

其实用它来提升肤质细节也是不错的。还有双边滤波也是。比如使用bilateralFilter(src, dst, 15, 45, 3);可以达到一个类似于美颜的效果(大雾)。

那么,再加一个掩膜让图片锐化一下会怎样呢(滑稽

也许就能达到一个假装ps过的效果了(

膨胀操作与腐蚀操作

它是图像处理中最常用的形态学操作手段。

膨胀:
与卷积操作类似,假设有图像A和结构元素B,结构元素B在A上面移动,其中B定义其中心为锚点,计算B覆盖下A的最大像素值用来替换锚点的像素,其中B作为结构体可以是任意形状。

腐蚀操作与膨胀操作类似,只是把最大像素值换成了最小像素值。它的定义为:
与卷积操作类似,假设有图像A和结构元素B,结构元素B在A上面移动,其中B定义其中心为锚点,计算B覆盖下A的 最小
像素值用来替换锚点的像素,其中B作为结构体可以是任意形状。

相关API:

1  
2  
3  
4  
5  
6  
7  
8  
9  
10  
11  
12  


getStructuringElement(int shape, Size ksize, Point anchor);  
// 第一个参数表示形状  
// (比如 MORPH_RECT 矩形\ MORPH_CROSS 十字形\ MORPH_ELLIPSE 曲线形,有时是圆)  
// 第二个参数表示大小  
// 第三个参数表示锚点,默认是Point(-1, -1),也就是中心像素  
// 这个操作还是很有用的,用它获取结构元素,使图像在它的基础上进行各种操作  

dilate(src, dst, kernel, Point anchor, int iterations, int borderType, const Scalar& borderValue);  
// 膨胀操作,kernel由上面的getStructuringElement得到  

erode(src, dst, kernel, Point anchor, int iterations, int borderType, const Scalar& borderValue);  
// 腐蚀操作,kernel也由上面的getStructuringElement得到  

此外,还可以在图中增加一个滑块用于控制,动态调整结构元素大小。这个操作是通用的,每当用户拖动滑块时都会调用callback函数,以后就可以使用这个操作对图像进行动态编辑了233333

(有一点美中不足的是,trackbar的UI做的相当丑,而且目前我还不知道怎么去改。。不过还好,毕竟这是个图像处理为主的库,应该并不会直接拿来给用户使用)

1  
2  


createTrackbar(const String &trackbarname, const String winName, int *value, int count, Trackbarcallback func, void *userdata = 0);  
// 其中最主要的是callback函数的功能,如果设置为NULL就是说只有update,但是不会调用callback函数。  

当操作为膨胀时,效果是这样的:

当操作为腐蚀时,效果是这样的:

源代码:

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  
33  
34  
35  
36  
37  
38  
39  
40  


#include <opencv2/opencv.hpp>  
#include <iostream>  
#define debug cout << "ok" << endl;  

using namespace cv;  
using namespace std;  

Mat src, dst;  
int element_size = 5;  
int max_size = 21;  
char input_title[] = "input image";  
char output_title[] = "output image";  

void Callback_Demo(int, void*) {  
    int s = element_size * 2 + 1;  
    Mat structureElement = getStructuringElement(MORPH_RECT, Size(s, s), Point(-1, -1));  
    //dilate(src, dst, structureElement, Point(-1, -1), 1);  
    erode(src, dst, structureElement, Point(-1, -1), 1);  
    imshow(output_title, dst);  
}  

int main() {  
    src = imread("D:/QQ_files4/1006607327/FileRecv/MobileFile/img1.jpg");  
    if (!src.data) {  
        cout << "could not load image..." << endl;  
        return -1;  
    }  
    char input_title[] = "input image";  
    char output_title[] = "output image";  
    namedWindow(input_title, CV_WINDOW_AUTOSIZE);  
    namedWindow(output_title, CV_WINDOW_AUTOSIZE);  
    imshow(input_title, src);  

    createTrackbar("Element size: ", output_title, &element_size, max_size, Callback_Demo);  
    Callback_Demo(0, 0);  

    waitKey(0);  
    return 0;  
}  

不难看出,膨胀操作和腐蚀操作是什么样的效果呢?

对于膨胀操作,是最大值替换最小值,直观点讲就是白色替换掉黑色,所以膨胀操作之后图上的白色块会越来越大。对于腐蚀操作,是最小值替换最大值,也就是黑色替换掉白色,所以腐蚀操作之后图上的黑色块会越来越大。

那么这个玩意有什么用呢?当你要提取某个图像中的一个大块用于分析时,如果存在同色的小块进行干扰,则你可以使用腐蚀或者膨胀将其消除。

开操作与闭操作

它其实是上面所说的膨胀操作和腐蚀操作的结合。开操作便是先腐蚀再膨胀。它可以去掉小的对象并且不会对大的对象造成很大影响,假设对象是前景色,背景是黑色。相对的,闭操作就是先膨胀再腐蚀了。

相关API:

1  
2  
3  
4  
5  
6  
7  
8  


morphologyEx(src, dest, int OPT, kernel, int Iteration);  
// src和dest是输入图像和输出图像  
// OPT是形态学操作类型,有以下几种:  
// CV_MOP_OPEN \ CV_MOP_CLOSE  
// CV_MOP_GRADIENT \ CV_MOP_TOPHAT  
// CV_MOP \ CV_MOP_BLACKHAT  
// Mat kernel为结构元素  
// int Iteration为迭代次数,默认为1  

这次为了方便测试,我用画图随便画了一张测试用图,图上有一个我想保留的白色大块和不想保留的白色小块,用开操作就能很好的把这个问题解决。

(可以看到,下图的矩形四角部分也被侵蚀掉了一些,这是因为模式选取不准确,这里用MORPH_RECT好一些,但是我用了MORPH_ELLIPSE)

源代码:

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  


#include <opencv2/opencv.hpp>  
#include <iostream>  
#define debug cout << "ok" << endl;  

using namespace cv;  
using namespace std;  

Mat src, dst;  
char input_title[] = "input image";  
char output_title[] = "output image";  

int main() {  
    src = imread("D:/imgtest.png");  
    if (!src.data) {  
        cout << "could not load image..." << endl;  
        return -1;  
    }  
    char input_title[] = "input image";  
    char output_title[] = "output image";  
    namedWindow(input_title, CV_WINDOW_AUTOSIZE);  
    namedWindow(output_title, CV_WINDOW_AUTOSIZE);  
    imshow(input_title, src);  

    Mat kernel = getStructuringElement(MORPH_ELLIPSE, Size(11, 11), Point(-1, -1));  
    morphologyEx(src, dst, CV_MOP_OPEN, kernel);  
    imshow(output_title, dst);  

    waitKey(0);  
    return 0;  
}  

对于闭操作,它的操作正好相反。如果说开操作的作用是“把多的删掉”,那闭操作就是“把缺的补回来”。

还是假设我们有一个需要提取的白色大块,但是里面有一个黑色的小洞,这时候就可以使用闭操作把小洞补上。

(下图我画的一些小洞比较大,我把Size调到了(17,17)才算可以去掉。。)

源代码与上面开操作类似。

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  


#include <opencv2/opencv.hpp>  
#include <iostream>  
#define debug cout << "ok" << endl;  

using namespace cv;  
using namespace std;  

Mat src, dst;  
char input_title[] = "input image";  
char output_title[] = "output image";  

int main() {  
    src = imread("D:/imgtest.png");  
    if (!src.data) {  
        cout << "could not load image..." << endl;  
        return -1;  
    }  
    char input_title[] = "input image";  
    char output_title[] = "output image";  
    namedWindow(input_title, CV_WINDOW_AUTOSIZE);  
    namedWindow(output_title, CV_WINDOW_AUTOSIZE);  
    imshow(input_title, src);  

    Mat kernel = getStructuringElement(MORPH_RECT, Size(17, 17), Point(-1, -1));  
    morphologyEx(src, dst, CV_MOP_CLOSE, kernel);  
    imshow(output_title, dst);  

    waitKey(0);  
    return 0;  
}  

形态学梯度操作

它又称为基本梯度操作(其他的还有内部梯度和方向梯度),操作可以简单理解为膨胀减去腐蚀。

只需要把morphologyEx(src, dst, CV_MOP_CLOSE, kernel);里面的CV_MOP_CLOSE换成CV_MOP_GRADIENT就行了。。。。

做出来效果是这样,感觉好迷。。

我加个trackbar调一下试试:

嗯。。。感觉好多了

顶帽操作与黑帽操作

顶帽是原图像与开操作之间的差值图像。改一下模式到CV_MOP_TOPHAT就可以。

我们知道,开操作是先腐蚀再膨胀,可以去掉小对象,原图是一个大块加一堆小块,开操作之后图中仅剩一个大块,二者相减后自然是剩下小对象了。如图所示。

对于黑帽操作,它是闭操作与原图像的差值图像。我们知道,闭操作是“把缺的补回来”,这个图像与原图像做差值的话,剩下的就是通过闭操作补回来的色块。仍然是改一下模式到CV_MOP_BLACKHAT就可以。

提取水平线与垂直线

回顾一下,膨胀输出的像素值是结构元素覆盖下输入图像的最大像素值,腐蚀输出的像素值是结构元素覆盖下输入图像的最小像素值。提取水平线与垂直线是形态学操作的一个应用。图像形态学操作的时候,
可以通过自定义的结构元素实现结构元素的一种效果,即对输入图像的一些对象敏感、另外一些对象不敏感,这样就会让敏感的对象改变而不敏感的对象保留输出。通过使用两个最基本的形态学操作膨胀与腐蚀,使用不同的结构元素实现对输入图像的操作,得到想要的结果。比如说要提取水平线,发现垂直线对提取有干扰,就通过一些结构元素的操作把垂直线膨胀掉或者腐蚀掉,然后提取水平线就简单了。

这个膨胀与腐蚀过程可以使用任意形状。常见的形状:矩形、圆、直线、磁盘形,钻石形,十字形等等各种自定义形状。

提取步骤简单来说是下面这么做:

  • 输入彩色图像imread
  • 转化为灰度图像cvtColor
  • 转换为二值图像adaptiveThreshold
  • 定义结构元素
  • 开操作(腐蚀+膨胀)提取水平线与垂直线

其中转换为二值图像adaptiveThreshold这个操作是没有见过的,这里给出原型。

1  
2  
3  
4  
5  
6  
7  
8  


void adaptiveThreshold( InputArray src, OutputArray dst, double maxValue, int adaptiveMethod, int thresholdType, int blockSize, double C );  
// src为输入的灰度图像  
// dst为输出的二值图像  
// maxValue为二值图像的最大值  
// adaptiveMethod为自适应方法,只能是 ADAPTIVE_THRESH_MEAN_C 或 ADAPTIVE_THRESH_GAUSSIAN_C  
// thresholdType为阈值类型  
// blockSize为块大小  
// C是一个常量,可以是正数,0,负数  

这个操作里面有一个小技巧,将输入的src取反(即把src换成~src,让其黑白颠倒,得到的二值图像会比原来更好处理一些)

在没加~的时候是这样的:

加了之后是这样的:

使用对竖线敏感模式擦除竖线之后是这样的:

使用对横线敏感模式擦除横线之后是这样的:

源代码(对横线敏感,若要改成对竖线敏感只需要把vline换成hline就可以了)

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
33
34
35
36
37
38
39
40
41
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, gsrc, binImg, dst;
char input_title[] = "input image";
char gray_title[] = "gray image";
char bin_title[] = "bin image";
char final_title[] = "final image";
int main() {
src = imread("D:/imgtest2.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
namedWindow(gray_title, CV_WINDOW_AUTOSIZE);
namedWindow(bin_title, CV_WINDOW_AUTOSIZE);
namedWindow(final_title, CV_WINDOW_AUTOSIZE);
imshow(input_title, src);

cvtColor(src, gsrc, CV_BGR2GRAY);
imshow(gray_title, gsrc);

adaptiveThreshold(~gsrc, binImg, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY, 15, -2);
imshow(bin_title, binImg);

Mat hline = getStructuringElement(MORPH_RECT, Size(src.cols / 16, 1), Point(-1, -1));
Mat vline = getStructuringElement(MORPH_RECT, Size(1, src.rows / 16), Point(-1, -1));

Mat temp;
erode(binImg, temp, vline);
dilate(temp, dst, vline);
imshow(final_title, dst);

waitKey(0);
return 0;
}

拓展:识别简单的验证码

这里我先自己造一个简单的验证码,并且为其加一些干扰。

然后我们采用矩形结构的掩膜,并对其做一次膨胀和腐蚀,然后用bitwise_not取一下反,最后用blur模糊平滑一下,看看效果如何。

嗯。。。可能对于它来说,这个验证码还是有点难。。效果还算可以吧。。不过至少它可以完整的提取出文字信息了。。

源代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, gsrc, binImg, dst;
char input_title[] = "input image";
char gray_title[] = "gray image";
char bin_title[] = "bin image";
char final_title[] = "final image";
int main() {
src = imread("D:/yzm.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
namedWindow(gray_title, CV_WINDOW_AUTOSIZE);
namedWindow(bin_title, CV_WINDOW_AUTOSIZE);
namedWindow(final_title, CV_WINDOW_AUTOSIZE);
imshow(input_title, src);

cvtColor(src, gsrc, CV_BGR2GRAY);
imshow(gray_title, gsrc);

adaptiveThreshold(~gsrc, binImg, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY, 15, -2);
imshow(bin_title, binImg);

// Mat hline = getStructuringElement(MORPH_RECT, Size(src.cols / 16, 1), Point(-1, -1));
// Mat vline = getStructuringElement(MORPH_RECT, Size(1, src.rows / 16), Point(-1, -1));
Mat kernel = getStructuringElement(MORPH_RECT, Size(2, 2), Point(-1, -1));

Mat temp;
erode(binImg, temp, kernel);
dilate(temp, dst, kernel);

bitwise_not(dst, dst);
blur(dst, dst, Size(1, 1), Point(-1, -1));

imshow(final_title, dst);

waitKey(0);
return 0;
}

图像金字塔

图像金字塔是图像多尺度表达的一种,是一种以多分辨率来解释图像的有效但概念简单的结构。一幅图像的金字塔是一系列以金字塔形状排列的分辨率逐步降低,且来源于同一张原始图的图像集合。其通过梯次向下采样获得,直到达到某个终止条件才停止采样。我们将一层一层的图像比喻成金字塔,层级越高,则图像越小,分辨率越低。

——摘自百度百科“图像金字塔”

这是一个简单的示意图。

简单来讲,当图像从顶向下进行采样时,由于包含的色块增多,分辨率就会逐步提高,这便是放大图片的效果,称为上采样。相反的,当图像从底向上进行采样时,分辨率就会逐步降低,这便是缩小图片的效果,称为降采样。

高斯金字塔多用于降采样。它是从底向上,逐层采样得到。降采样之后图像的大小是原图像的四分之一(长宽都变为原来的一半),在实现上是对原图像删除偶数行和偶数列即可。它的生成过程分为两步。首先对整张图片进行高斯模糊,然后删除当前层的偶数行和偶数列,即可得到上一层的图像。

此外,还有拉普拉斯采样。

拓展概念: 高斯不同(Difference of Gaussian,DOG)
就是把同一张图像在不同的参数下做高斯模糊之后的结果相减,得到的输出图像,这样的图像称为高斯不同。高斯不同是图像的内在特征,在灰度图像增强,角点检测中经常用到。

相关API:

1
2
3
4
pyrUp(Mat src, Mat dst, Size(src.cols*2, src.rows*2));  
// 上采样,生成的图像是原来的四倍大小,长宽各自变为原来的二倍
pyrDown(Mat src, Mat dst, Size(src.cols*2, src.rows*2));
// 降采样,生成的图像是原来的四分之一大小,长宽各自变为原来的二分之一

放大图像测试:

缩小图像测试:

源代码(缩小):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, dst;
char input_title[] = "input image";
char output_title[] = "output image";
int main() {
src = imread("D:/pics/cover.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
imshow(input_title, src);
pyrDown(src, dst, Size(src.cols / 2, src.rows / 2));
imshow(output_title, dst);

waitKey(0);
return 0;
}

通过上采样和降采样操作后得到高斯不同(DOG):

在源代码中使用了两个新的API,一个是substract,另一个是normalizesubstract顾名思义是进行相减操作,而normalize的作用比较不明确,bing查得其作用为归一化输入数组,使它的范数或者数值范围在一定的范围内。它支持多种归一化类型,其中这里用的是NORM_MINMAX
数组的数值被平移或缩放到一个指定的范围,线性归一化,一般较常用。

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
33
34
35
36
37
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, srcUp, srcDown;
char input_title[] = "input image";
char Up_title[] = "srcUp image";
char Down_title[] = "srcDown image";
char DOG_title[] = "DOG image";
int main() {
src = imread("D:/pics/cover.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
imshow(input_title, src);

pyrDown(src, srcDown, Size(src.cols / 2, src.rows / 2));
imshow(Down_title, srcDown);

pyrUp(src, srcUp, Size(src.cols * 2, src.rows * 2));
imshow(Up_title, srcUp);

Mat gray_src, g1, g2, dogImg;
cvtColor(src, gray_src, CV_BGR2GRAY);
GaussianBlur(gray_src, g1, Size(3, 3), 0, 0);
GaussianBlur(g1, g2, Size(3, 3), 0);
subtract(g1, g2, dogImg, Mat());
normalize(dogImg, dogImg, 255, 0, NORM_MINMAX);
imshow(DOG_title, dogImg);

waitKey(0);
return 0;
}

注意到当Size()的取值变大时,高斯不同操作结果后的图像轮廓越清晰。

阈值类型

阈值二值化(threshold binary) ,设定一个标线,超过此标线的按满处理,不足此标线的按0处理。

阈值反二值化(threshold binary inverted) ,设定一个标线,超过此标线的按0处理,不足此标线的按满处理。

截断(truncate) ,设定一个标线,超过此标线的取标线值,不足此标线的不动。

阈值取零(threshold to zero) ,设定一个标线,超过此标线的不动,不足此标线的按0处理。

阈值反取零(threshold to zero inverted) ,设定一个标线,超过此标线的按0处理,不足此标线的不动。

那么在OpenCV里,如何去确定使用什么阈值呢?其实已经给了相关的参数。

其中有两个比较特殊的THRESH_OTSUTHRESH_TRIANGLE,它们可以调用Otsu算法或Triangle算法对当前图片选择合适的阈值。
至于THRESH_MASK,我没查到这东西是干啥的,OpenCV社区中有回帖这么说:

obsolete constant? in opencv 2.2 (nov-2010) you can find THRESH_MASK
https://github.com/opencv/opencv/blob…
In opencv 4 it is defined but not used.

LBerger (Apr 15 ‘19)

然后我在GitHub里的源码中也没找到关于THRESH_MASK的有关信息。。。算了,不管他了

代码测试

先来测试阈值二值化的效果,看起来127的效果并不是很好,使用trackbar稍微调整了一下,192看起来还可以:

(原图裂了)

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
33
34
35
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, gsrc, dst;
int thresholdval = 127;
int threshold_max = 255;
const char* input_title = "src image";
const char* output_title = "binary image";

void Threshold_Demo(int, void*) {
cvtColor(src, gsrc, CV_BGR2GRAY);
threshold(gsrc, dst, thresholdval, threshold_max, THRESH_BINARY);
imshow(output_title, dst);
}

int main() {
src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
namedWindow(output_title, CV_WINDOW_AUTOSIZE);
imshow(input_title, src);

createTrackbar("Threshold Value:", output_title, &thresholdval, threshold_max, Threshold_Demo);
Threshold_Demo(0, 0);

waitKey(0);
return 0;
}

再来测试一下阈值反二值化:(原图裂了)

妈耶好恐怖。。。。还是192的时候看起来比较合适。

然后是截断、阈值取零、阈值反取零,来回调整还是有些麻烦,不如再借助一下trackbar让它能同时调整一下模式:(原图裂了)

其实能用trackbar调整模式的原因是什么呢?可以尝试一下直接输出上表中那几个THRESH_打头的参数,你会发现它们其实就是0~4的整数值。

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
33
34
35
36
37
38
#include <opencv2/opencv.hpp>  
#include <iostream>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;

Mat src, gsrc, dst;
int thresholdval = 127;
int threshold_max = 255;
int type_value = 2;
int type_max = 4;
const char* input_title = "src image";
const char* output_title = "binary image";

void Threshold_Demo(int, void*) {
cvtColor(src, gsrc, CV_BGR2GRAY);
threshold(gsrc, dst, thresholdval, threshold_max, type_value);
imshow(output_title, dst);
}

int main() {
src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
namedWindow(input_title, CV_WINDOW_AUTOSIZE);
namedWindow(output_title, CV_WINDOW_AUTOSIZE);
imshow(input_title, src);

createTrackbar("Threshold Value:", output_title, &thresholdval, threshold_max, Threshold_Demo);
createTrackbar("Type Value:", output_title, &type_value, type_max, Threshold_Demo);
Threshold_Demo(0, 0);

waitKey(0);
return 0;
}

刚才说到Otsu算法和Triangle算法,可以通过它进行调试,只需要将threshold(gsrc, dst, thresholdval, threshold_max, type_value);中的type_value换成THRESH_OTSU | type_value或者THRESH_TRIANGLE | type_value就可以。此时它会根据算法计算合适的阈值,这时拖动阈值滑块就无效了。(原图裂了)

复习卷积

卷积 是图像处理中的一个操作,是kernel在图像的每个像素上的操作。kernel本质上是一个固定大小的矩阵数组,其中心点称之为锚点。

卷积如何工作?把kernel放到像素数组之上,求锚点周围覆盖的像素乘积之和(包括锚点), 用来替换锚点覆盖下像素点值称为卷积处理。

卷积有三个作用。

  • 模糊图像
  • 提取边缘
  • 进行图像增强,比如锐化

采用卷积进行图像的处理,每一个小块的卷积和称之为 算子
。根据对图像的操作不同,算子也就不同。有一些比较经典的算子比如Robert算子,Sobel算子和Laplance算子。

简单的算子应用

Robert算子,X方向:

Robert算子,Y方向:

两个算子体现了在给定方向梯度上差异的最大体现。实际上,Robert算子是一种梯度算子。

尝试一下将两个算子计算出的结果相加后取反输出,是这样的:

源代码:

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
33
34
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
Mat robertx, roberty;
int ksize = 0;

src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

imshow(input_WIN, src);
// Robert operator, direction X
Mat kernelx = (Mat_<int>(2, 2) << 1, 0, 0, -1);
filter2D(src, robertx, -1, kernelx, Point(-1, -1), 0, 0);

// Robert operator, direction Y
Mat kernely = (Mat_<int>(2, 2) << 0, 1, -1, 0);
filter2D(src, roberty, -1, kernely, Point(-1, -1), 0, 0);

add(robertx, roberty, dst);

imshow(output_WIN, ~dst);
waitKey(0);
return 0;
}

Sobel算子:

还是相加后取反输出,结果是这样,看起来要比Robert强一些。。。?它体现的差异值要比Robert更大一些,所以看起来效果会更好。

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
33
34
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
Mat sobelx, sobely;
int ksize = 0;

src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

imshow(input_WIN, src);
// Sobel operator, direction X
Mat kernelx = (Mat_<int>(3, 3) << -1, 0, 1, -2, 0, 2, -1, 0, 1);
filter2D(src, sobelx, -1, kernelx, Point(-1, -1), 0, 0);

// Sobel operator, direction Y
Mat kernely = (Mat_<int>(3, 3) << -1, -2, -1, 0, 0, 0, 1, 2, 1);
filter2D(src, sobely, -1, kernely, Point(-1, -1), 0, 0);

add(sobelx, sobely, dst);

imshow(output_WIN, ~dst);
waitKey(0);
return 0;
}

Laplance算子:

(仍然是对dst取反后输出)

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
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
int ksize = 0;

src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

imshow(input_WIN, src);

// Laplance operator
Mat kernel = (Mat_<int>(3, 3) << 0, -1, 0, -1, 4, -1, 0, -1, 0);
filter2D(src, dst, -1, kernel, Point(-1, -1), 0, 0);

imshow(output_WIN, ~dst);
waitKey(0);
return 0;
}

自定义卷积模糊

核心在flit2D这个方法上面。这里给出原型解释:

1
2
3
4
5
6
7
8
9
void filter2D(   
InputArray src, // 输入图像
OutputArray dst, // 输出图像
int ddepth, // 图像深度,一般默认-1,即自动
InputArray kernel, // 卷积模板,输入一个Mat对象
Point anchor = Point(-1,-1), // 锚点位置,一般默认Point(-1, -1),即中心点
double delta = 0, // 计算出来的像素+delta
int borderType = BORDER_DEFAULT // 不清楚这个是干嘛的。。
);

采用一些方法可以让它动起来,这个是静态的效果图(懒得录gif了)

源代码:

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
33
34
35
36
37
38
39
40
41
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
int ksize = 0;

src = imread("D:/pics/lovelive.jpg");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

imshow(input_WIN, src);

// Custom operator demo
int c = 0;
int index = 0;
int dir = 1;
while (true) {
c = waitKey(1);
if ((char)c == 27) // 27是esc键
break;
else {
ksize = 4 + index * 2 + 1;
Mat kernel = Mat::ones(Size(ksize, ksize), CV_32F) / (float)(ksize * ksize);
filter2D(src, dst, -1, kernel, Point(-1, -1));
index += dir;
if (index == 8 || index == 0)
dir = -dir;
imshow(output_WIN, dst);
}
}
waitKey(0);
return 0;
}

其中Mat::ones是以前没见过的方法,给出的原解释是:

The method returns a Matlab-style identity matrix initializer, similarly to
Mat::zeros. Similarly to Mat::ones, you can use a scale operation to create
a scaled identity matrix efficiently.

翻译:该方法返回一个matlab样式的单位矩阵初始化器,类似于Mat::zeros。与Mat::ones类似,您可以使用缩放操作来高效地创建缩放的单位矩阵。

应该是一个用来初始化的东西吧……

卷积边界问题

图像卷积的时候边界像素,不能被卷积操作。原因在于边界像素没有完全跟kernel重叠,所以当3×3滤波的时候会有1个像素的边缘没有被处理,5×5滤波的时候会有2个像素的边缘没有被处理。

(Emmmmm。。具体原因也许我会再探究一下,对卷积的计算原理上感觉消化的不是很透彻)

处理边缘

在卷积开始之前增加边缘像素,
填充的像素值为0或者RGB黑色,比如3×3在四周各填充1个像素的边缘,这样就能确保图像的边缘被处理。在卷积处理之后,再把这些边缘去掉。openCV中有一个默认的处理方法,即BORDER_DEFAULT,此外还有BORDER_CONSTANT可以用指定像素值填充边缘,BORDER_REPLICATE用已知的边缘像素值填充边缘,BORDER_WRAP用另外一边的像素来补偿填充。

相关API:给图像添加边缘

1
2
3
4
5
6
7
8
9
10
copyMakeBorder {  
Mat src, // 输入图像
Mat dst, // 输出图像
int top, // 边缘长度,一般来说上下左右都取相同值
int bottom,
int left,
int right,
int borderType, // 边缘类型
Scalar value // 颜色
}

简单测试

这里通过按键来控制使用哪一个边缘类型。不难看出,不同的borderType总会让dst的长和宽增加(边缘),只是增加的形式不同。

这是default:

这是wrap:

这是constant:

(由于采用了随机颜色,其实这个颜色是在不断变化的)

这是replicate:

代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
int ksize = 0;

src = imread("D:/pic.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

int top = (int)(0.05 * src.rows);
int bottom = (int)(0.05 * src.rows);
int left = (int)(0.05 * src.cols);
int right = (int)(0.05 * src.cols);

// 采用随机数以得到随机划线的效果
RNG rng(12345);

int borderType = BORDER_DEFAULT;
imshow(input_WIN, src);
int c = 0;
while (true) {
c = waitKey(500);
if ((char)c == 27) // esc
break;
else {
if ((char)c == 'r')
borderType = BORDER_REPLICATE;
else if ((char)c == 'w')
borderType = BORDER_WRAP;
else if ((char)c == 'c')
borderType = BORDER_CONSTANT;
else
borderType = BORDER_DEFAULT;
Scalar color = Scalar(rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255));
copyMakeBorder(src, dst, top, bottom, left, right, borderType, color);
imshow(output_WIN, dst);

}
}

waitKey(0);
return 0;
}

用高斯模糊测试一下:

源代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst, dst2, dst3;
int ksize = 0;

src = imread("D:/pic.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";
char no_border_output_WIN[] = "no border output image";

int top = (int)(0.05 * src.rows);
int bottom = (int)(0.05 * src.rows);
int left = (int)(0.05 * src.cols);
int right = (int)(0.05 * src.cols);

// 采用随机数以得到随机划线的效果
RNG rng(12345);

int borderType = BORDER_DEFAULT;
imshow(input_WIN, src);
int c = 0;
while (true) {
c = waitKey(500);
if ((char)c == 27) // esc
break;
else {
if ((char)c == 'r')
borderType = BORDER_REPLICATE;
else if ((char)c == 'w')
borderType = BORDER_WRAP;
else if ((char)c == 'c')
borderType = BORDER_CONSTANT;
else
borderType = BORDER_DEFAULT;
Scalar color = Scalar(rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255));
copyMakeBorder(src, dst, top, bottom, left, right, borderType, color);
GaussianBlur(dst, dst2, Size(5, 5), 0, 0);
GaussianBlur(src, dst3, Size(5, 5), 0, 0);
imshow(no_border_output_WIN, dst3);
imshow(output_WIN, dst2);

}
}

waitKey(0);
return 0;
}

Sobel算子

Sobel算子是离散微分算子(discrete differentiation
operator),用来计算图像灰度的近似梯度。梯度越大的地方,越可能是图像的边缘。

比如在一幅图的一小块区域当中出现了黑与白之间的渐变,像是人脸轮廓与头发之间的渐变,这种渐变通常看起来是瞬间的,其实它们在中间总会有一个很短的变化过程,或者说,像素发生了跃迁。在头发区域是黑色的,在人脸区域是白色的,而它们之间总会有一段颜色的变化,这个变化是按照慢-
快-慢的规律进行的。如果我们把像素渐变的这个过程抽象成函数,也许它看起来像是一个三次函数。

那么它总能找到一个变化率最高的点,不难理解,只需一次求导就可以得到这个点。Sobel算子便是会判断这里为图像的边缘。

Sobel算子的功能集合了高斯平滑和微分求导两个方面,所以又称一阶微分算子、求导算子。在水平和垂直两个方向上求导,可以得到图像X方向与Y方向的梯度图像。所以说,只要说到求图像的梯度图像,想到使用Sobel算子就好。

它是怎么实现的呢?简单来说,它会扩大选定方向上的差异,通过给算子两侧赋予一定的值,让被处理的图片的位置上的像素值出现更大的差来扩大差异,达到一个凸显轮廓的效果。

水平梯度上:

垂直梯度上:

最终图像梯度(上式为准确定义公式,下式是为了减少计算机的计算量采用的近似公式):

然而在实际应用中,Sobel算子在kernel=3时不是很准确,OpenCV中采用了改进版本的Scharr算子,它的算子如下:

Sobel算子与Scharr算子的相关API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Sobel (  
InputArray src, // 输入图像
OutputArray dst,// 输出图像,大小与输入图像一致
int depth, // 输出图像深度 一定要比输出图像的深度大或者深度相等,不可以比他小。当然,遇事不决写-1。-1代表与原来深度相同。
int dx, // X方向,几阶导数
int dy, // Y方向,几阶导数.
int ksize, // SOBEL算子kernel大小,必须是1、3、5、7、9等奇数
double scale = 1,
double delta = 0,
int borderType = BORDER_DEFAULT
)

Scharr (
InputArray src, // 输入图像
OutputArray dst, // 输出图像,大小与输入图像一致
int depth, // 输出图像深度.
int dx, // X方向,几阶导数
int dy, // Y方向,几阶导数.
double scale = 1,
double delta = 0,
int borderType = BORDER_DEFAULT
)

对Sobel算子的应用

一般可以用来做边缘提取(和上一节说的那个边缘不是一个边缘= =)。

边缘是什么?是像素值发生跃迁的地方,是图像的显著特征之一。在图像特征提取、对象检测、模式识别等方面都有重要的作用。

那么如何捕捉/提取边缘呢?只需要对图像求它的一阶导数就可以。

我们知道,Δ = f(x) – f(x-1),Δ越大,说明像素在x方向变化越大,边缘信号越强。

应用操作分四步,首先做一次高斯模糊,使图片变得平滑,得到一个降噪的效果。然后使用cvtColor()把图片转化成灰度,再对图片求一下x方向和y方向上的梯度。最后混合一下xy方向上的梯度就可以了。一般叫混合之后的图像为振幅图像。信号越强,振幅也就越大。

源代码:

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
33
34
35
36
37
38
39
40
41
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst, dst2, dst3;
int ksize = 0;

src = imread("D:/pic2.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char outputx_WIN[] = "output x image";
char outputy_WIN[] = "output y image";

int borderType = BORDER_DEFAULT;
imshow(input_WIN, src);

Mat gray_src;
GaussianBlur(src, dst, Size(3, 3), 0, 0);
cvtColor(dst, gray_src, CV_BGR2GRAY);

Mat xgrad, ygrad;
Sobel(gray_src, xgrad, CV_16S, 0, 1, 3);
Sobel(gray_src, ygrad, CV_16S, 1, 0, 3);

// Performs a look-up table transform of an array.
// 没看懂函数原型解释讲的是什么玩意,不过它可以处理显示不正常。。
// 似乎是在灰度图像上进行一个彩色绘制达到显示图像的效果?
convertScaleAbs(xgrad, xgrad);
convertScaleAbs(ygrad, ygrad);

imshow(outputx_WIN, xgrad);
imshow(outputy_WIN, ygrad);
waitKey(0);
return 0;
}

这样做出来的效果似乎和之前用的Sobel算子效果不太一样,问题出现在哪里呢?

是深度问题。我们把深度设为-1再试试。

可以看到似乎颜色的“力道”稍浅了一些。当“力道”较大时,图片处理后的细节就较为明显。

最后让我们用addWeighted把两个方向上的图片拼合到一起。

最终的代码:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst, dst2, dst3;
int ksize = 0;

src = imread("D:/pic2.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char outputx_WIN[] = "output x image";
char outputy_WIN[] = "output y image";
char output_WIN[] = "final image";

int borderType = BORDER_DEFAULT;
imshow(input_WIN, src);

Mat gray_src;
GaussianBlur(src, dst, Size(3, 3), 0, 0);
cvtColor(dst, gray_src, CV_BGR2GRAY);

Mat xgrad, ygrad, final;
Sobel(gray_src, xgrad, -1, 0, 1, 3);
Sobel(gray_src, ygrad, -1, 1, 0, 3);

// Performs a look-up table transform of an array.
// 没看懂函数原型解释讲的是什么玩意,不过它可以处理显示不正常。。
// 似乎是在灰度图像上进行一个彩色绘制达到显示图像的效果?
convertScaleAbs(xgrad, xgrad);
convertScaleAbs(ygrad, ygrad);

//imshow(outputx_WIN, xgrad);
//imshow(outputy_WIN, ygrad);
addWeighted(xgrad, 0.5, ygrad, 0.5, 0, final);

imshow(output_WIN, final);
waitKey(0);
return 0;
}

Laplace算子

Laplace算子也是通过卷积求取图像的边缘。只不过它是用一种求二阶导数的方法。

在二阶导数的时候,最大变化处的值为零即边缘是零值。通过二阶导数计算,依据此理论我们可以计算图像二阶导数,提取边缘。这便是Laplance算子所依靠的数学依据。

这里给出公式。可以看到它是由在x和y方向上的二阶偏导数组成。

Opencv已经提供了相关API cv::Laplancian

处理流程

  • 高斯模糊去噪声(使用GaussianBlur()

  • 转换为灰度图像(使用cvtColor()

  • Laplance算子-二阶导数计算(使用Laplancian

  • 取绝对值(使用convertScaleAbs()

  • 显示结果

可以看到与做Sobel操作时的有点类似。

简单测试

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
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

int main() {
Mat src, dst;
src = imread("D:/pic3.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
char input_WIN[] = "input image";
char output_WIN[] = "output image";

namedWindow(input_WIN, CV_WINDOW_AUTOSIZE);
imshow(input_WIN, src);

Mat gsrc, edgeimg;
GaussianBlur(src, dst, Size(3, 3), 0, 0);
cvtColor(dst, gsrc, CV_BGR2GRAY);

Laplacian(src, edgeimg, CV_16S, 3); // 后注 参数1应是gsrc
convertScaleAbs(edgeimg, edgeimg);

namedWindow(output_WIN, CV_WINDOW_AUTOSIZE);
imshow(output_WIN, edgeimg);
waitKey(0);
return 0;
}

嗯,当我写到这里时才发现,貌似有些不太对劲,因为我没有传入gsrc,而传入了彩色的src,所以导致效果图看起来怪怪的。

稍作修改。

算子优化

可以看到,用Laplance算子得出的图片噪点较多,我们着手处理解决一下这个问题。

采用什么方法?即的阈值操作threshold

在输出前增加一行代码即可:threshold(edgeimg, edgeimg, 0, 255, THRESH_OTSU | THRESH_BINARY);

Canny边缘检测算法理论知识

Canny边缘检测算法最早在1986年提出,它是一种很好的边缘检测方法,也是很常用,很实用的边缘检测方法。目前现代的很多边缘检测应用中都采用了Canny算法。

它的使用方法也很简单。

  • 高斯模糊 作用是对图像进行降噪,将可能影响算法的点去掉
  • 灰度转换
  • 计算梯度(SobelScharr
  • 非最大信号抑制
  • 高低阈值输出二值图像

非最大信号抑制是一步新的操作,做这个操作的原因是边缘只能有一个,如果存在多个边缘则只能留下一个。为了清晰起见只留下信号最强的边缘,将其他边缘消除。做法很简单,判断像素点在切向和法向是否为最大,如果不是则消除。

高低阈值是什么?我们做完非最大信号抑制之后就已经能得到一个边缘分明的图像了,但即使这样仍然不能达到对结果图像的要求。我们设定一个高阈值和低阈值,大于高阈值的保留,小于低阈值的舍弃,在中间范围的部分要做一个阈值连接操作。靠近高阈值的按保留做,靠近低阈值的按舍弃做。

浅谈非最大信号抑制

非最大值抑制能帮助保留局部最大梯度而抑制所有其他梯度值。这意味着只保留了梯度变化中最锐利的位置。通过前面的讨论我们可以知道,上图所示操作是一种体现在xy方向上的梯度差异。(这不就是Sobel算子吗)没有见过的是最下面的θ,它等于一个反三角函数。这个函数又由Gy和Gx的大小确定。它叫做梯度幅值,代表梯度的具体方向。取值范围看下图。

梯度角度θ范围从弧度-
π到π,然后把它近似到四个方向,分别代表水平,垂直和两个对角线方向(0°,45°,90°,135°)。可以对它进行一下分割,落在每个区域的梯度角给一个特定值,代表四个方向之一。

它的算法如下:

1. 比较当前点的梯度强度和正负梯度方向点的梯度强度。
2. 如果当前点的梯度强度和同方向的其他点的梯度强度相比较是最大,保留其值。否则抑制,即设为0。比如当前点的方向指向正上方90°方向,那它需要和垂直方向,它的正上方和正下方的像素比较。

高低阈值输出二值图像

T1, T2为阈值,凡是高于T2的都保留,凡是小于T1都丢弃,从高于T2的像素出发,凡是大T1而且相互连接的,都保留。最终得到一个输出二值图像。

推荐的高低阈值比值为 T2: T1 = 3:1/2:1其中T2为高阈值,T1为低阈值。

简单实践

相关API:cv::Canny

1
2
3
4
5
6
7
8
Canny(  
InputArray src, // 8-bit的输入图像
OutputArray edges,// 输出边缘图像, 一般都是二值图像,背景是黑色
double threshold1,// 低阈值,常取高阈值的1/2或者1/3
double threshold2,// 高阈值
int aptertureSize,// Soble算子的size,通常3x3,取值3
bool L2gradient // 选择 true表示是L2来归一化,否则用L1归一化,一般用L1

对边缘的检测效果还是很不错的。虽然加了trackbar,但是发现调还不如不调效果好。。

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
33
34
35
36
37
38
39
40
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace cv;
using namespace std;

Mat src, gray_src, dst;
int t1_value = 50;
int max_value = 255;
const char* OUTPUT_TITLE = "Canny Result";

void Canny_Demo(int, void*) {
Mat edge_output;
blur(gray_src, gray_src, Size(3, 3), Point(-1, -1), BORDER_DEFAULT);
Canny(gray_src, edge_output, t1_value, t1_value * 2, 3, false);

//dst.create(src.size(), src.type());
//src.copyTo(dst, edge_output);
imshow(OUTPUT_TITLE, ~edge_output);
}

int main() {
src = imread("D:/pic4.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}

char INPUT_TITLE[] = "input image";
namedWindow(INPUT_TITLE, CV_WINDOW_AUTOSIZE);
namedWindow(OUTPUT_TITLE, CV_WINDOW_AUTOSIZE);
imshow(INPUT_TITLE, src);

cvtColor(src, gray_src, CV_BGR2GRAY);
createTrackbar("Threshold Value:", OUTPUT_TITLE, &t1_value, max_value, Canny_Demo);
Canny_Demo(0, 0);

waitKey(0);
return 0;
}

基于Canny边缘检测算法的轮廓发现

轮廓发现是基于图像边缘提取的基础寻找对象轮廓的方法。所以边缘提取的阈值选定会影响最终轮廓发现结果。轮廓与边缘的概念是非常相似的,但是也不完全一致。

简单测试

可能会用到的API:

findContours发现轮廓 在灰度图像上发现轮廓

drawContours绘制轮廓 根据发现的轮廓把它绘制出来

在二值图像上发现轮廓使用APIcv::findContours

1
2
3
4
5
6
7
8
9
10
11
findContours(  
InputOutputArray binImg,
// 输入图像,非0的像素被看成1,0的像素值保持不变,8-bit
OutputArrayOfArrays contours,
// 全部发现的轮廓对象
OutputArray, hierachy
// 图该的拓扑结构,可选,该轮廓发现算法正是基于图像拓扑结构实现。
int mode, // 轮廓返回的模式
int method,// 发现方法
Point offset=Point()// 轮廓像素的位移,默认(0, 0)没有位移
)

在二值图像上发现轮廓使用API cv::findContours之后对发现的轮廓数据进行绘制显示

1
2
3
4
5
6
7
8
9
10
11
drawContours(  
InputOutputArray binImg, // 输出图像
OutputArrayOfArrays contours,// 全部发现的轮廓对象
Int contourIdx// 轮廓索引号
const Scalar & color,// 绘制时候颜色
int thickness,// 绘制线宽
int lineType ,// 线的类型LINE_8
InputArray hierarchy,// 拓扑结构图
int maxlevel,// 最大层数, 0只绘制当前的,1表示绘制绘制当前及其内嵌的轮廓
Point offset=Point()// 轮廓位移,可选
)

做法是这样的:

  • 输入图像转化为灰度图
  • 使用Canny算法进行边缘检测,得到二值图像
  • 使用findContours寻找轮廓
  • 使用drawContours绘制轮廓

感觉自己手里的图不是太适合拿来做检测,毕竟插画都太花里胡哨。这次我直接拿工业相机拍下的离心机内部照片做测试。尝试采用第二种方案,去掉二维码检测边缘(第一种方案为重新贴二维码,暂时不考虑)。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <opencv2/opencv.hpp>  
#include <iostream>
#include <vector>
#define debug cout << "ok" << endl;

using namespace cv;
using namespace std;
Mat src, gsrc, dst;
int threshold_value = 50;
int max_value = 255;
const char* output_win = "Final Result";
const char* input_win = "src image";

void demo_contours(int, void*) {
Mat cannyOutPut;
vector<vector<Point>> contours;
vector<Vec4i> hierachy;
Canny(gsrc, cannyOutPut, threshold_value, threshold_value * 2, 3, false);
findContours(cannyOutPut, contours, hierachy, RETR_TREE, CHAIN_APPROX_SIMPLE, Point(0, 0));

dst = Mat::zeros(src.size(), CV_8UC3);
RNG rng(12345);
for (size_t i = 0; i < contours.size(); i++) {
Scalar color = Scalar(rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255));
drawContours(dst, contours, i, color, 2, 8, hierachy, 0, Point(0, 0));
}
imshow(output_win, dst);
}

int main() {
src = imread("D:/pic6.png");
if (!src.data) {
cout << "could not load image..." << endl;
return -1;
}
namedWindow(output_win, CV_WINDOW_AUTOSIZE);
imshow(input_win, src);
cvtColor(src, gsrc, CV_BGR2GRAY);

const char* trackBarTitle = "Threshold Value";
createTrackbar(trackBarTitle, output_win, &threshold_value, max_value, demo_contours);
demo_contours(0, 0);

waitKey(0);
return 0;
}

嗯,检测轮廓还是比较有效果的,只是它现在线条还比较乱。

也许学了后面的绘制矩形框和圆形框就可以定位出矩形了。

此后的操作便是求出矩形的中心坐标,传给机器,就完成目标了。

慢慢来,加油。

绘制轮廓周围矩形框和圆形框

之前说到各种关于图像的操作,到现在为止我们可以把图像的轮廓简单的显示出来,今天要解决的是如何根据轮廓绘制矩形框和圆形框。

首先需要一个预处理。对于此绘制算法来说,如果轮廓是一个多边形,那么多边形边数越多,画起来就越难。为了解决这个问题,需要引入一个新的API。OpenCV中使用RDP算法实现减小多边形边数且尽量令其大小不变。

1
2
3
4
5
6
approxPolyDP(  
InputArray curve,
OutputArray approxCurve,
double epsilon, // 表示两点之间的最小距离,低于这个距离的两个点会被删除
bool closed // 多边形要不要闭合
)

然后就是绘制各种形状 。

1
2
3
4
5
6
7
8
9
10
cv::boundingRect(InputArray points)   
// 得到轮廓周围最小矩形左上交点坐标和右下角点坐标,绘制一个矩形
cv::minAreaRect(InputArray points)
// 得到一个旋转的矩形,返回旋转矩形
cv::minEnclosingCircle(
InputArray points, //得到最小区域圆形
Point2f& center, // 圆心位置
float& radius)// 圆的半径
cv::fitEllipse(InputArray points)
// 得到最小椭圆

做法是这样的

  • 首先将图像转换为二值图像
  • 发现轮廓,找到图像轮廓
  • 通过相关API在轮廓点上找到最小包含矩形和圆,旋转矩形和椭圆
  • 绘制它们

项目中只需要绘制矩形,我就只用绘制矩形部分的吧。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <opencv2/opencv.hpp>  
#include <iostream>

using namespace std;
using namespace cv;

Mat src, gray_src, drawImg;
int threshold_v = 170;
int threshold_max = 255;
const char* output_win = "rectangle-demo";
RNG rng(12345);
void Contours_Callback(int, void*) {
Mat binary_output;
vector<vector<Point>> contours;
vector<Vec4i> hierachy;
// 阈值操作与寻找轮廓,转化成一个比较轻恰当的二值图像
threshold(gray_src, binary_output, threshold_v, threshold_max, THRESH_BINARY);
findContours(binary_output, contours, hierachy, RETR_TREE, CHAIN_APPROX_SIMPLE, Point(-1, -1));

vector<vector<Point>> contours_ploy(contours.size());
vector<Rect> ploy_rects(contours.size());
vector<Point2f> ccs(contours.size());
vector<float> radius(contours.size());

vector<RotatedRect> minRects(contours.size());

for (size_t i = 0; i < contours.size(); i++) {
// 调用ADP算法
approxPolyDP(Mat(contours[i]), contours_ploy[i], 3, true);
ploy_rects[i] = boundingRect(contours_ploy[i]);
minEnclosingCircle(contours_ploy[i], ccs[i], radius[i]);
if (contours_ploy[i].size() > 5) {
minRects[i] = minAreaRect(contours_ploy[i]);
}
}

// 绘制
drawImg = Mat::zeros(src.size(), src.type());
Point2f pts[4];
for (size_t t = 0; t < contours.size(); t++) {
Scalar color = Scalar(rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255));
//rectangle(drawImg, ploy_rects[t], color, 2, 8);
//circle(drawImg, ccs[t], radius[t], color, 2, 8);
if (contours_ploy[t].size() > 5) {
//ellipse(drawImg, myellipse[t], color, 1, 8);
minRects[t].points(pts);
for (int r = 0; r < 4; r++) {
line(drawImg, pts[r], pts[(r + 1) % 4], color, 1, 8);
}
}
}
imshow(output_win, drawImg);
}
int main() {
src = imread("D:/test.bmp");
if (!src.data) {
printf("could not load image...\n");
return -1;
}
cvtColor(src, gray_src, CV_BGR2GRAY);
blur(gray_src, gray_src, Size(3, 3), Point(-1, -1));

const char* source_win = "input image";
namedWindow(source_win, CV_WINDOW_AUTOSIZE);
namedWindow(output_win, CV_WINDOW_AUTOSIZE);
imshow(source_win, src);

createTrackbar("Threshold Value:", output_win, &threshold_v, threshold_max, Contours_Callback);
Contours_Callback(0, 0);

waitKey(0);
return 0;
}
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2018-2023 Shawn Zhou
  • Hexo 框架强力驱动 | 主题 - Ayer
  • 访问人数: | 浏览次数:

感谢打赏~

支付宝
微信