0. 前言

我们已经知道可以通过一些最独特的点来分析图像,对于图像序列同样如此,其中一些特征点的运动可用于了解捕获场景的不同元素如何移动。在本节中,我们将学习如何在特征点逐帧移动时通过跟踪特征点来执行序列的时间分析。

1. 追踪视频中的特征点

(1) 要开始追踪运动过程,首先要做的是检测初始帧中的特征点。然后尝试在之后的帧中跟踪这些点。

(2) 由于我们正在处理一个视频序列,因此找到特征点的对象很可能已经移动(这种移动也可能是由于相机移动造成的)。因此,必须围绕一个点的先前位置进行搜索,以便在下一帧中找到它的新位置,可以通过 cv::calcOpticalFlowPyrLK 函数完成此过程。也就是说,输入两个连续帧和一个特征点向量,函数可以返回特征点在新图像中的位置。要跟踪完整序列中的点,需要逐帧重复此过程。需要注意的是,当在整个序列中追踪点时,不可避免地会失去对其中一些点的跟踪,因此跟踪的特征点的数量将逐渐减少。因此,通常需要持续检测新的特征点。

(3) 我们继续利用在视频序列处理一节中定义的视频处理框架,我们将定义一个实现 FrameProcessor 接口的类。此类的数据属性包括执行特征点检测及其跟踪所需的变量:

class FeatureTracker : public FrameProcessor {
    cv::Mat gray;                       // 当前灰度图像
    cv::Mat gray_prev;                  // 前一帧灰度图像
    std::vector<cv::Point2f> points[2]; // 追踪特征从索引 0->1
    std::vector<cv::Point2f> initial;   // 追踪点的初始位置 
    std::vector<cv::Point2f> features;  // 特征探测
    int max_count;                      // 要检测的最大特征数 
    double qlevel;                      // 特征检测的质量水平 
    double minDist;                     // 两个特征点之间的最小距离 
    std::vector<uchar> status;          // 跟踪特征的状态
    std::vector<float> err;             // 追踪误差
    public:
        FeatureTracker() : max_count(500), qlevel(0.01), minDist(10.) {}

(4) 接下来,定义序列的每一帧需要调用的处理方法:

  • 首先,定义检测特征点
  • 接下来,跟踪这些点
  • 放弃无法跟踪或不需要再追踪的点
  • 最后,当前帧及其点成为下一次迭代时的上一帧及其点
        // 处理方法
        void process(cv:: Mat &frame, cv:: Mat &output) {
            // 转换为灰度图像
            cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
            frame.copyTo(output);
            // 1. 如果需要添加新特征
            if (addNewPoints()) {
                // 检测特征点
                detectFeaturePoints();
                // 将检测到的特征添加到当前追踪的特征
                points[0].insert(points[0].end(), features.begin(), features.end());
                initial.insert(initial.end(), features.begin(), features.end());
            }
            // 对于第一张图像
            if (gray_prev.empty()) gray.copyTo(gray_prev);
            // 追踪特征
            cv::calcOpticalFlowPyrLK(gray_prev, gray,   // 两张连续图像帧
                        points[0],                      // 第一帧输入点位置
                        points[1],                      // 第二帧输出点位置
                        status,                         // 追踪状态
                        err);                           // 追踪误差
            // 在跟踪的点上循环以丢弃无关对象 
            int k = 0;
            for (int i=0; i<points[1].size(); i++) {
                // 判断是否接受当前点
                if (acceptTrackedPoint(i)) {
                    initial[k] = initial[i];
                    points[1][k++] = points[1][i];
                }
            }
            // 消除丢弃掉的点
            points[1].resize(k);
            initial.resize(k);
            // 处理接受的点
            handleTrackedPoints(frame, output);
            // 将当前点和图像变为下一次处理时的前一帧点和图像
            std::swap(points[1], points[0]);
            cv::swap(gray_prev, gray);
        }

(5) 该算法使用了四种实用方法,我们可以改进这些方法中以便为跟踪器定义新的行为。第一种方法用于检测特征点:

        // 特征点检测
        void detectFeaturePoints() {
            // 检测特征
            cv::goodFeaturesToTrack(gray,   // 输入图像
                    features,               // 输出检测特征
                    max_count,              // 特征的最大数量
                    qlevel,                 // 质量级别
                    minDist);               // 两个特征间的最小距离
        }

(6) 第二种方法用于确定是否应该检测新的特征点:

        // 判断新点是否应该被添加
        bool addNewPoints() {
            return points[0].size() <= 10;
        }

(7) 第三种方法基于应用程序定义的标准移除一些跟踪点。我们移除不移动的点(除了那些无法被 cv::calcOpticalFlowPyrLK 函数跟踪的点),不移动的点被认为属于背景场景,因此对此不感兴趣:

        // 确定应接受哪个追踪点
        bool acceptTrackedPoint(int i) {
            return status[i] &&  // 如果无法追踪点 i,则该点状态为 false
                    (abs(points[0][i].x-points[1][i].x)+(abs(points[0][i].y-points[1][i].y))>2); // 如果点已经移动
        }

(8) 第四种方法通过用一条线将所有跟踪点连接到它们在当前帧上的初始位置(即它们第一次被检测到的位置),绘制所有跟踪点处理跟踪的特征点:

