用 “普通话” 讲算法之 VIBE算法

写在最前

这是博主第一篇科技分享类的长文,其实有很多话想跟大家说,但是因为时间关系,不得不先写内容,想跟大家说的话就先放放吧,等有时间我会把这段话换成一篇博客,和大家一起分享科研中的点滴生活,以及博主推出本专栏的初衷,希望大家多多支持!!闲话不说,咱们开启正题~

目录

第一章: 算法效果

第二章: 算法解析
 2.1 算法目标

 2.2 算法原理
  2.2.1 样本建模
  2.2.2 分类操作:前景与背景
  2.2.3 更新策略:前景
  2.2.4 更新策略:背景

第三章: 代码分析
 3.1 样本建模

 3.2 判定策略与更新策略

 3.3 整体项目代码

1. 算法效果

               (备注:因为只是演示效果,我并没有详细的调整参数,演示效果不能完全代表算法效果)

文章原文:ViBE: A powerful random technique to estimate the background in video sequences

2. 算法解析

2.1 解决问题

首先我们先来聊聊ViBe算法是干啥的。ViBe(Visual Background Extractor)算法主要用来解决前景和背景的提取问题。你问我啥是前景?啥是背景?普通话解释一波:前景就是图像中你想要的部分,背景就是你不想要的部分。或者咱们这么说:动态区域提取,是不是更好理解一点?相似的算法比如帧间差分,高斯混合模型,光流法都是可以来解决这个问题的。图像中什么东西在动,我就提取什么东西~上图中的烟雾就是个例子。

2.2 算法原理

不论哪种算法,它需要解决的问题是唯一的:在一幅图像中找出哪些像素是我们想要的(前景),哪些是我们不想要的(背景),其本质就是一个二分类问题。 图像其实是一个巨大的矩阵,每一个矩阵元素代表了一个像素值。 ViBe算法是一个在灰度图像基础上进行计算的算法,也就是说,使用的图像信息是单通道灰度图像(像素值是0-255)。那么ViBe是如何通过灰度图实现这个二分类操作的呢?

2.2.1 样本建模

首先,ViBe需要对待检测的视频图像进行一个建模操作,我们来看这样的一个结构图:

用 “普通话” 讲算法之 VIBE算法_第1张图片

上图中,蓝色大方块代表我们视频图像的 第一帧的灰度图;灰色大方块代表原始图像的 样本库,其数量为N,在论文原文中,这个数量被设定为20,这个样本库的本质我们可以理解为一幅一幅的灰度图片,共有20张,每一张大小尺寸完全和蓝色大方块相同;绿色的大方块代表一个 计数盘,其本质也可以理解为灰度图片,只不过每个像素代表的是计数器所记录的数据,尺寸和蓝色大方块相同(不理解没关系,下文会讲解);白色的大方块代表 结果模板,这个模板用来显示最终的检测结果,尺寸和蓝色大方块相同。直白来说,就是上文演示动画中的左侧图像,这个图像上只有像素值为0和255两种类型的像素点,即:二值化图像。

在第一帧这个蓝色大方块中有一个标记有*符号,位置为 (i,j) 的像素,我们记为A像素。我们就拿这一个像素来举例子,说明算法是如何对所有像素完成建模操作的(其他所有像素的建模操作和该像素相同)。

由于样本库中每一张图像的尺寸完全和蓝色大方块相同,所以数据库中每一张图像都有一个完全对应于A像素位置的像素点,我们记为Ai(i = 1~N)。图中A像素周围会有8个相邻的像素,上图中记为8个灰色的小方格。算法会从这共计9个像素中(8个邻域+本身)随机选择一个像素值赋予层数为 i(i = 1~N) 的Ai像素。通过循环执行这个操作N次,就会将N个样本图像中的所有Ai像素都赋予一个随机选取的像素值,我们将这个像素值记为Pi(i = 1~N) 这样就完成了一颗像素的建模操作。

在建模操作过程中,记数盘结果模板 的所有像素值均赋值为0,从视觉上讲,这两张图片被初始化成两张完全为黑色的图像。

