这是opencv系列的最后一节,主要学习视频序列,上一节介绍了读取、处理和存储视频的工具,本文将介绍几种跟踪图像序列中运动物体的算法。可见运动或表观运动,是物体以不同的速度在不同的方向上移动,或者是因为相机在移动(或者两者都有)。在很多应用程序中,跟踪表观运动都是极其重要的。它可用来追踪运动中的物体,以测定它们的速度、判断它们的目的地。对于手持摄像机拍摄的视频,可以用这种方法消除抖动或减小抖动幅度,使视频更加平稳。运动估值还可用于视频编码,用以压缩视频,便于传输和存储。
目录
1. 跟踪视频中的特征点
被跟踪的运动可以是稀疏的(图像的少数位置上有运动,称为稀疏运动),也可以是稠密的(图像的每个像素都有运动,称为稠密运动)。在启动跟踪过程时,首先要在最初的帧中检测特征点,然后在下一帧中跟踪这些特征点,如果想找到特征点在下一帧的新位置,就必须在它原来位置的周围进行搜索。这个功能由函数cv::calcOpticalFlowPyrLK 实现。在函数中输入两个连续的帧和第一幅图像中特征点的向量,将返回新的特征点位置的向量。
要逐帧地跟踪特征点,就必须在后续帧中定位特征点的新位置。假设每个帧中特征点的强度
值是不变的,这个过程就是寻找如下的位移(u, v):
其中It 和It+1 分别是当前帧和下一个瞬间的帧。强度值不变的假设普遍适用于相邻图像上的
微小位移。我们可使用泰勒展开式得到近似方程式(包含图像导数):
根据第二个方程式,可以得到另一个方程式(根据强度值不变的假设,去掉了两个表示强度
值的项)
这就是基本的光流约束方程,也称作亮度恒定方程,Lukas-Kanade 特征跟踪算法使用了这个约束方程。除此之外,该算法还做了一个假设,即特征点邻域中所有点的位移量是相等的。因此,我们可以将光流约束应用到所有位移量为(u, v)的点(u 和v 还是未知的)。这样就得到了更多的方程式,数量超过未知数的个数(两个),因此可以在均方意义下解出这个方程组。
在实际应用中,我们采用迭代的方法来求解。为了使搜索更高效且适应更大的位移量,OpenCV 还提供了在不同分辨率下进行计算的方法:默认的图像等级数量为3,窗口大小为15,还可以设定一个终止条件,符合这个条件时就停止迭代搜索。
cv::calcOpticalFlowPyrLK 函数的第六个参数是剩余均方误差,用于评定跟踪的质量。第五个参数包含二值标志,表示是否成功跟踪了对应的点。
// 1. 特征点检测方法
void detectFeaturePoints() {
// 检测特征点
cv::goodFeaturesToTrack(gray, // 图像
features, // 输出检测到的特征点
max_count, // 特征点的最大数量
qlevel, // 质量等级
minDist); // 特征点之间的最小差距
}
// 2 法根据应用程序定义的条件剔除部分被跟踪的特征点。这里剔除静止的特征点(还有不能被cv::calcOpticalFlowPyrLK 函数跟踪的特征点)。我们假定静止的点属于背景部分,可以忽略:
// 判断需要保留的特征点
bool acceptTrackedPoint(int i) {
return status[i] &&
// 如果特征点已经移动
(abs(points[0][i].x-points[1][i].x)+
(abs(points[0][i].y-points[1][i].y))>2);
}
// 3 处理被跟踪的特征点,具体做法是在当前帧画直线,连接特征点和它们的初始位置(即第一次检测到它们的位置):
// 处理当前跟踪的特征点
void handleTrackedPoints(cv:: Mat &frame, cv:: Mat &output) {
// 遍历所有特征点
for (int i= 0; i < points[1].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);
}
}
2. 估算光流
通常关注视频序列中运动的部分,即场景中不同元素的三维运动在成像平面上的投影。三维运动向量的投影图被称作运动场。但是在只有一个相机传感器的情况下,是不可能直接测量三维运动的,我们只能观察到帧与帧之间运动的亮度模式,亮度模式上的表观运动被称作光流。通常认为运动场和光流是等同的,但其实不一定:典型的例子是观察均匀的物体;例如相机在白色的墙壁前移动时就不产生光流。
估算光流其实就是量化图像序列中亮度模式的表观运动。首先来看视频中某个时刻的一帧画
面。观察当前帧的某个像素(x, y),我们要知道它在下一帧会移动到哪个位置。也就是说,这个点
的坐标在随着时间变化(表示为(x(t), y(t))),而我们要估算出这个点的速度(dx/dt, dy/dt),对应的帧中获取这个点在t 时刻的亮度,表示为I(x(t), y(t),t),根据图像亮度恒定的假设:
这个约束条件可以用基于光流的拉普拉斯算子的公式表示:
现在要做的就是找到光流场,使亮度恒定公式的偏差和光流向量的拉普拉斯算子都达到最
小值,估算稠密光流的方法有很多,可以使用cv::Algorithm的子类cv::DualTVL1OpticalFlow。
所得结果是二维向量(cv::Point)组成的图像,每个二维向量表示一个像素在两个帧之
间的变化值。要展示结果,就必须显示这些向量。为此我们创建了一个函数,用来创建光流场的图像映射。为控制向量的可见性,需要使用两个参数:步长(间隔一定像素)和缩放因子,
// 1. 创建光流算法
cv::Ptr<cv::DualTVL1OpticalFlow> tvl1 = cv::createOptFlow_DualTVL1();
这个实例已经可以使用了,所以只需调用计算两个帧之间的光流场的方法即可:
cv::Mat oflow; // 二维光流向量的图像
// 计算frame1 和frame2 之间的光流
tvl1->calc(frame1, frame2, oflow);
// 2. 绘制光流向量图
void drawOpticalFlow(const cv::Mat& oflow, // 光流
cv::Mat& flowImage, // 绘制的图像
int stride, // 显示向量的步长
float scale, // 放大因子
const cv::Scalar& color) // 显示向量的颜色
{
// 必要时创建图像
if (flowImage.size() != oflow.size()) {
flowImage.create(oflow.size(), CV_8UC3);
flowImage = cv::Vec3i(255,255,255);
}
// 对所有向量,以stride 作为步长
for (int y = 0; y < oflow.rows; y += stride)
for (int x = 0; x < oflow.cols; x += stride) {
// 获取向量
cv::Point2f vector = oflow.at< cv::Point2f>(y, x);
// 画线条
cv::line(flowImage, cv::Point(x,y),
cv::Point(static_cast<int>(x + scale*vector.x + 0.5),
static_cast<int>(y + scale*vector.y + 0.5)),
color);
// 画顶端圆圈
cv::circle(flowImage,
cv::Point(static_cast<int>(x + scale*vector.x + 0.5),
static_cast<int>(y + scale*vector.y + 0.5)),
1, color, -1);
}
}
前面使用的方法被称作双DV L1 方法,由两部分组成。第一部分使用光滑约束,使光流梯度的绝对值(不是平方值)最小化;选用绝对值可以削弱平滑度带来的影响,尤其是对于不连续的区域,运动物体和背景部分的光流向量的差别很大。第二部分使用一阶泰勒近似,使亮度恒定约束公式线性化。
3. 跟踪视频中的物体
在很多应用程序中,更希望能够跟踪视频中一个特定的运动物体。为此要先标识出该物体,然后在很长的图像序列中对它进行跟踪。这是一个很有挑战性的课题,因为随着物体在场景中的运动,物体的图像会因视角和光照改变、非刚体运动、被遮挡等原因而不断变化。
import cv2 # type: ignore
import numpy as np
# Illustration of the Median Tracker principle
image1 = cv2.imread("E:/CODE/images/goose/goose130.bmp", 0)
image_show = cv2.imread("E:/CODE/images/goose/goose130.bmp")
#define a regular grid of points
grid = []
x,y,width,height = 290, 100, 65, 40
for i in range(10):
for j in range(10):
p = (x+i*width/10,y+j*height/10)
grid.append(p)
grid = np.array(grid, dtype=np.float32)
#track in next image
image2 = cv2.imread("E:/CODE/images/goose/goose131.bmp",0)
lk_params = []
lk_params = dict(winSize=(10, 10),
maxLevel=2,
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))
# ShiTomasi corner detection的参数
feature_params = dict(maxCorners=300,
qualityLevel=0.3,
minDistance=7,
blockSize=7)
p0 = cv2.goodFeaturesToTrack(image1, mask=None, **feature_params)
#grid 从22*2 维度调整为22*1*2
grid = grid.reshape(-1,1,2)
kp2, st, err =cv2.calcOpticalFlowPyrLK(image1, image2, grid, None, **lk_params)
#good_new = kp2[st == 1]
for i in grid:
cv2.circle(image_show, (int(i[0][0]),int(i[0][1])), 1, (255, 255, 255), 3)
for i in kp2:
cv2.circle(image_show, (int(i[0][0]),int(i[0][1])), 1, (255, 0, 255), 3)
cv2.imshow("Tracked points", image_show)
cv2.waitKey()
开始跟踪前,要先在一个帧中标识出物体,然后从这个位置开始跟踪。标识物体的方法就是指定一个包含该物体的矩形(YOLO),而跟踪模块的任务就是在后续的帧中重新识别出这个物体。OpenCV 中的物体跟踪框架类cv::Tracker 包含两个主方法,一个是init 方法,用于定义初始目标矩形;另一个是update 方法,输出新的帧中对应的矩形。中值流量跟踪算法的基础是特征点跟踪。它先在被跟踪物体上定义一个点阵。你也可以改为检测物体的兴趣点,例如采用第8 章介绍的FAST 算子检测兴趣点。但是使用预定位置的点有很多好处:它不需要计算兴趣点,因而节约了时间;它可以确保用于跟踪的点的数量足够多,还能确保这些点分布在整个物体上。默认情况下,中值流量法采用10×10 的点阵。