        // 处理当前追踪点
        void handleTrackedPoints(cv::Mat &frame, cv::Mat &output) {
            for (int i=0; i<points[i].size(); i++) {
                cv::line(output, initial[i], points[1][i], cv::Scalar(255, 255, 255));
                cv::circle(output, points[1][i], 3, cv::Scalar(255, 255, 255), -1);
            }
        }

(9) 编写 main 函数追踪视频序列中的特征点:

int main() {
    // 创建视频处理器实例
    VideoProcessor processor;
    // 创建特征追踪实例
    FeatureTracker tracker;
    // 打开视频文件
    processor.setInput("r3.mp4");
    processor.setOutput("test000.mp4");
    // 设置帧处理器
    processor.setFrameProcessor(&tracker);
    processor.displayOutput("Tracked Features");
    processor.setDelay(1000./processor.getFrameRate());
    processor.stopAtFrameNo(90);
    // 开始处理
    processor.run();
    cv::waitKey();
}

以上程序可以展示移动追踪特征随时间的演变。在示例视频中,假设相机是固定的:

OpenCV实战(27)——追踪视频中的特征点-LMLPHP
经历一段时间后,我们可以得到以下帧结果:

OpenCV实战(27)——追踪视频中的特征点-LMLPHP

2. 特征点追踪原理

为了逐帧跟踪特征点,我们必须在后续帧中定位特征点的新位置。如果我们假设特征点的强度从一帧到下一帧都没有变化,那么寻找 ( u , v ) (u,v) (u,v) 的位移如下:
I t ( x , y ) = I t + 1 ( x + u , y + v ) I_t(x,y)=I_{t+1}(x+u,y+v) It(x,y)=It+1(x+u,y+v)
其中, I t I_t It I t + 1 I_{t+1} It+1 分别是当前帧和下一帧,适用于在两个相应时刻拍摄的图像中的较小位移。我们可以使用泰勒展开基于图像导数的方程来近似以上方程:
I t + 1 ( x + u , y + v ) ≈ I t ( x , y ) + ∂ I ∂ x u + ∂ I ∂ y v + ∂ I ∂ t I_{t+1}(x+u,y+v)\approx I_t(x,y)+\frac {\partial I}{\partial x}u+\frac {\partial I}{\partial y}v+\frac {\partial I}{\partial t} It+1(x+u,y+v)It(x,y)+xIu+yIv+tI
假设不考虑两个强度项的恒定强度假设,根据方程右侧的等式,可以得到以下结果:
∂ I ∂ x u + ∂ I ∂ y v = − ∂ I ∂ t \frac {\partial I}{\partial x}u+\frac {\partial I}{\partial y}v=-\frac {\partial I}{\partial t} xIu+yIv=tI
该约束是基本的光流 (optical flow) 约束方程,也称为亮度恒定方程 (brightness constancy equation)。
Lukas-Kanade 特征追踪算法利用了这种约束。除了使用这个约束,Lukas-Kanade 算法还假设特征点邻域内所有点的位移是相同的。因此,我们可以使用唯一的 ( u , v ) (u, v) (u,v) 未知位移对所有这些点施加光流约束。由此可以得到比未知数(两个)更多的方程,因此,我们可以求解该方程组。在实践中,可以通过迭代解决以上方程,OpenCV 还提供了以不同分辨率执行此估计的可能性,以便使搜索更有效并更能容忍更大的位移。默认情况下,图像级别数为 3,窗口大小为 15。当然,这些参数是可以改变的。我们还可以指定终止条件,该条件定义停止迭代搜索的条件。cv::calcOpticalFlowPyrLK 的第六个参数包含可用于评估追踪质量的残差均方误差。第五个参数包含二进制标志,用于指示跟踪的相应点是否是成功的。
以上描述给出了 Lukas-Kanade 追踪器背后的基本原理,实现中包含了其他优化和改进,使算法在计算大量特征点的位移时更有效。

3. 完整代码

头文件 (videoprocessor.h) 完整代码参考视频序列处理一节,头文件 (featuretracker.h) 完整代码如下所示:

#if !defined FTRACKER
#define FTRACKER

#include <string>
#include <vector>
#include <opencv2/core.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/features2d.hpp>
#include <opencv2/video/tracking.hpp>

#include "videoprocessor.h"

class FeatureTracker : public FrameProcessor {
    cv::Mat gray;                       // 当前灰度图像
    cv::Mat gray_prev;                  // 前一帧灰度图像
    std::vector<cv::Point2f> points[2]; // 追踪特征从索引 0->1
    std::vector<cv::Point2f> initial;   // 追踪点的初始位置 
    std::vector<cv::Point2f> features;  // 特征探测
    int max_count;                      // 要检测的最大特征数 
    double qlevel;                      // 特征检测的质量水平 
    double minDist;                     // 两个特征点之间的最小距离 
    std::vector<uchar> status;          // 跟踪特征的状态
    std::vector<float> err;             // 追踪误差
    public:
        FeatureTracker() : max_count(500), qlevel(0.01), minDist(10.) {}
        // 处理方法
        void process(cv:: Mat &frame, cv:: Mat &output) {
            // 转换为灰度图像
            cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
            frame.copyTo(output);
            // 1. 如果需要添加新特征
            if (addNewPoints()) {
                // 检测特征点
                detectFeaturePoints();
                // 将检测到的特征添加到当前追踪的特征
                points[0].insert(points[0].end(), features.begin(), features.end());
                initial.insert(initial.end(), features.begin(), features.end());
            }
            // 对于第一张图像
            if (gray_prev.empty()) gray.copyTo(gray_prev);
            // 追踪特征
            cv::calcOpticalFlowPyrLK(gray_prev, gray,   // 两张连续图像帧
                        points[0],                      // 第一帧输入点位置
                        points[1],                      // 第二帧输出点位置
                        status,                         // 追踪状态
                        err);                           // 追踪误差
            // 在跟踪的点上循环以丢弃无关对象 
            int k = 0;
            for (int i=0; i<points[1].size(); i++) {
                // 判断是否接受当前点
                if (acceptTrackedPoint(i)) {
                    initial[k] = initial[i];
                    points[1][k++] = points[1][i];
                }
            }
            // 消除丢弃掉的点
            points[1].resize(k);
            initial.resize(k);
            // 处理接受的点
            handleTrackedPoints(frame, output);
            // 将当前点和图像变为下一次处理时的前一帧点和图像
            std::swap(points[1], points[0]);
            cv::swap(gray_prev, gray);
        }
        // 特征点检测
        void detectFeaturePoints() {
            // 检测特征
            cv::goodFeaturesToTrack(gray,   // 输入图像
                    features,               // 输出检测特征
                    max_count,              // 特征的最大数量
                    qlevel,                 // 质量级别
                    minDist);               // 两个特征间的最小距离
        }
        // 判断新点是否应该被添加
        bool addNewPoints() {
            return points[0].size() <= 10;
        }
        // 确定应接受哪个追踪点
        bool acceptTrackedPoint(int i) {
            return status[i] &&  // 如果无法追踪点 i,则该点状态为 false
                    (abs(points[0][i].x-points[1][i].x)+(abs(points[0][i].y-points[1][i].y))>2); // 如果点已经移动
        }
        // 处理当前追踪点
        void handleTrackedPoints(cv::Mat &frame, cv::Mat &output) {
            for (int i=0; i<points[i].size(); i++) {
                cv::line(output, initial[i], points[1][i], cv::Scalar(255, 255, 255));
                cv::circle(output, points[1][i], 3, cv::Scalar(255, 255, 255), -1);
            }
        }
};

#endif

主函数文件 (tracker.cpp) 完整代码如下所示:

#include <iostream>
#include <opencv2/core.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/features2d.hpp>
#include <opencv2/video/tracking.hpp>

#include "featuretracker.h"

int main() {
    // 创建视频处理器实例
    VideoProcessor processor;
    // 创建特征追踪实例
    FeatureTracker tracker;
    // 打开视频文件
    processor.setInput("r3.mp4");
    processor.setOutput("test000.mp4");
    // 设置帧处理器
    processor.setFrameProcessor(&tracker);
    processor.displayOutput("Tracked Features");
    processor.setDelay(1000./processor.getFrameRate());
    processor.stopAtFrameNo(90);
    // 开始处理
    processor.run();
    cv::waitKey();
}

小结

视频特征点追踪,对于分析视频中不同重要元素的移动而言十分重要,在本节中,我们学习了如何在特征点逐帧移动时通过跟踪特征点来执行序列的时间分析。

系列链接

OpenCV实战(1)——OpenCV与图像处理基础
OpenCV实战(2)——OpenCV核心数据结构
OpenCV实战(3)——图像感兴趣区域
OpenCV实战(4)——像素操作
OpenCV实战(5)——图像运算详解
OpenCV实战(6)——OpenCV策略设计模式
OpenCV实战(7)——OpenCV色彩空间转换
OpenCV实战(8)——直方图详解
OpenCV实战(9)——基于反向投影直方图检测图像内容
OpenCV实战(10)——积分图像详解
OpenCV实战(11)——形态学变换详解
OpenCV实战(12)——图像滤波详解
OpenCV实战(13)——高通滤波器及其应用
OpenCV实战(14)——图像线条提取
OpenCV实战(15)——轮廓检测详解
OpenCV实战(16)——角点检测详解
OpenCV实战(17)——FAST特征点检测
OpenCV实战(18)——特征匹配
OpenCV实战(19)——特征描述符
OpenCV实战(20)——图像投影关系
OpenCV实战(21)——基于随机样本一致匹配图像
OpenCV实战(22)——单应性及其应用
OpenCV实战(23)——相机标定
OpenCV实战(24)——相机姿态估计
OpenCV实战(25)——3D场景重建
OpenCV实战(26)——视频序列处理

07-02 10:05