总结来说,建模阶段ViBe算法主要完成了三件事情:

  1. 用第一帧图像的灰度信息填满样本库;
  2. 初始化一个全部为0的计数盘;
  3. 初始化一个全部为0的用来显示检测结果的二值图像模板;

至此,算法第一部分,图像的建模操作全部完成。

2.2.2 分类操作:前景与背景

其次,我们来说明算法是如何区分前景和背景的,如果像素不满足背景的条件,就会被定义为前景,反之亦然。在定义前景和背景的时候,算法引入了一个距离阈值来判定是否满足条件。这个东西的本质可以理解为像素值之差,我们看图说话:

用 “普通话” 讲算法之 VIBE算法_第2张图片

我们依然拿 一个像素来举例子。上图中右侧灰色部分是在初始化阶段建立的样本库中Ai像素的像素值,在每一层样本的Ai点都有一个像素值即Pi, 算法以这些数据作为评价指标,计算与新来数据的距离。例如:目前要判断第二帧图像(i,j)位置的像素点是前景还是背景。

  1. 第一步,算法会将新来像素的像素值分别与1~N个样本库中的像素值进行相减,即循环执行图片右下角的公式(abs()函数代表绝对值函数),得到N个 Distance 数据。
  2. 第二步,设定 阈值1,称为半径阈值,记为R(这个数值在论文原文中为30),设定 阈值2,称为匹配阈值,记为M(这个数值在论文原文中为2)。将 阈值R 与第一步中所有的 Distance 循环比较。
  3. 第三步,如果 Distance < R 的次数 超过阈值M ,说明待判定的像素值与之前的像素值变化不大,距离不远,即定义该点为背景像素,不是动态区域。如果Distance < R 的次数 没有超过阈值M ,说明新来的像素值与之前的像素值差异较大,距离较远,即定义该点为前景像素。

总结来说,前景背景判定的方式就是 用新来的像素与之前的样本像素值做差,如果差的不多,说明变化不大,认为是背景,反之认为是前景。

至此,算法第二部分,前景与背景的判定操作全部完成。

2.2.3 更新策略:前景

接下来, 我们需要知道当确定当前像素为前景像素以后,需要做哪些后续的操作,以及如何维护样本库和计数盘。现假设 像素A 已经被确定为前景像素:

  1. 第一步,将A像素点在最终的检测结果上标记出来。在此,我们用到了2.2.1章节中提到的 结果模板,将 结果模板 上与当前像素A位置对应的像素点置为255,即白色点
  2. 第二步,将2.2.1章节中提到的 计数盘 中与当前像素A位置对应的像素点值+1;
  3. 第三步,判定 计数盘 上与当前像素A位置对应的像素点值,其本质是当前像素被判定程前景像素的次数。若该次数 >50(开源代码中的阈值设定为50次),则认为该点是静止物体被误检测成了动态物体,需要强制更新数据样本信息。

数据样本更新方式较为简单,一句话总结 就是把当前新输入图像的 像素A的像素值 随机的选择一张样本图像(样本图像一共有N个),赋予其对应位置的像素。举个例子,比如当前新输入图像位置为(i,j)的像素A,其像素值为200,通过随机的方式,选中了样本库中第2张样本进行更新,则将第二张样本图像上位于(i,j)的像素的像素值赋值为200。

2.2.4 更新策略:背景

现在我们来聊一聊另外一个分支, 当确定当前像素为背景以后,需要做哪些后续的操作,以及如何维护样本库和计数盘。现假设 像素A 已经被确定为背景像素:

  1. 第一步,将A像素点在最终的检测结果上标记出来。在此,我们用到了2.2.1章节中提到的 结果模板,将 结果模板 上与当前像素A位置对应的像素点置为0,即黑色点
  2. 第二步,将2.2.1章节中提到的 计数盘 中与当前像素A位置对应的像素点值清0;
  3. 第三步,有1/φ的概率更新样本库,并同时更新临近像素的样本库。

