介绍
在本实验中,我们将使用 OpenCV 实现视频目标跟踪。在学习本项目之前,你必须完成「使用 C++ 构建太阳系」课程。
学习内容
- C++ 基础
- g++ 基础
- 图像表示
- OpenCV 应用
- Meanshift 和 Camshift 算法
最终成果
本实验将实现一个能够跟踪太阳系中行星的程序。(在下图中,我们从黄色轨道中选择了木星,你可以看到被跟踪的目标已被红色椭圆标记):

在编写此项目之前,你必须完成我们的「使用 C++ 构建太阳系」课程。
在本实验中,我们将使用 OpenCV 实现视频目标跟踪。在学习本项目之前,你必须完成「使用 C++ 构建太阳系」课程。
本实验将实现一个能够跟踪太阳系中行星的程序。(在下图中,我们从黄色轨道中选择了木星,你可以看到被跟踪的目标已被红色椭圆标记):
在编写此项目之前,你必须完成我们的「使用 C++ 构建太阳系」课程。
在 LabEx 环境中,我们不支持摄像头环境。因此,我们需要为项目创建一个视频文件。
让我们安装视频录制工具:
sudo apt-get update && sudo apt-get install gtk-recordmydesktop
安装完成后,我们可以在应用程序菜单中找到录制软件:
然后,你可以运行太阳系程序 ./solarsystem
,并使用 RecordMyDesktop 录制桌面屏幕(10~30 秒即可),并将其保存到 ~/Code/camshift
目录下,命名为 video
:
当你想要结束录制时,可以点击右下角的 Stop 按钮。然后你将得到一个 video.ogv
文件:
OpenCV 是一个开源的跨平台计算机视觉库。与 OpenGL 的图像渲染不同,OpenCV 实现了许多常见的图像处理和计算机视觉算法。在学习 OpenCV 之前,我们需要了解一些计算机中图像和视频的基本概念。
首先,我们必须理解图片或图像在计算机中是如何表示的。常见的图片存储方式有两种:一种是矢量图,另一种是像素图。
在矢量图中,图像通过数学方式定义为由线条连接的一系列点。矢量图文件中的图形元素称为对象。每个对象都是一个独立的实体,具有颜色、形状、轮廓、大小和屏幕位置等属性。
更常见的是像素图。例如,图像的大小通常为 1024*768。这意味着图片在水平方向上有 1024 个像素,在垂直方向上有 768 个像素。
像素是像素图的基本单位。通常,一个像素是三种原色(红、绿、蓝)的混合。由于计算机的本质是对数字的识别,在正常情况下,我们用 0 到 255 的亮度值来表示一种原色。换句话说,对于红色,0
表示 最暗,即 黑色,而 255
表示 最亮,即 纯红色。
因此,一个像素可以表示为一个三元组 (R,G,B)
,例如 白色 可以表示为 (255,255,255)
,而 黑色 是 (0,0,0)
。然后我们称这种图像为 RGB 色彩空间 中的图像。R
、G
和 B
成为图像的三个 通道;除了 RGB 色彩空间之外,还有许多其他色彩空间,例如 HSV、YCrCb 等。
正如像素是像素图的基本单位,图像是视频的基本单位。视频由一系列图像组成,我们将每一张图像称为一帧。我们通常所说的视频帧率意味着该视频每秒包含多少帧图像。例如,如果帧率为 25,则该视频每秒播放 25 帧。
如果 1 秒有 1000 毫秒,且帧率为 rate
,则帧图像之间的时间间隔为 1000/rate
。
颜色直方图是一种用于描述图像的工具。它与普通直方图类似,不同之处在于颜色直方图需要从特定图像中计算得出。
如果一张图片处于 RGB 色彩空间 中,那么我们可以统计 R 通道 中每种颜色出现的次数。这样我们可以得到一个长度为 256 的数组(颜色概率查找表)。将所有值同时除以图像中的总像素数(宽度乘以高度),并将结果序列转换为直方图。结果就是 R 通道 的颜色直方图。类似地,你可以得到 G 通道 和 B 通道 的直方图。
已经证明,在 RGB 色彩空间中,直方图对光照变化非常敏感。为了减少这种变化对跟踪效果的影响,需要对直方图进行反向投影。这个过程分为三个步骤:
这个过程称为反向投影,颜色概率分布图是一张灰度图像。
我们首先需要安装 OpenCV:
sudo apt update
sudo apt-get install libopencv-dev
我们假设你已经熟悉 C++ 的基本语法。你知道几乎每个程序都会使用头文件 #include <iostream>
和 using namespace std;
或 std::cout
。OpenCV 也有自己的命名空间。
要使用 OpenCV,我们只需要包含以下头文件:
#include <opencv2/opencv.hpp>
然后:
using namespace cv;
以启用 OpenCV 命名空间(或者直接在所有 API 前使用 cv::
前缀)。
这是你第一次使用 OpenCV,可能对 OpenCV 的接口不太熟悉,因此我们建议使用 cv::
前缀来学习 OpenCV 的 API。
让我们编写第一个程序来读取我们录制的视频:
//
// main.cpp
//
#include <opencv2/opencv.hpp> // OpenCV 头文件
int main() {
// 创建一个视频捕获对象
// OpenCV 提供了 VideoCapture 对象,
// 并将从文件读取视频与从摄像头读取视频视为相同。
// 当输入参数是文件路径时,它将读取视频文件;
// 如果是摄像头的标识号(通常为 0),它将读取摄像头
cv::VideoCapture video("video.ogv"); // 从文件读取
// cv::VideoCapture video(0); // 从摄像头读取
// 用于存储读取的图像帧的容器,OpenCV 中的 Mat 对象
// OpenCV 中的关键类是 Mat,表示矩阵
// OpenCV 使用矩阵来描述图像
cv::Mat frame;
while(true) {
// 将视频数据写入 frame,>> 被 OpenCV 重载
video >> frame;
// 当没有帧时,退出循环
if(frame.empty()) break;
// 可视化当前帧
cv::imshow("test", frame);
// 视频帧率为 15,因此我们需要等待 1000/15 以使播放流畅
// waitKey(int delay) 是 OpenCV 中的等待函数
// 此时,程序将等待 `delay` 毫秒以获取键盘输入
int key = cv::waitKey(1000/15);
// 当按下键盘上的 ESC 键时退出循环
if (key == 27) break;
}
// 释放内存
cv::destroyAllWindows();
video.release();
return 0;
}
将此 main.cpp
文件与 video.ogv
放在 ~/Code/camshift
目录下,并编译程序:
g++ main.cpp `pkg-config opencv --libs --cflags opencv` -o main
当我们运行程序时,可以看到视频正在播放:
./main
注意
你可能会观察到以下错误:
libdc1394 error: Failed to initialize libdc1394
这是 OpenCV 的一个 bug,不会影响我们的运行。
如果你想消除这个问题,可以在运行程序之前执行以下代码:
sudo ln /dev/null /dev/raw1394
Meanshift 和 Camshift 算法是两种经典的目标跟踪算法。Camshift 基于 Meanshift。它们的数学解释较为复杂,但基本思想相对简单。因此,我们跳过这些数学细节,首先介绍 Meanshift 算法。
假设屏幕上有一组红点,蓝色圆圈(窗口)必须移动到点最密集的区域(或点数最多的位置):
如上图所示,将蓝色圆圈标记为 C1
,圆心为 C1_o
。但这个圆圈的重心是 C1_r
,标记为蓝色实心圆。
当 C1_o
和 C1_r
不重叠时,重复将圆圈 C1
移动到圆圈 C1_r
的中心。最终它将停留在密度最高的圆圈 C2
上。
对于图像处理,我们通常使用图像的反向投影直方图。当跟踪目标移动时,显然这种移动过程可以通过反向投影直方图反映出来。因此,Meanshift 算法最终会将我们选择的窗口移动到移动目标的位置。(算法最终已被证明是收敛的。)
通过前面的描述,我们看到 Meanshift 算法总是跟踪一个固定大小的窗口,这不符合我们的需求,因为在视频中,目标物体的大小可能会变化。
因此,Camshift 被创建来解决这个问题。从 Camshift 的全称 Continuously Adaptive Meanshift 也可以看出这一点。
它的基本思想是:首先应用 Meanshift 算法。一旦 Meanshift 结果收敛,Camshift 会更新窗口大小,计算一个方向椭圆来匹配窗口,然后将该椭圆作为新的窗口再次应用 Meanshift 算法。
OpenCV 提供了 Camshift 算法的通用接口:
RotatedRect CamShift(InputArray probImage, Rect& window, TermCriteria criteria)
第一个参数 probImage
是目标直方图的反向投影。第二个参数 window
是 Camshift 算法的搜索窗口。第三个参数 criteria
是算法结束(终止)的条件。
在理解了 Camshift 算法的基本思想后,我们可以分析出这段代码的实现主要分为以下几个步骤:
接下来,我们继续修改 main.cpp
中的代码。
OpenCV 与 OpenGL 不同。鼠标回调函数有五个参数,其中前三个是我们最需要的:通过 event
的值,我们可以获取鼠标左键按下的事件(CV_EVENT_LBUTTONDOWN
)、鼠标左键释放的事件(CV_EVENT_LBUTTONUP
)等:
bool selectObject = false; // 用于判断是否选择了对象
int trackObject = 0; // 1 表示有跟踪对象,0 表示没有对象,-1 表示尚未计算 Camshift 属性
cv::Rect selection; // 保存鼠标选择的区域
cv::Mat image; // 缓存视频中的图像
// OpenCV 的鼠标回调函数:
// void onMouse(int event, int x, int y, int flag, void *param)
// 第四个参数 `flag` 表示附加状态,
// param 表示用户参数,我们不需要它们,因此未命名。
void onMouse( int event, int x, int y, int, void* ) {
static cv::Point origin;
if(selectObject) {
// 确定选择的高度、宽度以及左上角位置
selection.x = MIN(x, origin.x);
selection.y = MIN(y, origin.y);
selection.width = std::abs(x - origin.x);
selection.height = std::abs(y - origin.y);
// & 被 cv::Rect 重载
// 它表示两个区域的交集,
// 这里的主要目的是处理选择区域之外的部分
selection &= cv::Rect(0, 0, image.cols, image.rows);
}
switch(event) {
// 处理左键按下事件
case CV_EVENT_LBUTTONDOWN:
origin = cv::Point(x, y);
selection = cv::Rect(x, y, 0, 0);
selectObject = true;
break;
// 处理左键释放事件
case CV_EVENT_LBUTTONUP:
selectObject = false;
if( selection.width > 0 && selection.height > 0 )
trackObject = -1; // 跟踪对象尚未计算 Camshift 属性
break;
}
}
我们已经实现了读取视频流的结构。让我们进一步完善代码:
int main() {
cv::VideoCapture video("video.ogv");
cv::namedWindow("CamShift at LabEx");
// 1. 注册鼠标事件回调
cv::setMouseCallback("CamShift at LabEx", onMouse, NULL);
cv::Mat frame;
// 2. 从视频中读取图像
while(true) {
video >> frame;
if(frame.empty()) break;
// 将帧中的图像写入全局变量 image 以缓存
frame.copyTo(image);
// 如果正在选择对象,则绘制矩形
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi);
}
imshow("CamShift at LabEx", image);
int key = cv::waitKey(1000/15.0);
if(key == 27) break;
}
// 释放分配的内存
cv::destroyAllWindows();
video.release();
return 0;
}
注意:
ROI (感兴趣区域): 在图像处理中,任何需要处理的区域都可以称为感兴趣区域,即 ROI。
计算跟踪目标的反向投影直方图需要使用 cvtColor
函数,该函数可以将 RGB 色彩空间的原始图像转换为 HSV 色彩空间。计算直方图必须在选择初始目标之后进行,因此:
int main() {
cv::VideoCapture video("video.ogv");
cv::namedWindow("CamShift at LabEx");
cv::setMouseCallback("CamShift at LabEx", onMouse, NULL);
cv::Mat frame;
cv::Mat hsv, hue, mask, hist, backproj;
cv::Rect trackWindow; // 跟踪窗口
int hsize = 16; // 用于直方图
float hranges[] = {0,180}; // 用于直方图
const float* phranges = hranges; // 用于直方图
while(true) {
video >> frame;
if(frame.empty()) break;
frame.copyTo(image);
// 转换到 HSV 空间
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 当有对象时进行处理
if(trackObject) {
// 仅处理 H: 0~180, S: 30~256, V: 10~256,过滤其他部分并将剩余部分复制到 mask
cv::inRange(hsv, cv::Scalar(0, 30, 10), cv::Scalar(180, 256, 10), mask);
// 从 hsv 中分离出 h 通道
int ch[] = {0, 0};
hue.create(hsv.size(), hsv.depth());
cv::mixChannels(&hsv, 1, &hue, 1, ch, 1);
// 如果跟踪对象的属性尚未计算,则进行属性提取
if( trackObject < 0 ) {
// 设置 h 通道和 mask ROI
cv::Mat roi(hue, selection), maskroi(mask, selection);
// 计算 ROI 直方图
calcHist(&roi, 1, 0, maskroi, hist, 1, &hsize, &phranges);
// 直方图归一化
normalize(hist, hist, 0, 255, CV_MINMAX);
// 设置跟踪窗口
trackWindow = selection;
// 标记跟踪对象的属性已计算
trackObject = 1;
}
// 反向投影直方图
calcBackProject(&hue, 1, 0, hist, backproj, &phranges);
// 获取共同区域
backproj &= mask;
// 调用 Camshift 算法
cv::RotatedRect trackBox = CamShift(backproj, trackWindow, cv::TermCriteria( CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1 ));
// 如果区域太小无法绘制,则进行处理
if( trackWindow.area() <= 1 ) {
int cols = backproj.cols, rows = backproj.rows, r = (MIN(cols, rows) + 5)/6;
trackWindow = cv::Rect(trackWindow.x - r, trackWindow.y - r,
trackWindow.x + r, trackWindow.y + r) & cv::Rect(0, 0, cols, rows);
}
// 绘制跟踪区域
ellipse( image, trackBox, cv::Scalar(0,0,255), 3, CV_AA );
}
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi);
}
imshow("CamShift at LabEx", image);
int key = cv::waitKey(1000/15.0);
if(key == 27) break;
}
cv::destroyAllWindows();
video.release();
return 0;
}
以下展示了我们在本项目中编写的所有代码:
#include <opencv2/opencv.hpp>
bool selectObject = false; // 用于判断是否选择了对象
int trackObject = 0; // 1 表示有跟踪对象,0 表示没有对象,-1 表示尚未计算 Camshift 属性
cv::Rect selection; // 保存鼠标选择的区域
cv::Mat image; // 缓存视频中的图像
// OpenCV 的鼠标回调函数:
// void onMouse(int event, int x, int y, int flag, void *param)
// 第四个参数 `flag` 表示附加状态,
// param 表示用户参数,我们不需要它们,因此未命名。
void onMouse( int event, int x, int y, int, void* ) {
static cv::Point origin;
if(selectObject) {
// 确定选择的高度、宽度以及左上角位置
selection.x = MIN(x, origin.x);
selection.y = MIN(y, origin.y);
selection.width = std::abs(x - origin.x);
selection.height = std::abs(y - origin.y);
// & 被 cv::Rect 重载
// 它表示两个区域的交集,
// 这里的主要目的是处理选择区域之外的部分
selection &= cv::Rect(0, 0, image.cols, image.rows);
}
switch(event) {
// 处理左键按下事件
case CV_EVENT_LBUTTONDOWN:
origin = cv::Point(x, y);
selection = cv::Rect(x, y, 0, 0);
selectObject = true;
break;
// 处理左键释放事件
case CV_EVENT_LBUTTONUP:
selectObject = false;
if( selection.width > 0 && selection.height > 0 )
trackObject = -1; // 跟踪对象尚未计算 Camshift 属性
break;
}
}
int main( int argc, const char** argv ) {
cv::VideoCapture video("video.ogv");
cv::namedWindow("CamShift at LabEx");
cv::setMouseCallback("CamShift at LabEx", onMouse, NULL);
cv::Mat frame, hsv, hue, mask, hist, backproj;
cv::Rect trackWindow; // 跟踪窗口
int hsize = 16; // 用于直方图
float hranges[] = {0,180}; // 用于直方图
const float* phranges = hranges; // 用于直方图
while(true) {
video >> frame;
if(frame.empty()) break;
frame.copyTo(image);
// 转换到 HSV 空间
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// 当有对象时进行处理
if(trackObject) {
// 仅处理 H: 0~180, S: 30~256, V: 10~256,过滤其他部分并将剩余部分复制到 mask
cv::inRange(hsv, cv::Scalar(0, 30, 10), cv::Scalar(180, 256, 256), mask);
// 从 hsv 中分离出 h 通道
int ch[] = {0, 0};
hue.create(hsv.size(), hsv.depth());
cv::mixChannels(&hsv, 1, &hue, 1, ch, 1);
// 如果跟踪对象的属性尚未计算,则进行属性提取
if( trackObject < 0 ) {
// 设置 h 通道和 mask ROI
cv::Mat roi(hue, selection), maskroi(mask, selection);
// 计算 ROI 直方图
calcHist(&roi, 1, 0, maskroi, hist, 1, &hsize, &phranges);
// 直方图归一化
normalize(hist, hist, 0, 255, CV_MINMAX);
// 设置跟踪窗口
trackWindow = selection;
// 标记跟踪对象的属性已计算
trackObject = 1;
}
// 反向投影直方图
calcBackProject(&hue, 1, 0, hist, backproj, &phranges);
// 获取共同区域
backproj &= mask;
// 调用 Camshift 算法
cv::RotatedRect trackBox = CamShift(backproj, trackWindow, cv::TermCriteria( CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1 ));
// 如果区域太小无法绘制,则进行处理
if( trackWindow.area() <= 1 ) {
int cols = backproj.cols, rows = backproj.rows, r = (MIN(cols, rows) + 5)/6;
trackWindow = cv::Rect(trackWindow.x - r, trackWindow.y - r,
trackWindow.x + r, trackWindow.y + r) & cv::Rect(0, 0, cols, rows);
}
// 绘制跟踪区域
ellipse( image, trackBox, cv::Scalar(0,0,255), 3, CV_AA );
}
if( selectObject && selection.width > 0 && selection.height > 0 ) {
cv::Mat roi(image, selection);
bitwise_not(roi, roi);
}
imshow("CamShift at LabEx", image);
int key = cv::waitKey(1000/15.0);
if(key == 27) break;
}
cv::destroyAllWindows();
video.release();
return 0;
}
让我们重新编译 main.cpp
:
g++ main.cpp $(pkg-config opencv --libs --cflags opencv) -o main
并运行它:
./main
现在,我们可以在程序中选择对象,跟踪功能已经启动:
在上图中,我们选择了木星,跟踪窗口是一个红色椭圆。