实现自己的线性滤波器
原理
Note
以下解释节选自Bradski and Kaehler所著 Learning OpenCV 。
如何用核实现卷积?
假如你想得到图像的某个特定位置的卷积值,可用下列方法计算:
- 将核的锚点放在该特定位置的像素上,同时,核内的其他值与该像素邻域的各像素重合;
- 将核内各值与相应像素值相乘,并将乘积相加;
- 将所得结果放到与锚点对应的像素上;
- 对图像所有像素重复上述过程。
用公式表示上述过程如下:
幸运的是,我们不必自己去实现这些运算,OpenCV为我们提供了函数 filter2D 。
代码
下面这段程序做了些什么?
载入一幅图像
对图像执行 归一化块滤波器 。举例来说,如果该滤波器核的大小为 ,则它会像下面这样:
程序将执行核的大小分别为3、5、7、9、11的滤波器运算。
该滤波器每一种核的输出将在屏幕上显示500毫秒
本教程代码所示如下。你也可以从 这里 下载。
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <stdlib.h>
#include <stdio.h>
using namespace cv;
/** @函数main */
int main ( int argc, char** argv )
{
/// 声明变量
Mat src, dst;
Mat kernel;
Point anchor;
double delta;
int ddepth;
int kernel_size;
char* window_name = "filter2D Demo";
int c;
/// 载入图像
src = imread( argv[1] );
if( !src.data )
{ return -1; }
/// 创建窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
/// 初始化滤波器参数
anchor = Point( -1, -1 );
delta = 0;
ddepth = -1;
/// 循环 - 每隔0.5秒,用一个不同的核来对图像进行滤波
int ind = 0;
while( true )
{
c = waitKey(500);
/// 按'ESC'可退出程序
if( (char)c == 27 )
{ break; }
/// 更新归一化块滤波器的核大小
kernel_size = 3 + 2*( ind%5 );
kernel = Mat::ones( kernel_size, kernel_size, CV_32F )/ (float)(kernel_size*kernel_size);
/// 使用滤波器
filter2D(src, dst, ddepth , kernel, anchor, delta, BORDER_DEFAULT );
imshow( window_name, dst );
ind++;
}
return 0;
}
说明
载入一幅图像
src = imread( argv[1] ); if( !src.data ) { return -1; }
创建窗口以显示结果
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
初始化线性滤波器的参数
anchor = Point( -1, -1 ); delta = 0; ddepth = -1;
执行无限循环。在循环中,我们更新了核的大小,并将线性滤波器用在输入图像上。下面,我们详细分析一下该循环:
首先,我们定义滤波器要用到的核。像下面这样:
kernel_size = 3 + 2*( ind%5 ); kernel = Mat::ones( kernel_size, kernel_size, CV_32F )/ (float)(kernel_size*kernel_size);
第一行代码将 核的大小 设置为 范围内的奇数。第二行代码把1填充进矩阵,并执行归一化——除以矩阵元素数——以构造出所用的核。
将核设置好之后,使用函数 filter2D 就可以生成滤波器:
filter2D(src, dst, ddepth , kernel, anchor, delta, BORDER_DEFAULT );
其中各参数含义如下:
- src: 源图像
- dst: 目标图像
- ddepth: dst 的深度。若为负值(如 ),则表示其深度与源图像相等。
- kernel: 用来遍历图像的核
- anchor: 核的锚点的相对位置,其中心点默认为 (-1, -1) 。
- delta: 在卷积过程中,该值会加到每个像素上。默认情况下,这个值为 。
- BORDER_DEFAULT: 这里我们保持其默认值,更多细节将在其他教程中详解
#. 我们在程序里写了个 while 循环。每隔500毫秒,滤波器的核将在我们所指定的范围内更新。 结果 ========
编译好上述代码之后,输入图像路径的参数,我们就可以执行这个程序。其输出结果是一个窗口,其中显示了由归一化滤波器模糊之后的图像。每过0.5秒,滤波器核的大小会有所变化,如你在下面几张图像中所见:
给图像添加边界
Theory
Note
以下内容来自于Bradski和Kaehler的大作 Learning OpenCV 。
前一节我们学习了图像的卷积操作。一个很自然的问题是如何处理卷积边缘。当卷积点在图像边界时会发生什么,如何处理这个问题?
大多数用到卷积操作的OpenCV函数都是将给定图像拷贝到另一个轻微变大的图像中,然后自动填充图像边界(通过下面示例代码中的各种方式)。这样卷积操作就可以在边界像素安全执行了(填充边界在操作完成后会自动删除)。
本文档将会探讨填充图像边界的两种方法:
- BORDER_CONSTANT: 使用常数填充边界 (i.e. 黑色或者 )
- BORDER_REPLICATE: 复制原图中最临近的行或者列。
源码部分给出更加详细的解释。
源码
本程序做什么?
装载图像
由用户决定使用哪种填充方式。有两个选项:
- 常数边界: 所有新增边界像素使用一个常数,程序每0.5秒会产生一个随机数更新该常数值。
- 复制边界: 复制原图像的边界像素。
用户可以选择按 ‘c’ 键 (常数边界) 或者 ‘r’ 键 (复制边界)
当用户按 ‘ESC’ 键,程序退出。
下面是本教程的源码, 你也可以从 这里 下载
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <stdlib.h>
#include <stdio.h>
using namespace cv;
/// 全局变量
Mat src, dst;
int top, bottom, left, right;
int borderType;
Scalar value;
char* window_name = "copyMakeBorder Demo";
RNG rng(12345);
/** @函数 main */
int main( int argc, char** argv )
{
int c;
/// 装载图像
src = imread( argv[1] );
if( !src.data )
{ return -1;
printf(" No data entered, please enter the path to an image file \n");
}
/// 使用说明
printf( "\n \t copyMakeBorder Demo: \n" );
printf( "\t -------------------- \n" );
printf( " ** Press 'c' to set the border to a random constant value \n");
printf( " ** Press 'r' to set the border to be replicated \n");
printf( " ** Press 'ESC' to exit the program \n");
/// 创建显示窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
/// 初始化输入参数
top = (int) (0.05*src.rows); bottom = (int) (0.05*src.rows);
left = (int) (0.05*src.cols); right = (int) (0.05*src.cols);
dst = src;
imshow( window_name, dst );
while( true )
{
c = waitKey(500);
if( (char)c == 27 )
{ break; }
else if( (char)c == 'c' )
{ borderType = BORDER_CONSTANT; }
else if( (char)c == 'r' )
{ borderType = BORDER_REPLICATE; }
value = Scalar( rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255) );
copyMakeBorder( src, dst, top, bottom, left, right, borderType, value );
imshow( window_name, dst );
}
return 0;
}
解释
首先申明程序中用到的变量:
Mat src, dst; int top, bottom, left, right; int borderType; Scalar value; char* window_name = "copyMakeBorder Demo"; RNG rng(12345);
尤其要注意变量 rng ,这是一个随机数生成器, 用来产生随机边界色彩。
装载原图像 src:
src = imread( argv[1] ); if( !src.data ) { return -1; printf(" No data entered, please enter the path to an image file \n"); }
在简要说明了程序的使用方法后,创建一个显示窗口:
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
初始化边界宽度参数(top, bottom, left 和 right)。我们将它们设定为图像 src 大小的5%。
top = (int) (0.05*src.rows); bottom = (int) (0.05*src.rows); left = (int) (0.05*src.cols); right = (int) (0.05*src.cols);
程序进入 while 循环。 如果用户按’c’键或者 ‘r’键, 变量 borderType 分别取值 BORDER_CONSTANT 或BORDER_REPLICATE :
while( true ) { c = waitKey(500); if( (char)c == 27 ) { break; } else if( (char)c == 'c' ) { borderType = BORDER_CONSTANT; } else if( (char)c == 'r' ) { borderType = BORDER_REPLICATE; }
每个循环 (周期 0.5 秒), 变量 value 自动更新...
value = Scalar( rng.uniform(0, 255), rng.uniform(0, 255), rng.uniform(0, 255) );
为一个由 RNG 类型变量 rng 产生的随机数。 随机数的范围在 之间。
最后调用函数 copyMakeBorder 填充边界像素:
copyMakeBorder( src, dst, top, bottom, left, right, borderType, value );
接受参数:
- src: 原图像
- dst: 目标图像
- top, bottom, left, right: 各边界的宽度,此处定义为原图像尺寸的5%。
- borderType: 边界类型,此处可以选择常数边界或者复制边界。
- value: 如果 borderType 类型是 BORDER_CONSTANT, 该值用来填充边界像素。
显示输出图像
imshow( window_name, dst );
结果
在编译上面的代码之后, 我们可以运行结果,将图片路径输入。 结果应该为:
- 程序启动时边界类型为 BORDER_CONSTANT (0), 因此,一开始边界颜色任意变换。
- 如果用户按 ‘r’ 键, 边界将会变成原图像边缘的拷贝。
- 如果用户按 ‘c’ 键, 边界再次变为任意颜色。
- 如果用户按 ‘ESC’ 键,程序退出。
下面显示了几张截图演示了边界颜色如何改变,以及在边界类型为 BORDER_REPLICATE 时的情形:
-
Sobel 导数
原理
Note
以下内容来自于Bradski和Kaehler的大作: Learning OpenCV .
上面两节我们已经学习了卷积操作。一个最重要的卷积运算就是导数的计算(或者近似计算).
为什么对图像进行求导是重要的呢? 假设我们需要检测图像中的 边缘 ,如下图:
你可以看到在 边缘 ,相素值显著的 改变 了。表示这一 改变 的一个方法是使用 导数 。 梯度值的大变预示着图像中内容的显著变化。
用更加形象的图像来解释,假设我们有一张一维图形。下图中灰度值的”跃升”表示边缘的存在:
使用一阶微分求导我们可以更加清晰的看到边缘”跃升”的存在(这里显示为高峰值)
从上例中我们可以推论检测边缘可以通过定位梯度值大于邻域的相素的方法找到(或者推广到大于一个阀值).
更加详细的解释,请参考Bradski 和 Kaehler的 Learning OpenCV 。
源码
- 本程序做什么?
- 使用 Sobel算子 产生的输出图像上,检测到的亮起的 边缘 相素散布在更暗的背景中。
- 下面是本教程的源码,你也可以从 here 下载
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <stdlib.h>
#include <stdio.h>
using namespace cv;
/** @function main */
int main( int argc, char** argv )
{
Mat src, src_gray;
Mat grad;
char* window_name = "Sobel Demo - Simple Edge Detector";
int scale = 1;
int delta = 0;
int ddepth = CV_16S;
int c;
/// 装载图像
src = imread( argv[1] );
if( !src.data )
{ return -1; }
GaussianBlur( src, src, Size(3,3), 0, 0, BORDER_DEFAULT );
/// 转换为灰度图
cvtColor( src, src_gray, CV_RGB2GRAY );
/// 创建显示窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
/// 创建 grad_x 和 grad_y 矩阵
Mat grad_x, grad_y;
Mat abs_grad_x, abs_grad_y;
/// 求 X方向梯度
//Scharr( src_gray, grad_x, ddepth, 1, 0, scale, delta, BORDER_DEFAULT );
Sobel( src_gray, grad_x, ddepth, 1, 0, 3, scale, delta, BORDER_DEFAULT );
convertScaleAbs( grad_x, abs_grad_x );
/// 求Y方向梯度
//Scharr( src_gray, grad_y, ddepth, 0, 1, scale, delta, BORDER_DEFAULT );
Sobel( src_gray, grad_y, ddepth, 0, 1, 3, scale, delta, BORDER_DEFAULT );
convertScaleAbs( grad_y, abs_grad_y );
/// 合并梯度(近似)
addWeighted( abs_grad_x, 0.5, abs_grad_y, 0.5, 0, grad );
imshow( window_name, grad );
waitKey(0);
return 0;
}
解释
首先申明变量:
Mat src, src_gray; Mat grad; char* window_name = "Sobel Demo - Simple Edge Detector"; int scale = 1; int delta = 0; int ddepth = CV_16S;
装载原图像 src:
src = imread( argv[1] ); if( !src.data ) { return -1; }
第一步对原图像使用 GaussianBlur 降噪 ( 内核大小 = 3 )
GaussianBlur( src, src, Size(3,3), 0, 0, BORDER_DEFAULT );
将降噪后的图像转换为灰度图:
cvtColor( src, src_gray, CV_RGB2GRAY );
第二步,在 x 和 y 方向分别”求导“。 为此,我们使用函数 Sobel :
Mat grad_x, grad_y; Mat abs_grad_x, abs_grad_y; /// 求 X方向梯度 Sobel( src_gray, grad_x, ddepth, 1, 0, 3, scale, delta, BORDER_DEFAULT ); /// 求 Y方向梯度 Sobel( src_gray, grad_y, ddepth, 0, 1, 3, scale, delta, BORDER_DEFAULT );
该函数接受了以下参数:
- src_gray: 在本例中为输入图像,元素类型 CV_8U
- grad_x/grad_y: 输出图像.
- ddepth: 输出图像的深度,设定为 CV_16S 避免外溢。
- x_order: x 方向求导的阶数。
- y_order: y 方向求导的阶数。
- scale, delta 和 BORDER_DEFAULT: 使用默认值
注意为了在 x 方向求导我们使用: , . 采用同样方法在 y 方向求导。
将中间结果转换到 CV_8U:
convertScaleAbs( grad_x, abs_grad_x ); convertScaleAbs( grad_y, abs_grad_y );
将两个方向的梯度相加来求取近似 梯度 (注意这里没有准确的计算,但是对我们来讲已经足够了)。
addWeighted( abs_grad_x, 0.5, abs_grad_y, 0.5, 0, grad );
最后,显示结果:
imshow( window_name, grad );
Laplace 算子
原理
前一节我们学习了 Sobel 算子 ,其基础来自于一个事实,即在边缘部分,像素值出现”跳跃“或者较大的变化。如果在此边缘部分求取一阶导数,你会看到极值的出现。正如下图所示:
如果在边缘部分求二阶导数会出现什么情况?
你会发现在一阶导数的极值位置,二阶导数为0。所以我们也可以用这个特点来作为检测图像边缘的方法。 但是, 二阶导数的0值不仅仅出现在边缘(它们也可能出现在无意义的位置),但是我们可以过滤掉这些点。
Laplacian 算子
- 从以上分析中,我们推论二阶导数可以用来 检测边缘 。 因为图像是 “2维”, 我们需要在两个方向求导。使用Laplacian算子将会使求导过程变得简单。
- Laplacian 算子 的定义:
- OpenCV函数 Laplacian 实现了Laplacian算子。 实际上,由于 Laplacian使用了图像梯度,它内部调用了 Sobel 算子。
源码
- 本程序做什么?
- 装载图像
- 使用高斯平滑消除噪声, 将图像转换到灰度空间。
- 使用Laplacian算子作用于灰度图像,并保存输出图像。
- 输出结果。
- 下面是本教程的源码,你也可以从 这里 下载。
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <stdlib.h>
#include <stdio.h>
using namespace cv;
/** @函数 main */
int main( int argc, char** argv )
{
Mat src, src_gray, dst;
int kernel_size = 3;
int scale = 1;
int delta = 0;
int ddepth = CV_16S;
char* window_name = "Laplace Demo";
int c;
/// 装载图像
src = imread( argv[1] );
if( !src.data )
{ return -1; }
/// 使用高斯滤波消除噪声
GaussianBlur( src, src, Size(3,3), 0, 0, BORDER_DEFAULT );
/// 转换为灰度图
cvtColor( src, src_gray, CV_RGB2GRAY );
/// 创建显示窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
/// 使用Laplace函数
Mat abs_dst;
Laplacian( src_gray, dst, ddepth, kernel_size, scale, delta, BORDER_DEFAULT );
convertScaleAbs( dst, abs_dst );
/// 显示结果
imshow( window_name, abs_dst );
waitKey(0);
return 0;
}
解释
首先申明变量:
Mat src, src_gray, dst; int kernel_size = 3; int scale = 1; int delta = 0; int ddepth = CV_16S; char* window_name = "Laplace Demo";
装载原图像:
src = imread( argv[1] ); if( !src.data ) { return -1; }
高斯平滑降噪:
GaussianBlur( src, src, Size(3,3), 0, 0, BORDER_DEFAULT );
使用 cvtColor 转换为灰度图
cvtColor( src, src_gray, CV_RGB2GRAY );
#.对灰度图使用Laplacian算子:
Laplacian( src_gray, dst, ddepth, kernel_size, scale, delta, BORDER_DEFAULT );函数接受了以下参数:
- src_gray: 输入图像。
- dst: 输出图像
- ddepth: 输出图像的深度。 因为输入图像的深度是 CV_8U ,这里我们必须定义 ddepth = CV_16S 以避免外溢。
- kernel_size: 内部调用的 Sobel算子的内核大小,此例中设置为3。
- scale, delta 和 BORDER_DEFAULT: 使用默认值。
将输出图像的深度转化为 CV_8U :
convertScaleAbs( dst, abs_dst );
显示结果:
imshow( window_name, abs_dst );
结果
#.在编译上面的代码之后, 我们可以运行结果,将图片路径输入,如下图:
我们得到下图所示的结果。 注意观察树木和牛的轮廓基本上很好的反映出来(除了像素值比较接近的地方, 比如奶牛的头部)。 此外,注意树木(右方)后面的房子屋顶被明显的加强显示出来,这是由于局部对比度比较强的原因。
Canny 边缘检测
原理
- Canny 边缘检测算法 是 John F. Canny 于 1986年开发出来的一个多级边缘检测算法,也被很多人认为是边缘检测的 最优算法, 最优边缘检测的三个主要评价标准是:
- 低错误率: 标识出尽可能多的实际边缘,同时尽可能的减少噪声产生的误报。
- 高定位性: 标识出的边缘要与图像中的实际边缘尽可能接近。
- 最小响应: 图像中的边缘只能标识一次。
步骤
消除噪声。 使用高斯平滑滤波器卷积降噪。 下面显示了一个 的高斯内核示例:
计算梯度幅值和方向。 此处,按照Sobel滤波器的步骤:
运用一对卷积阵列 (分别作用于 和 方向):
使用下列公式计算梯度幅值和方向:
梯度方向近似到四个可能角度之一(一般 0, 45, 90, 135)
非极大值 抑制。 这一步排除非边缘像素, 仅仅保留了一些细线条(候选边缘)。
滞后阈值: 最后一步,Canny 使用了滞后阈值,滞后阈值需要两个阈值(高阈值和低阈值):
- 如果某一像素位置的幅值超过 高 阈值, 该像素被保留为边缘像素。
- 如果某一像素位置的幅值小于 低 阈值, 该像素被排除。
- 如果某一像素位置的幅值在两个阈值之间,该像素仅仅在连接到一个高于 高 阈值的像素时被保留。
Canny 推荐的 高:低 阈值比在 2:1 到3:1之间。
想要了解更多细节,你可以参考任何你喜欢的计算机视觉书籍。
源码
- 本程序做什么?
- 要求使用者输入一个数字,设置 Canny Edge Detector 的低阈值 (通过trackbar)
- 使用 Canny 边缘检测 产生一个 mask (白线代表边缘,黑色代表背景)。
- 使用 mask 作为掩码显示原图像。
- 本教程的源码如下,你也可以从 这里 下载
#include "opencv2/imgproc/imgproc.hpp"
#include "opencv2/highgui/highgui.hpp"
#include <stdlib.h>
#include <stdio.h>
using namespace cv;
/// 全局变量
Mat src, src_gray;
Mat dst, detected_edges;
int edgeThresh = 1;
int lowThreshold;
int const max_lowThreshold = 100;
int ratio = 3;
int kernel_size = 3;
char* window_name = "Edge Map";
/**
* @函数 CannyThreshold
* @简介: trackbar 交互回调 - Canny阈值输入比例1:3
*/
void CannyThreshold(int, void*)
{
/// 使用 3x3内核降噪
blur( src_gray, detected_edges, Size(3,3) );
/// 运行Canny算子
Canny( detected_edges, detected_edges, lowThreshold, lowThreshold*ratio, kernel_size );
/// 使用 Canny算子输出边缘作为掩码显示原图像
dst = Scalar::all(0);
src.copyTo( dst, detected_edges);
imshow( window_name, dst );
}
/** @函数 main */
int main( int argc, char** argv )
{
/// 装载图像
src = imread( argv[1] );
if( !src.data )
{ return -1; }
/// 创建与src同类型和大小的矩阵(dst)
dst.create( src.size(), src.type() );
/// 原图像转换为灰度图像
cvtColor( src, src_gray, CV_BGR2GRAY );
/// 创建显示窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
/// 创建trackbar
createTrackbar( "Min Threshold:", window_name, &lowThreshold, max_lowThreshold, CannyThreshold );
/// 显示图像
CannyThreshold(0, 0);
/// 等待用户反应
waitKey(0);
return 0;
}
解释
创建程序中要用到的变量:
Mat src, src_gray; Mat dst, detected_edges; int edgeThresh = 1; int lowThreshold; int const max_lowThreshold = 100; int ratio = 3; int kernel_size = 3; char* window_name = "Edge Map"; 注意: a. 我们首先设定高:低阈值比为 3:1 (通过变量 *ratio* ) b. 设定内核尺寸为 :math:`3` (Canny函数内部调用Sobel操作) c. 将低阈值的上限设定为 :math:`100`.
装载原图像:
/// 装载图像 src = imread( argv[1] ); if( !src.data ) { return -1; }
创建与 src 同类型和大小的矩阵(dst)
dst.create( src.size(), src.type() );
将输入图像转换到灰度空间 (使用函数 cvtColor):
cvtColor( src, src_gray, CV_BGR2GRAY );
创建显示窗口
namedWindow( window_name, CV_WINDOW_AUTOSIZE );
创建trackbar,来获取用户交互输入的低阈值:
createTrackbar( "Min Threshold:", window_name, &lowThreshold, max_lowThreshold, CannyThreshold );
注意:
- 通过trackbar控制的变量为 lowThreshold ,上限为 max_lowThreshold (我们已经设定为100)
- 每次用户通过trackbar产生变动,回调函数 CannyThreshold 被调用.
让我们一步一步的来观察 CannyThreshold 函数:
首先, 使用 3x3的内核平滑图像:
blur( src_gray, detected_edges, Size(3,3) );
其次,运用 Canny 寻找边缘:
Canny( detected_edges, detected_edges, lowThreshold, lowThreshold*ratio, kernel_size );
输入参数:
- detected_edges: 原灰度图像
- detected_edges: 输出图像 (支持原地计算,可为输入图像)
- lowThreshold: 用户通过 trackbar设定的值。
- highThreshold: 设定为低阈值的3倍 (根据Canny算法的推荐)
- kernel_size: 设定为 3 (Sobel内核大小,内部使用)
填充 dst 图像,填充值为0 (图像全黑).
dst = Scalar::all(0);
最后, 使用函数 copyTo 标识被检测到的边缘部分 (背景为黑色).
src.copyTo( dst, detected_edges);
copyTo 将 src 图像拷贝到 dst . 但是,仅仅拷贝掩码不为0的像素。既然Canny边缘检测的输出是镶嵌在黑色背景中的边缘像素,因此其结果 dst 图像除了被检测的边缘像素,其余部分都为黑色。
显示结果:
imshow( window_name, dst );