第三步稍微有点复杂,在这里博主也出现了点疑问,咱们先说技术,一会再聊疑问。
1/φ这个概率是作者自己定义的(开源代码中为1/16),算法首先维护一个随机数来确定是否达到1/φ这个概率,在达到这个概率后,开始对 当前像素 数据样本执行2.2.3章节给出的更新策略,同时随机选取当前像素的 一个8邻域像素(8个邻域像素中任选一个),执行相同的更新策略。例如当前像素记为A,像素值为Pa,选定的邻域像素记为B,像素值记为Pb,则A像素样本库中更新的像素值为Pa,B像素样本库中更新的像素值也为Pa。即采用A像素的当前像素值更新B像素的样本库。(数值上Pa=Pb不考虑在内)。

存疑:
博主在源代码中发现这一部分的更新策略是:有1/φ的概率更新样本库,并同时有1/φ的概率更新临近像素的样本库。 简单来讲就是代码中同时更新样本和临近像素样本库的概率为(1/φ)*(1/φ)。而论文中的内容是两者同时更新的概率为1/φ,虽然并不影响功能,但是还是希望能有读者大佬帮忙指点迷津,万分感谢!!!

将2.2.3和2.2.4章节的内容合并总结来说,当一个像素被定为一次前景,就会为他增加一层计数器,当计数器超过50,就会强制更新数据库样本信息。当一个像素被定为背景,会有一定概率更新数据库和其邻域像素的数据库,这个概率为1/φ。

至此,ViBe算法的原理全部结束。

3. 代码分析

可能是个人性格问题,博主觉得如果不能亲自撸通代码,在给大家做代码的分析的时候总是没什么底气 (只是我个人对自己的要求,不代表对其他人的看法。。。勿喷) ,所以这个章节中,代码分析用的代码是我自己在对开源代码稍作修改的基础上贴出来的,个人认为应该会更有助于大家理解算法。

PS: 如果感觉看着费劲可以开两个显示器窗口(如果你有两个显示器的话),一边看上面的原理,一边看代码效果会更好~

3.1 样本建模(对应2.2.1章节)

void ViBe::Init(Mat First_Frame){ //函数输入为第一帧图像的灰度图;
	
	RNG rng;			//定义一个随机数类;
	int random;			//定义一个随机数;
	int random_rows;	//定义随机行数;
	int random_cols;	//定义随机列数;
	int x_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };	//水平偏移;
	int y_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };	//垂直偏移;
	
	//2.2.1总结中提出的三件事情中的第一件:
	//建立一个vector类型的数据库
	for (int k = 0; k < num_sample; k++){
		//定义一个缓存模板,即原始图像样本库中的任意一帧;
		Mat Temp = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);
		//对每个像素进行双for循环内的操作;
		for (int i = 0; i < First_Frame.rows; i++){
			for (int j = 0; j < First_Frame.cols; j++){
				//先算随机坐标,会从9个像素点里随机选取一个存入当前缓存模板;
				random = rng.uniform(0, 9); random_rows = i + y_shift[random];
				random = rng.uniform(0, 9); random_cols = j + x_shift[random];
				//如果超出图像边界会防止越界,也就是说位于图像最外层一周的像素不会有完整
				//的9个像素供其选择,没有类似于深度学习的padding操作;
				if (random_rows < 0) random_rows = 0;
				if (random_rows >= First_Frame.rows) random_rows = First_Frame.rows - 1;
				if (random_cols < 0) random_cols = 0;
				if (random_cols >= First_Frame.cols) random_cols = First_Frame.cols - 1;
				//将当前模板位置为(i,j)的像素值赋值为随机选取的8邻域像素值中的一个;
				Temp.at<uchar>(i, j) = First_Frame.at<uchar>(random_rows, random_cols);			}
		}
		//全部像素初始化完毕,送入样本库,成为一张图像样本,共有 num_sample 张图像样本(即前文中提到的N);
		samples.push_back(Temp);
	}
	//2.2.1总结中提出的三件事情中的第二件:
	//初始化所有像素的前景计数器(计数盘)为0;
	FG_counts = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);
	
	//2.2.1总结中提出的三件事情中的第三件:
	//初始化结果模板为全黑色(全部为0);
	FG_model = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);
}

3.2 判定策略与更新策略(对应2.2.2~2.2.4章节)

void ViBe::Run(Mat Frame){//函数输入为待检测图像的灰度图;

	RNG rng;				//定义一个随机数类;

	int distance = 0;		//定义2.2.2章节中提到的Distance距离;
	int match_count = 0;	//匹配计数器;
	int random = 0;			//定义一个随机数;
	int random_rows;		//定义一个随机行数;
	int random_cols;		//定义一个随机列数;
	int x_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };	//水平偏移;
	int y_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };	//垂直偏移;

	for (int i = 0; i < Frame.rows; i++){
		//以下两个for循环完成的是每个像素的距离计算;
		//即2.2.2章节中三大步骤中的第一步和第二步;
		for (int j = 0; j < Frame.cols; j++){
			for (int k = 0; k < num_sample; k++){
				//2.2.2章节中的距离计算;
				distance = abs(samples[k].at<uchar>(i, j) - Frame.at<uchar>(i, j));
				//如果距离小于半径,说明变化不明显,即不是动态区域;
				if (distance < radius)
					match_count++;
				//这句话的目的是加速算法计算,如果Distance < R 的次数等于阈值M,就没有必要
				//继续向下计算了,值接退出就可以了;
				//min_match是2.2.2章节中提到的 匹配阈值 M 
				if (match_count == min_match)
					break;
			}
			
			//如果匹配程度较高,则认为是背景,2.2.2章节第三步;
			if (match_count >= min_match){
				//匹配计数器清0;
				match_count = 0;
				
				//背景模型置黑(置0),2.2.4章节第一步;
				FG_model.at<uchar>(i, j) = 0;
				//前景计数器清0,2.2.4章节第二步;
				FG_counts.at<uchar>(i, j) = 0;
				
				//2.2.4章节第三步;
				//已经为背景,有1/φ的概率更新样本库和临近像素值;(该处与开源代码不同);
				//updata_probability即是φ;
				random = rng.uniform(0, updata_probability);
				
				//如果恰好命中概率,则执行2.2.4章节的更新策略
				if (random == 0){
				
					//更新本像素样本;
					random = rng.uniform(0, num_sample);
					//该步骤为2.2.3章节的样本更新策略;
					samples[random].at<uchar>(i, j) = Frame.at<uchar>(i, j);
					
					//更新临近像素样本;
					random = rng.uniform(0, 9); random_rows = i + x_shift[random];
					random = rng.uniform(0, 9); random_cols = j + y_shift[random];

					// 防止选取的像素点越界
					if (random_rows < 0) random_rows = 0;
					if (random_rows >= Frame.rows)  random_rows = Frame.rows - 1;
					if (random_cols < 0) random_cols = 0;
					if (random_cols >= Frame.cols) random_cols = Frame.cols - 1;

					// 为临近像素随机样本库赋值;
					random = rng.uniform(0, num_sample);
					samples[random].at<uchar>(random_rows, random_cols) = Frame.at<uchar>(i, j);
				}

			}
			//否则,该像素点为动态区域,即前景像素,2.2.2章节第三步;
			else
			{
				match_count = 0;

				//2.2.3章节第一步,背景模型置白;
				FG_model.at<uchar>(i, j) = 255;
				//2.2.3章节第二步,前景计数器+1,
				FG_counts.at<uchar>(i, j)++;
				
				//2.2.3章节第三步,强制更新样本
				//如果前景计数器大于50,则认为一块静止区域被误判为运动,更新为背景点;
				if (FG_counts.at<uchar>(i, j) > 50)
				{
					random = rng.uniform(0, num_sample);
					samples[random].at<uchar>(i, j) = Frame.at<uchar>(i, j);
				}

			}

		}
	}
}

3.3 整体项目代码(详细备注请看3.1和3.2~)

不开源完整代码的程序员不是好博主~完整代码如下(我才不去骗大家的C币呢~~):

