一、概述
码本模型是 Kim 等提出的一种新颖的背景建模方法。该模型针对彩色视频图像序列,根据像素点的连续采样值的颜色失真程度及其亮度范围,将背景像素用码本表示,然后利用背景差分法思想对新输入像素值与其对应码本做比较判断,从而提取出前景目标像素。它不同于差分和均值滤波等方法,因为它被比较的背景模型值不只一个;也不同于混合高斯和非参数估计等方法要计算概率分布。码本模型为每一个背景像素点创建一个码本,且码本随着其中码字的更新而更新,所有像素点的码本构成一个完整的背景。他是用一个码本(codebook)cb来描述一个像素p,cb中包含着若干码元(code element)ce,这些ce就是该像素点p的一个聚类,码本算法背景建模的过程就是要构建像素的一个个聚类,即码本。
CodeBook算法的基本思想是得到每个像素的时间序列模型。这种模型能很好地处理时间起伏,缺点是需要消耗大量的内存。CodeBook算法为当前图像的每一个像素建立一个CodeBook(CB)结构,每个CodeBook结构又由多个CodeWord(CW)组成。
处理图像时每个像素对应一个码本,每个码本中可有若干个码元
CodeBook(CB)的数据结构如下:
typedef struct code_book {
code_element **cb;
// 码元的二维指针,理解为指向码元指针数组的指针,使得添加码元时不需要来回复制码元,只需要简单的指针赋值即可
int numEntries; // 此码本中码元的数目
int t; // 记录该像素码本的当前时间,以一帧做为一个时间单位
} codeBook;
CodeWord(CW)的数据结构如下:
typedef struct ce {
uchar learnHigh[CHANNELS]; // High side threshold for learning
// 此码元各通道的阀值上限(学习界限)
uchar learnLow[CHANNELS]; // Low side threshold for learning
// 此码元各通道的阀值下限
// 学习过程中如果一个新像素各通道值x[i],均有 learnLow[i]<=x[i]<=learnHigh[i],则该像素可合并于此码元
uchar max[CHANNELS]; // High side of box boundary
// 属于此码元的像素中各通道的最大值
uchar min[CHANNELS]; // Low side of box boundary
// 属于此码元的像素中各通道的最小值
int t_last_update; // This is book keeping to allow us to kill stale entries
// 此码元最后一次更新的时间,每一帧为一个单位时间,用于计算stale
int stale; // max negative run (biggest period of inactivity)
// 此码元最长不更新时间,用于删除规定时间不更新的码元,精简码本
} code_element; // 码元的数据结构
这里定义结构体用到了typedef,当没有typedef时,codeBook代表的是一个code_book类型的变量名字叫codeBook,不是code_book类型的别名,当加上typedef,codeBook表示code_book类型别名
背景建模过程
对于彩色视频图像,本算法采用量化的方法来构建背景模型,针对每一个像素点连续采样值的颜色距离和亮度范围为每个像素点构建一个码本,根据采样值的变化情况,每个码本中码元的个数不同。
假设训练序列中单个像素的序列采样值为:X={x1,x2,...,xn},它其中的每一个元素都是一个RGB 向量。设C={c1,c2,...,cl} 为该像素的码本,码本中含有L个码元。每个像素的码本都是不同的,主要是由训练序列中该像素点的采样值变化决定的。每个码字Ci(i=1,...L) 由两部分组成:RGB 向量 vi=(Ri,Gi,Bi) 和 auxi = <I, I, f, t, p, q>,码元中各元素的含义:
I:码元中最小最大亮度值;
f: 码元出现的频率;
t: 训练阶段该码元没有出现的最大时间间隔;
p,q: 码元第一次出现和最后一次出现的时间。
码本训练阶段每一个采样值 xt(1<= t <= N) 都和已有的码字进行比较,找到( 如果存在) 最匹配的码字 Cm 与其匹配,将匹配的码字进行更新;如果找不到匹配码字,则为其创建一个新的码字存入码本中。码本提取算法详细过程如下:
步骤一:初始化,将每个像素的码本置空,每个码本中码字的个数为零,即 L=0 。
步骤二:对于训练视频帧中的每个像素的序列采样值 X={x1,x2,...,xn} 中每一个值 xt = RGB(Rt,Gt,Bt):定义其亮度值 I=R+G+B 并根据以下两个条件来找出匹配的码字Cm。
步骤三:如果码本为空或者未找到匹配的码字,则令 L=L+1 并创建一个码字Cl:
否则,更新匹配的码字Cm,设Cm 的内容为vm=(Rm,Gm,Bm),
步骤四:训练结束后,计算该像素每个码字没有出现的最大时间间隔,对于Ci(i=1,...L):
利用 t 来消除冗余的码字,得到最能代表真实背景的初始码本M = {Ck | Ck 属于C,tk < Tm} ,k 为码字的索引。其中,阈值Tm通常取训练帧数的一半,即N /2 表示所有代表背景的码字至少在最近 N/2 帧中出现过。
步骤二中两个条件的满足情况是 Xt 与 Cm 的颜色向量非常接近,并且 Xt 的亮度在 Cm 可接受的范围内。引入时间准则 t 是由于训练过程中码字存在冗余,其中有一些码字可能代表噪声和前景运动目标。通过精简码本,从而得到最真实的背景。
from:背景建模--基于码本背景建模
码本算法的背景建模过程是一个统计的过程,即它会在建模阶段将某点出现的可能的像素值进行统计,根据其出现的某种特征属性,设定阀值进行判断,符合条件的像素值即可作为背景像素。
例如,要进行建模的帧数为5帧(为举例方便,一般建模应该在一秒以上,30帧左右,当然不是建模所用的帧数越多越好),在这5帧中图像相同位置处的某点p(x,y),像素取值依次是pi = {135,140,200,145,135}(此处假设所建模的对象是单通道的灰度图),那么在建模过程中:
当第一帧像素值135到来时,由于p点对应像素值的码本没有码元,那么就新建一个码元,并为该码元赋值,设定code_element的各项属性值;
第二帧到来后,140像素值会和已有的第一个码元进行匹配比较,在码元中有一对属性值learn_high和learn_low,这对属性值规定了该码元能“融合”的像素值范围,如果满足条件135-learn_low < 140 < 135+learn_high,即可将140像素融合,若不满足则为140像素新建一个码元,此处假设learn_high和learn_low均为10,那么可融合140,融合后更新该码元的一些数据,例如出现频数f要加1,stale最久未更新时间,他是最后更新时间t_last_update和当前时间t的差值;
同理之后的第3、4、5帧按此过程,被融合或者新建码元,会发现只有像素200没有被融合,自己新建了一个码元。
当匹配完后,就要进入下一个阶段——剔除噪声码元,剔除的依据是设定合理的阀值,若不满足条件则认为是噪声,剔除所依据的属性可以是频数f或者最久未更新时间stale,或者是二者的组合条件,具体使用什么样的剔除方法,要根据自己想要的结果进行选择,此处设立条件为f > t/2,即如果一个码元的更新频数如果小于一半建模帧数就认为它是噪声,将其删除,即认为该码元出现的次数太少,可以认为他不是能够长时间保持稳定不变的背景像素。那么,经过剔除的码元即是可以合理描述背景的码元。
最后将要检测的帧的像素和对应位置的背景模型的像素码元进行匹配,如果匹配则说明该点是背景点,如果不匹配则说明改点是前景点!
from:codebook码本算法
例程:
#include<opencv2/opencv.hpp>
#include<iostream>
using namespace std;
using namespace cv;
#define CHANNELS 3 // 设置处理的图像通道数,要求小于等于图像本身的通道数
///
// 下面为码本码元的数据结构
// 处理图像时每个像素对应一个码本,每个码本中可有若干个码元
// 当涉及一个新领域,通常会遇到一些奇怪的名词,不要被这些名词吓坏,其实思路都是简单的
typedef struct ce {
uchar learnHigh[CHANNELS]; // High side threshold for learning
// 此码元各通道的阀值上限(学习界限)
uchar learnLow[CHANNELS]; // Low side threshold for learning
// 此码元各通道的阀值下限
// 学习过程中如果一个新像素各通道值x[i],均有 learnLow[i]<=x[i]<=learnHigh[i],则该像素可合并于此码元
uchar max[CHANNELS]; // High side of box boundary
// 属于此码元的像素中各通道的最大值
uchar min[CHANNELS]; // Low side of box boundary
// 属于此码元的像素中各通道的最小值
int t_last_update; // This is book keeping to allow us to kill stale entries
// 此码元最后一次更新的时间,每一帧为一个单位时间,用于计算stale
int stale; // max negative run (biggest period of inactivity)
// 此码元最长不更新时间,用于删除规定时间不更新的码元,精简码本
} code_element; // 码元的数据结构
typedef struct code_book {
code_element **cb;
// 码元的二维指针,理解为指向码元指针数组的指针,使得添加码元时不需要来回复制码元,只需要简单的指针赋值即可
int numEntries;
// 此码本中码元的数目
int t; // count every access,此码本现在的时间,一帧为一个时间单位
} codeBook; // 码本的数据结构
///
// int updateCodeBook(uchar *p, codeBook &c, unsigned cbBounds)
// Updates the codebook entry with a new data point
//
// p Pointer to a YUV pixel
// c Codebook for this pixel
// cbBounds Learning bounds for codebook (Rule of thumb: 10)
// numChannels Number of color channels we're learning
//
// NOTES:
// cvBounds must be of size cvBounds[numChannels]
//
// RETURN
// codebook index
int updateCodeBook(uchar *p, codeBook &c, unsigned *cbBounds, int numChannels)
{
if (c.numEntries == 0) c.t = 0;
// 码本中码元为零时初始化时间为0
c.t += 1; // Record learning event
// 每调用一次加一,即每一帧图像加一
//SET HIGH AND LOW BOUNDS
int n;
unsigned int high[3], low[3];
for (n = 0; n<numChannels; n++)
{
high[n] = *(p + n) + *(cbBounds + n);
// *(p+n) 和 p[n] 结果等价,经试验*(p+n) 速度更快
if (high[n] > 255) high[n] = 255;
low[n] = *(p + n) - *(cbBounds + n);
if (low[n] < 0) low[n] = 0;
// 用p 所指像素通道数据,加减cbBonds中数值,作为此像素阀值的上下限
}
//SEE IF THIS FITS AN EXISTING CODEWORD
int matchChannel;
int i;
for (i = 0; i<c.numEntries; i++)
{
// 遍历此码本每个码元,测试p像素是否满足其中之一
matchChannel = 0;
for (n = 0; n<numChannels; n++)
//遍历每个通道
{
if ((c.cb[i]->learnLow[n] <= *(p + n)) && (*(p + n) <= c.cb[i]->learnHigh[n])) //Found an entry for this channel
// 如果p 像素通道数据在该码元阀值上下限之间
{
matchChannel++;
}
}
if (matchChannel == numChannels) // If an entry was found over all channels
// 如果p 像素各通道都满足上面条件
{
c.cb[i]->t_last_update = c.t;
// 更新该码元时间为当前时间
// adjust this codeword for the first channel
for (n = 0; n<numChannels; n++)
//调整该码元各通道最大最小值
{
if (c.cb[i]->max[n] < *(p + n))
c.cb[i]->max[n] = *(p + n);
else if (c.cb[i]->min[n] > *(p + n))
c.cb[i]->min[n] = *(p + n);
}
break;
}
}
// ENTER A NEW CODE WORD IF NEEDED
if (i == c.numEntries) // No existing code word found, make a new one
// p 像素不满足此码本中任何一个码元,下面创建一个新码元
{
code_element **foo = new code_element*[c.numEntries + 1];
// 申请c.numEntries+1 个指向码元的指针
for (int ii = 0; ii<c.numEntries; ii++)
// 将前c.numEntries 个指针指向已存在的每个码元
foo[ii] = c.cb[ii];
foo[c.numEntries] = new code_element;
// 申请一个新的码元
if (c.numEntries) delete[] c.cb;
// 删除c.cb 指针数组
c.cb = foo;
// 把foo 头指针赋给c.cb
for (n = 0; n<numChannels; n++)
// 更新新码元各通道数据
{
c.cb[c.numEntries]->learnHigh[n] = high[n];
c.cb[c.numEntries]->learnLow[n] = low[n];
c.cb[c.numEntries]->max[n] = *(p + n);
c.cb[c.numEntries]->min[n] = *(p + n);
}
c.cb[c.numEntries]->t_last_update = c.t;
c.cb[c.numEntries]->stale = 0;
c.numEntries += 1;
}
// OVERHEAD TO TRACK POTENTIAL STALE ENTRIES
for (int s = 0; s<c.numEntries; s++)
{
// This garbage is to track which codebook entries are going stale
int negRun = c.t - c.cb[s]->t_last_update;
// 计算该码元的不更新时间
if (c.cb[s]->stale < negRun)
c.cb[s]->stale = negRun;
}
// SLOWLY ADJUST LEARNING BOUNDS
for (n = 0; n<numChannels; n++)
// 如果像素通道数据在高低阀值范围内,但在码元阀值之外,则缓慢调整此码元学习界限
{
if (c.cb[i]->learnHigh[n] < high[n])
c.cb[i]->learnHigh[n] += 1;
if (c.cb[i]->learnLow[n] > low[n])
c.cb[i]->learnLow[n] -= 1;
}
return(i);
}
///
// uchar cvbackgroundDiff(uchar *p, codeBook &c, int minMod, int maxMod)
// Given a pixel and a code book, determine if the pixel is covered by the codebook
//
// p pixel pointer (YUV interleaved)
// c codebook reference
// numChannels Number of channels we are testing
// maxMod Add this (possibly negative) number onto max level when code_element determining if new pixel is foreground
// minMod Subract this (possible negative) number from min level code_element when determining if pixel is foreground
//
// NOTES:
// minMod and maxMod must have length numChannels, e.g. 3 channels => minMod[3], maxMod[3].
//
// Return
// 0 => background, 255 => foreground
uchar backgroundDiff(uchar *p, codeBook &c, int numChannels, int *minMod, int *maxMod)
{
// 下面步骤和背景学习中查找码元如出一辙
int matchChannel;
//SEE IF THIS FITS AN EXISTING CODEWORD
int i;
for (i = 0; i<c.numEntries; i++)
{
matchChannel = 0;
for (int n = 0; n<numChannels; n++)
{
if ((c.cb[i]->min[n] - minMod[n] <= *(p + n)) && (*(p + n) <= c.cb[i]->max[n] + maxMod[n]))
matchChannel++; //Found an entry for this channel
else
break;
}
if (matchChannel == numChannels)
break; //Found an entry that matched all channels
}
if (i == c.numEntries)
// p像素各通道值满足码本中其中一个码元,则返回白色
return(255);
return(0);
}
//UTILITES/
/
//int clearStaleEntries(codeBook &c)
// After you've learned for some period of time, periodically call this to clear out stale codebook entries
//
//c Codebook to clean up
//
// Return
// number of entries cleared
int clearStaleEntries(codeBook &c)
{
int staleThresh = c.t >> 1; // 设定刷新时间
int *keep = new int[c.numEntries]; // 申请一个标记数组
int keepCnt = 0; // 记录不删除码元数目
//SEE WHICH CODEBOOK ENTRIES ARE TOO STALE
for (int i = 0; i<c.numEntries; i++)
// 遍历码本中每个码元
{
if (c.cb[i]->stale > staleThresh)
// 如码元中的不更新时间大于设定的刷新时间,则标记为删除
keep[i] = 0; //Mark for destruction
else
{
keep[i] = 1; //Mark to keep
keepCnt += 1;
}
}
// KEEP ONLY THE GOOD
c.t = 0; //Full reset on stale tracking
// 码本时间清零
code_element **foo = new code_element*[keepCnt];
// 申请大小为keepCnt 的码元指针数组
int k = 0;
for (int ii = 0; ii<c.numEntries; ii++)
{
if (keep[ii])
{
foo[k] = c.cb[ii];
foo[k]->stale = 0; //We have to refresh these entries for next clearStale
foo[k]->t_last_update = 0;
k++;
}
}
//CLEAN UP
delete[] keep;
delete[] c.cb;
c.cb = foo;
// 把foo 头指针地址赋给c.cb
int numCleared = c.numEntries - keepCnt;
// 被清理的码元个数
c.numEntries = keepCnt;
// 剩余的码元地址
return(numCleared);
}
int main()
{
///
// 需要使用的变量
VideoCapture capture("D:\\Program Files\\OpenCV\\opencv\\sources\\samples\\data\\tree.avi");
Mat rawImage;
codeBook* cB;
unsigned cbBounds[CHANNELS];
uchar* pColor; //YUV pointer
int imageLen;
int nChannels = CHANNELS;
int minMod[CHANNELS];
int maxMod[CHANNELS];
namedWindow("Raw");
namedWindow("CodeBook");
capture >> rawImage;
Mat yuvImage(rawImage.rows, rawImage.cols, 8, 3);// 给yuvImage 分配一个和rawImage 尺寸相同,8位3通道图像
Mat ImaskCodeBook(rawImage.rows, rawImage.cols, CV_8UC1, Scalar(255)); // 为ImaskCodeBook 分配一个和rawImage 尺寸相同,8位单通道图像,初始化为白色图像
imageLen = rawImage.cols * rawImage.rows;
cB = new codeBook[imageLen]; // 得到与图像像素数目长度一样的一组码本,以便对每个像素进行处理
for (int i = 0; i<imageLen; i++) // 初始化每个码元数目为0
cB[i].numEntries = 0;
for (int i = 0; i<nChannels; i++)
{
cbBounds[i] = 10; // 用于确定码元各通道的阀值
minMod[i] = 20; // 用于背景差分函数中
maxMod[i] = 20; // 调整其值以达到最好的分割
}
//
// 开始处理视频每一帧图像
for (int i = 0;; i++)
{
capture >> rawImage;
if (rawImage.empty())
break;
cvtColor(rawImage, yuvImage, CV_BGR2YCrCb);
// 色彩空间转换,将rawImage 转换到YUV色彩空间 // 即使不转换效果依然很好
if (i <= 30)
// 30帧内进行背景学习
{
pColor = (uchar *)(yuvImage.ptr(0));//ptr(0)是指向yuvimage第一行第一个元素的指针
// 指向yuvImage 图像的通道数据
for (int c = 0; c<imageLen; c++)//遍历整张图的所有像素,生成对应的码本
{
updateCodeBook(pColor, cB[c], cbBounds, nChannels);//cB表示一组码本,码本个数与像素总数一致,cbBounds表示码元的阈值,nchannles=3.
// 对每个像素,调用此函数,捕捉背景中相关变化图像
pColor += 3;
// 3 通道图像, 指向下一个像素通道数据
}
if (i == 30)
// 到30 帧时调用下面函数,删除码本中陈旧的码元
{
for (int c = 0; c<imageLen; c++)//遍历整张图的所有像素对应的码本
clearStaleEntries(cB[c]);
}
}
else
{
uchar maskPixelCodeBook;
pColor = (uchar *)(yuvImage.ptr<uchar>(0)); //3 channel yuv image
uchar *pMask = (uchar *)(ImaskCodeBook.ptr(0)); //1 channel image
// 指向ImaskCodeBook 通道数据序列的首元素
for (int c = 0; c<imageLen; c++)
{
maskPixelCodeBook = backgroundDiff(pColor, cB[c], nChannels, minMod, maxMod);
// 我看到这儿时豁然开朗,开始理解了codeBook 呵呵
*pMask++ = maskPixelCodeBook;
pColor += 3;
// pColor 指向的是3通道图像
}
}
imshow("Raw", rawImage);
imshow("CodeBook", ImaskCodeBook);
waitKey(20);
if (i == 450)
waitKey(0);
cout << "这是处理的第 " << i << " 帧图像数据\n\r";
}
destroyAllWindows();//不加这个,会出现退出异常
delete[] cB;
return 0;
}
结果: