使用 OpenCV 实现视频目标跟踪

C++C++Beginner
立即练习

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

介绍

在本实验中,我们将使用 OpenCV 实现视频目标跟踪。在学习本项目之前,你必须完成「使用 C++ 构建太阳系」课程。

学习内容

  • C++ 基础
  • g++ 基础
  • 图像表示
  • OpenCV 应用
  • Meanshift 和 Camshift 算法

最终成果

本实验将实现一个能够跟踪太阳系中行星的程序。(在下图中,我们从黄色轨道中选择了木星,你可以看到被跟踪的目标已被红色椭圆标记):

图片描述

在编写此项目之前,你必须完成我们的「使用 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 色彩空间 中的图像。RGB 成为图像的三个 通道;除了 RGB 色彩空间之外,还有许多其他色彩空间,例如 HSVYCrCb 等。

正如像素是像素图的基本单位,图像是视频的基本单位。视频由一系列图像组成,我们将每一张图像称为一帧。我们通常所说的视频帧率意味着该视频每秒包含多少帧图像。例如,如果帧率为 25,则该视频每秒播放 25 帧。

如果 1 秒有 1000 毫秒,且帧率为 rate,则帧图像之间的时间间隔为 1000/rate

图像的颜色直方图

颜色直方图是一种用于描述图像的工具。它与普通直方图类似,不同之处在于颜色直方图需要从特定图像中计算得出。

如果一张图片处于 RGB 色彩空间 中,那么我们可以统计 R 通道 中每种颜色出现的次数。这样我们可以得到一个长度为 256 的数组(颜色概率查找表)。将所有值同时除以图像中的总像素数(宽度乘以高度),并将结果序列转换为直方图。结果就是 R 通道 的颜色直方图。类似地,你可以得到 G 通道B 通道 的直方图。

直方图的反向投影

已经证明,在 RGB 色彩空间中,直方图对光照变化非常敏感。为了减少这种变化对跟踪效果的影响,需要对直方图进行反向投影。这个过程分为三个步骤:

  1. 首先,我们将图像从 RGB 空间转换到 HSV 空间。
  2. 然后计算 H 通道的直方图。
  3. 将图像中每个像素的值替换为颜色概率查找表中对应的概率值,从而得到颜色概率分布图。

这个过程称为反向投影,颜色概率分布图是一张灰度图像。

OpenCV 基础

我们首先需要安装 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 算法

  • Meanshift
  • Camshift
  • 设置鼠标回调事件以选择跟踪目标
  • 从视频流中读取图像
  • 实现 Camshift

Meanshift

Meanshift 和 Camshift 算法是两种经典的目标跟踪算法。Camshift 基于 Meanshift。它们的数学解释较为复杂,但基本思想相对简单。因此,我们跳过这些数学细节,首先介绍 Meanshift 算法。

假设屏幕上有一组红点,蓝色圆圈(窗口)必须移动到点最密集的区域(或点数最多的位置):

图片描述

如上图所示,将蓝色圆圈标记为 C1,圆心为 C1_o。但这个圆圈的重心是 C1_r,标记为蓝色实心圆。

C1_oC1_r 不重叠时,重复将圆圈 C1 移动到圆圈 C1_r 的中心。最终它将停留在密度最高的圆圈 C2 上。

对于图像处理,我们通常使用图像的反向投影直方图。当跟踪目标移动时,显然这种移动过程可以通过反向投影直方图反映出来。因此,Meanshift 算法最终会将我们选择的窗口移动到移动目标的位置。(算法最终已被证明是收敛的。)

Camshift

通过前面的描述,我们看到 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 算法的基本思想后,我们可以分析出这段代码的实现主要分为以下几个步骤:

  1. 设置鼠标回调事件以选择跟踪目标。
  2. 从视频流中读取图像。
  3. 实现 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。

使用 OpenCV 实现 Camshift

计算跟踪目标的反向投影直方图需要使用 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

现在,我们可以在程序中选择对象,跟踪功能已经启动:

图片描述

在上图中,我们选择了木星,跟踪窗口是一个红色椭圆。

您可能感兴趣的其他 C++ 教程