刚好有个小插曲在这里跟大家聊聊:关于类定义

博主并不是编程大神,只是会简单的用用相应语言而已,之前也有过疑问,什么时候用类?为什么大家都用类?类有啥好处啊??以下是我个人的粗浅理解,大家勿喷~

自己慢慢体会慢慢发现,感觉类这个东西最好的用途就是可以 “局部” 使用 “全局变量”。不知道这么说大家能不能理解。就拿下面这段程序中的 class ViBe 来说,在其内部定义的所有变量(如:samples,FG_counts,FG_model)都能被类内部的函数视为 “全局变量”,这种 “局部” 的 “全局效应” 仅在类内部有效,可以对比 “闭包” 的概念~ 这样是不是解决了很多小伙伴们既想让一个变量在一定场合下 “局部”,又在个别场景下 “全局” 的困惑??

说明:有小伙伴私信我说下文源代码中

  1. CV_CAP_PROP_FRAME_HEIGHT
  2. CV_CAP_PROP_FRAME_WIDTH
  3. CV_RGB2GRAY

这三个变量系统无法识别,原因是因为小伙伴们用的OpenCV版本比较高,上述三个变量已经被重新命名了,下文中的OpenCV版本为3.1.0,如果你们出现了报错,将上述三个变量分别改为:

  1. CAP_PROP_FRAME_HEIGHT
  2. CAP_PROP_FRAME_WIDTH
  3. COLOR_RGB2GRAY

这样应该就可以啦!!

/*===================================================================
* 作者:打团从来人不齐;
* 时间:2019.12.18;
* OpenCV版本:3.1.0
* 说明:ViBe算法手撸修改版;
* 维护:打团从来人不齐;
* 备注:欢迎大家多多评论交流!!
=====================================================================
*/

#include 
#include   
#include 
#include 

using namespace cv;
using namespace std;

#define DEFAULT_NUM_SAMPLES  20		//每个像素点的默认样本数量
#define DEFAULT_MIN_MATCHES  2		//匹配阈值 M
#define DEFAULT_RADIUS 30			//半径阈值 R
#define DEFAULT_RANDOM_SAMPLE 16	//采样概率φ

string PATH1 = "";
string PATH2 = "";


class ViBe{

public:
	//ViBe初始化函数;
	void Init(Mat First_Frame);
	//ViBe检测函数;
	void Run(Mat Frame);
	//检测结果获取;
	Mat GetFGModel();
	//删除样本库;
	void DeleteSamples();

private:
	
	int num_sample = DEFAULT_NUM_SAMPLES;
	int min_match = DEFAULT_MIN_MATCHES;
	int radius = DEFAULT_RADIUS;
	int updata_probability = DEFAULT_RANDOM_SAMPLE;

	vector<Mat> samples; //数据样本库;
	Mat FG_counts;		 //图像计数盘;
	Mat FG_model;		 //结果模板;
};

void ViBe::Init(Mat First_Frame){

	RNG rng;
	int random;
	int random_rows;
	int random_cols;
	int x_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };
	int y_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };

	//初始化所有像素的前景计数器为0;
	FG_counts = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);
	//初始化前景图像模板为全黑色;
	FG_model = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);
	//建立一个vector类型的数据库
	for (int k = 0; k < num_sample; k++){

		Mat Temp = Mat::zeros(First_Frame.rows, First_Frame.cols, CV_8UC1);

		for (int i = 0; i < First_Frame.rows; i++){
			for (int j = 0; j < First_Frame.cols; j++){

				random = rng.uniform(0, 9); random_rows = i + y_shift[random];
				random = rng.uniform(0, 9); random_cols = j + x_shift[random];

				if (random_rows < 0) random_rows = 0;
				if (random_rows >= First_Frame.rows) random_rows = First_Frame.rows - 1;
				if (random_cols < 0) random_cols = 0;
				if (random_cols >= First_Frame.cols) random_cols = First_Frame.cols - 1;


				Temp.at<uchar>(i, j) = First_Frame.at<uchar>(random_rows, random_cols);

			}
		}
		samples.push_back(Temp);
	}
}

