简介
码书法,是对每个像素建立一个记录背景范围的码书,一个码书上包含多个表示背景范围的盒子,类似于颜色空间YUV(背景颜色变化多发生在亮度轴V上)上漂浮着的气团。如果新像素和先前建立的盒子足够近,则扩展盒子。否则,新建新的盒子。为了能在存在移动的前景时进行背景学习,需要更低频率地(为了更好地学习背景和避免清理掉背景盒子)清理码书中的被判定为前景的盒子。最后进行背景差分,不在码书中全部盒子范围内的为前景,否则,为背景。
由于不可避免的存在各种干扰,需要使用形态学操作和连通分量法进行处理,最后用多边形逼近或者凸包逼近绘出处理后的前景结果。
结构
- 码书类继承自std::vector,加上记录当前帧数的成员变量t。
- 码数单元(盒子),包括盒子上下边界(max,min)、学习阈上下边界(learnHigh,learnLow)、最后一次被击中帧数(t_last_update)、盒子创建时帧数(t_create)(自己加的成员变量,改进stale计算方式)、用于判断长时间未被击中(stale)。
codeBookh.h
#pragma once
#include <iostream>
#include <opencv.hpp>
using namespace std;
//#define TRANSPOSE
#define CHANNELS 3
#define DP_EPSILON_DENOMINATOR 100.0
#define CVCLOSE_KSIZE 5 //闭操作核大小
#define CVOPEN_ITR 1//腐蚀膨胀次数
string videoname = "test2.mp4";
unsigned int bound[3] = { 10,10,10 };//像素边框,用于调整学习阈
int minMod[3] = { 5,5,5 };//误差范围,用于检测前景
int maxMod[3] = { 5,5,5 };
int fpr = 300;//每轮学习帧数
int fpsScale = 2;//每隔几帧取一帧计算
int rsb = 8;//图像缩小倍数
bool p1h0 = true;
int deleNum = 0;//调用清理盒子次数
float minAreaScale = 0.005;//最小面积比例
/*
码书单元(盒子)
*/
class CodeElement {
public:
uchar learnHigh[CHANNELS];//学习阈值上限
uchar learnLow[CHANNELS];//学习阈值下限
uchar max[CHANNELS];//盒子上边界
uchar min[CHANNELS];//盒子下边界
int t_last_update;//最后一次被命中的时间戳
int stale;//反映历史最长没命中此盒子的时间段,time(此时)-t_last_update>stale便更新,越大表示此盒子越不是背景(干扰或移动前景)
int t_create;//创建时间戳
CodeElement() {
for (int i = 0; i < CHANNELS; i++)
learnHigh[i] = learnLow[i] = max[i] = min[i] = 0;
t_last_update = t_create = 0;
}
CodeElement& operator=(const CodeElement& ce) {
for (int i = 0; i < CHANNELS; i++) {
learnHigh[i] = ce.learnHigh[i];
learnLow[i] = ce.learnLow[i];
max[i] = ce.max[i];
min[i] = ce.min[i];
}
t_last_update = ce.t_last_update;
t_create = ce.t_create;
stale = ce.stale;
return *this;
}
CodeElement(const CodeElement& ce) { *this = ce; }
};
/*
码书,每个像素上对应一个码书
*/
class CodeBook :public std::vector<CodeElement> {//vector太低效,可用MAX_CODE来限制最大码书大小,静态分配数组CodeElement[MAX_CODES]
public:
int t;//累加的点的个数
CodeBook() { t = 0; }
CodeBook(int n) :std::vector<CodeElement>(n) { t = 0; }//构建有n个码书单元的码书,更新出直接用book[i]
};
int updateCodebook(const cv::Vec3b& p, CodeBook& c, unsigned* cbBounds, int numChannels);
int clearStaleEntries(CodeBook& c);
uchar backgroundDiff(const cv::Vec3b& p, CodeBook& c, int numChannels, int* minMod, int* maxMod);
void findConnectedComponents(cv::Mat& mask, std::vector<cv::Rect>& bbs, std::vector<cv::Point>& centers, float minArea, bool poly1_hull0 = 1);
背景学习
由于在背景学习时,stale只是t-t_last_update并不能很好的表示此盒子在一轮视频流中未被击中的情况。这样会导致在盒子清理时,视频流偏后面出现的移动前景不能被清理掉。所以,此处修改为t - t_last_update + t_create。
每个像素自带一个自定义大小的边框,用于在创建新盒子时设置学习阈,或者是在扩展旧盒子时调整学习阈。
#include "pch.h"
#include "codeBookh.h"
/*
更新码书,返回更新的码书单元索引
p:输入的YUV像素,如果只看亮度,此处做修改
c:此像素的码书
cbBounds:码书边框,通常{10,10,10}
numChannels:像素维度,如果小于3,默认各通道YUV
*/
int updateCodebook(const cv::Vec3b& p, CodeBook& c, unsigned* cbBounds, int numChannels) {
//计算像素的上下边框位置
unsigned int high[3], low[3], n;
for (n = 0; n < numChannels; n++) {
high[n] = p[n] + *(cbBounds + n);
high[n] = high[n] > 255 ? 255 : high[n];//用于调整学习阈
low[n] = p[n] - *(cbBounds + n);
low[n] = low[n] < 0 ? 0 : low[n];//用于调整学习阈
}
//判断是否在已有盒子学习阈内
int i;//盒子索引
int matchChannel;//所有维度上满足学习阈内的维度数
for (i = 0; i < c.size(); i++) {
matchChannel = 0;//遍历每个盒子先清零
//遍历每个维度
for (n = 0; n < numChannels; n++) {
if ((c[i].learnLow[n] <= p[n]) && (p[n] <= c[i].learnHigh[n]))
matchChannel++;
}
//判断是否在此盒子内
if (matchChannel == numChannels) {
c[i].t_last_update = c.t;//更新盒子时间戳
//调整盒子边界
for (n = 0; n < numChannels; n++) {
c[i].max[n] = c[i].max[n] < p[n] ? p[n] : c[i].max[n];
c[i].min[n] = c[i].min[n] > p[n] ? p[n] : c[i].min[n];
}
break;//成功找到满足的盒子,返回盒子索引,不需要创建新盒子
}
}
//维护码书中各盒子的状态信息
for (int s = 0; s < c.size(); s++) {
int negRun = c.t - c[s].t_last_update + c[s].t_create;
c[s].stale = c[s].stale < negRun ? negRun : c[s].stale;
}
//cout << endl;
//判断是否需要创建新盒子
if (i == c.size()) {
CodeElement ce;
for (n = 0; n < numChannels; n++) {
ce.learnHigh[n] = high[n];
ce.learnLow[n] = low[n];
ce.max[n] = p[n];
ce.min[n] = p[n];
}
ce.t_last_update = c.t;
ce.t_create = c.t;
ce.stale = 0;
c.push_back(ce);
}
//缓慢调整盒子学习阈
for (n = 0; n < numChannels; n++) {
c[i].learnHigh[n] += c[i].learnHigh[n] < high[n] ? 1 : 0;
c[i].learnLow[n] -= c[i].learnLow[n] > low[n] ? 1 : 0;
}
return i;
}
清理前景盒子
在改进了stale的计算方式后,可以将一轮学习中偏后面出现的移动前景也清理掉。
除此以外,若是在此处将t_last_update等归零,会使得多轮学习能不断习得新的背景,但也可能误将前景当做背景,且会在帧数过少时清理掉背景盒子;
不清零则第一轮习得背景盒子后只会扩展或减少,不会增加,若存在一个长时间不动的前景物体被模型习得为背景,当此前景物体移走时,会出现一个空洞。但是不清零能更有效去掉前景盒子,综合利用多轮训练的视频流,且不会在帧数过少时清掉背景盒子。
为了解决以上问题,不能等周期调用此函数,要以更低频率调用此函数。
/*
删去学习过程中很少被命中的盒子
可用于在有少量前景浮动的视频流学习背景
返回删掉的盒子数
c:一个像素的码书
*/
int clearStaleEntries(CodeBook& c) {
int staleThresh = c.t >> 1;//整个视频帧数的一半,即一半以上未击中则为背景
int* keep = new int[c.size()];//标记每个盒子是否要删除
int keepCnt = 0;//保留的盒子数,用于调整盒子数量vector来删除之后的全部盒子
//标记每个盒子是否常用
for (int i = 0; i < c.size(); i++) {
if (c[i].stale > staleThresh) {
keep[i] = 0;
}
else
{
keep[i] = 1;
keepCnt += 1;
}
}
//调整盒子索引,保留的在前,去掉后面的
int k = 0;//保留索引
int numCleared = 0;//记录删除掉的盒子数
for (int ii = 0; ii < c.size(); ii++) {
if (keep[ii]) {
c[k] = c[ii];
//c[k].t_last_update = 0;//清零使得多轮学习能不断习得背景,不清零则第一轮习得背景盒子后等周期训练盒子会只减不增
//c[k].t_create = 0;//但是不清零能更有效去掉前景盒子,且不会在帧数过少时清掉背景盒子
k++;
}
else {
numCleared++;
}
}
c.resize(keepCnt);
//c.t = 0;
delete[] keep;
return numCleared;
}
检测前景
在检测前景时,附加了一个自定义大小的允许偏离的阈值(maxMod,minMod)。在判断是否在某盒子内时,允许其上下有一定的偏离。再此偏离之外才是候选前景。
/*
检测前景物体
背景返回0,前景返回255
p:传入像素(YUV)
c:此像素的码书
numChannels:通道数
minMod:允许的误差下限范围
maxMod:允许的误差上限范围
*/
uchar backgroundDiff(const cv::Vec3b& p, CodeBook& c, int numChannels, int* minMod, int* maxMod) {
int matchChannel;
int i;
//判断是否在误差范围内满足某个盒子
for (i = 0; i < c.size(); i++) {
matchChannel = 0;
for (int n = 0; n < numChannels; n++) {
if ((c[i].min[n] - minMod[n] <= p[n]) && (p[n] <= c[i].max[n] + maxMod[n])) {
matchChannel++;
}
else
{
break;
}
}
if (matchChannel == numChannels)
break;
}
if (i >= c.size())
return 255;
return 0;
}
连通分量前景清理
/*
使用连通分量进行清理
先开运算去小噪声,再闭运算重建开运算中丢失的区域,对小连通分量标记和剔除,寻找前景轮廓并用多边形或者凸包逼近
mask:传入的8位单通道图像
bbs:边框矩形
centers:质心
minArea:允许的最小连通分量
poly1_hull0:默认1,前景轮廓多边形逼近。0,凸包逼近
*/
void findConnectedComponents(cv::Mat& mask, std::vector<cv::Rect>& bbs, std::vector<cv::Point>& centers, float minArea, bool poly1_hull0) {
//开闭运算
cv::morphologyEx(mask, mask, cv::MORPH_OPEN, cv::Mat(), cv::Point(-1, -1), CVOPEN_ITR);
cv::morphologyEx(mask, mask, cv::MORPH_CLOSE, cv::getStructuringElement(cv::MORPH_RECT,cv::Size(CVCLOSE_KSIZE, CVCLOSE_KSIZE)), cv::Point(-1, -1), CVOPEN_ITR);
//cv::imshow("morph", mask);
//剔除小连通分量
cv::Mat labels, stats, centroids;
int num=cv::connectedComponentsWithStats(mask, labels, stats, centroids);//stats:N*5,[x0,y0,width0,height0,area0; centroids:N*2,[cx0,xy0;
std::vector<int> colors(num);
colors[0] = 0;//背景
for (int i = 1; i < num; i++) {
if (stats.at<int>(i, cv::CC_STAT_AREA) < minArea) {
colors[i] = 0;//小连通分量颜色为黑
}
else
{
colors[i] = 255;
cv::Rect rect = cv::Rect(stats.at<int>(i, cv::CC_STAT_LEFT), stats.at<int>(i, cv::CC_STAT_TOP), stats.at<int>(i, cv::CC_STAT_WIDTH), stats.at<int>(i, cv::CC_STAT_HEIGHT));
bbs.push_back(rect);
cv::Point pot = cv::Point(centroids.at<double>(i, 0), centroids.at<double>(i, 1));
centers.push_back(pot);
}
}
mask.setTo(0);
for (int r = 0; r < mask.rows; r++) {
for (int c = 0; c < mask.cols; c++) {
int label = labels.at<int>(r, c);
mask.at<uchar>(r, c) = colors[label];
}
}
//逼近
std::vector<std::vector<cv::Point>> contours_all, contours;
cv::findContours(mask, contours_all, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_SIMPLE);
for (int i = 0; i < contours_all.size(); i++) {
int len = cv::arcLength(contours_all[i], true);
std::vector<cv::Point> c_new;
if (poly1_hull0) {
cv::approxPolyDP(contours_all[i], c_new, len/DP_EPSILON_DENOMINATOR, true);
}
else
{
cv::convexHull(contours_all[i], c_new);
}
contours.push_back(c_new);
}
mask.setTo(0);
cv::drawContours(mask, contours, -1, 255, -1);
}
调参
我们在视频流上使用updateCodeBook()学习背景模型后,用clearStaleEntries()清理模型,周期性更新模型,并以更低的频率周期性清理(多轮可以排查掉在一轮更新时前期后期都出现的同一前景)
int main()
{
cv::VideoCapture cap;
cv::Mat frame, frame_YUV;
CodeBook* cb;
cv::Vec3b p;
cap.open(videoname);
cap >> frame;
int len = frame.rows*frame.cols / (rsb*rsb);
cb = new CodeBook[len];
int n = 1, fnum = 1;
//学习背景
for (;;) {
cap >> frame;
if (frame.empty())
break;
//周期性取帧计算
if (fnum != fpsScale) {
fnum++;
continue;
}
else {
fnum = 0;
}
#ifdef TRANSPOSE
cv::transpose(frame, frame);
#endif // TRANSPOSE
cv::resize(frame, frame, frame.size() / rsb);
cv::imshow("src", frame);
if (cv::waitKey(33) >= 0)
break;
cv::cvtColor(frame, frame_YUV, cv::COLOR_BGR2YUV);
for (int row = 0; row < frame.rows; row++) {
for (int col = 0; col < frame.cols; col++) {
int n = row * frame.cols + col;
cb[n].t += 1;
p = frame_YUV.at<cv::Vec3b>(row, col);
updateCodebook(p, cb[n], bound, CHANNELS);
}
}
//以更低频率周期性地清理
if (n != fpr) {
n++;
}
else {
n = 0;
fpr*=2;
//清除码书中不常用的盒子
for (int l = 0; l < len; l++) {
int num = clearStaleEntries(cb[l]);
cout << "delete:" << num << endl;
}
//cout << ++deleNum << endl;
}
}
cap.release();
//清除码书中不常用的盒子
for (int l = 0; l < len; l++) {
int num = clearStaleEntries(cb[l]);
//cout << "delete:" << num << endl;
}
cout << ++ deleNum << endl;
cap.open(videoname);
//检测前景
for (;;) {
cap >> frame;
if (frame.empty())
break;
#ifdef TRANSPOSE
cv::transpose(frame, frame);
#endif // TRANSPOSE
cv::resize(frame, frame, frame.size() / rsb);
if (cb->size() <= 0)
{
cerr << "没有学习到的码书" << endl;
system("pause");
return 0;
}
cv::cvtColor(frame, frame_YUV, cv::COLOR_BGR2YUV);
cv::Mat dst = cv::Mat::zeros(frame.size(), CV_8U);
for (int row = 0; row < frame.rows; row++) {
for (int col = 0; col < frame.cols; col++) {
int n = row * frame.cols + col;
//cb[n].t += 1;
p = frame_YUV.at<cv::Vec3b>(row, col);
uchar d = backgroundDiff(p, cb[n], CHANNELS, minMod, maxMod);
dst.at<uchar>(row, col) = d;
}
}
cv::imshow("dst", dst);
//使用连通分量进行前景清理
std::vector<cv::Rect> bbs;//前景框
std::vector<cv::Point> centers;//前景质心
findConnectedComponents(dst, bbs, centers, dst.rows*dst.cols*minAreaScale, p1h0);
for (int i = 0; i < bbs.size(); i++) {
cv::rectangle(frame, bbs[i], cv::Scalar(0, 0, 255));
cv::circle(frame, centers[i], 2, cv::Scalar(0, 0, 255), -1);
}
cv::imshow("src", frame);
cv::imshow("polyDst", dst);
if (cv::waitKey(33) >= 0)
break;
}
return 0;
}
效果
左图为原视频帧,中图为前景提取图,右图为前景清理逼近后的图