void ViBe::Run(Mat Frame){

	RNG rng;

	int distance = 0;
	int match_count = 0;
	int random = 0;
	int random_rows;
	int random_cols;
	int x_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };
	int y_shift[9] = { -1, 0, 1, -1, 0, 1, -1, 0, 1 };

	for (int i = 0; i < Frame.rows; i++){
		for (int j = 0; j < Frame.cols; j++){
			for (int k = 0; k < num_sample; k++){
				//距离计算;
				distance = abs(samples[k].at<uchar>(i, j) - Frame.at<uchar>(i, j));
				//如果距离小于半径,说明变化不明显,即不是动态区域;
				if (distance < radius)
					match_count++;
				if (match_count == min_match)
					break;
			}

			//如果匹配程度较高,则认为是背景,前景计数器清0,背景模型置黑;
			if (match_count >= min_match){

				match_count = 0;

				FG_counts.at<uchar>(i, j) = 0;
				FG_model.at<uchar>(i, j) = 0;
			
				//已经为背景,有1/φ的概率更新样本库和临近像素值;(该处于代码不同);
				random = rng.uniform(0, updata_probability);

				if (random == 0){
					//更新本像素样本;
					random = rng.uniform(0, num_sample);
					samples[random].at<uchar>(i, j) = Frame.at<uchar>(i, j);

					//跟新临近样本;
					random = rng.uniform(0, 9); random_rows = i + x_shift[random];
					random = rng.uniform(0, 9); random_cols = j + y_shift[random];

					// 防止选取的像素点越界
					if (random_rows < 0) random_rows = 0;
					if (random_rows >= Frame.rows)  random_rows = Frame.rows - 1;
					if (random_cols < 0) random_cols = 0;
					if (random_cols >= Frame.cols) random_cols = Frame.cols - 1;

					// 为样本库赋随机值
					random = rng.uniform(0, num_sample);
					samples[random].at<uchar>(random_rows, random_cols) = Frame.at<uchar>(i, j);
				}

			}
			//否则,该像素点为动态区域,即前景像素;
			else
			{
				match_count = 0;

				//前景计数器+1,背景模型置白;
				FG_model.at<uchar>(i, j) = 255;
				FG_counts.at<uchar>(i, j)++;
				
				//如果前景计数器大于50,则认为一块静止区域被误判为运动,更新为背景点;
				if (FG_counts.at<uchar>(i, j) > 50)
				{
					random = rng.uniform(0, num_sample);
					samples[random].at<uchar>(i, j) = Frame.at<uchar>(i, j);
				}

			}

		}
	}
}

Mat ViBe::GetFGModel(){
	return FG_model;
}

void ViBe::DeleteSamples()
{
	vector<Mat>().swap(samples);
}



void main(){
	
	VideoCapture capture;

	capture = VideoCapture(PATH2); 
	capture.set(CV_CAP_PROP_FRAME_WIDTH, 160);
	capture.set(CV_CAP_PROP_FRAME_HEIGHT, 120);

	Mat frame;
	Mat gray;
	Mat FG_img;

	ViBe Vibe_Model;
	int Start_Flag = 0;

	while (1){
		capture >> frame;
		resize(frame, frame, Size(480, 320));
		cvtColor(frame, gray, CV_RGB2GRAY);
		
		if (Start_Flag == 0){
			Vibe_Model.Init(gray);
			Start_Flag += 1;
		}
		else{
			Vibe_Model.Run(gray);
			FG_img = Vibe_Model.GetFGModel();

			imshow("ori", frame);
			imshow("test", FG_img);
		}


		if (waitKey(25) == 27)
		{
			break;
		}
	}
	system("pause");
}

4.写在最后

非常感谢大家的光临!希望大家有什么宝贵的建议和意见能够在评论区多多交流!希望与大家一同进步!!

(来自一名励志用“普通话”讲技术的菜狗子~)

你可能感兴趣的:(一起用普通话讲算法,算法,计算机视觉)