一、光流估计介绍
1、简介
光流估计是指通过计算相邻帧之间的像素位移,来估计图像中物体的运动信息。
2、原理
假设相邻帧之间的像素亮度保持不变,并根据此假设计算每个像素的位移向量。
3、算法
基于局部方法: 基于局部方法的光流估计算法通常基于图像区域内的像素点之间的亮度变化来计算位移向量。这些方法通常依赖于一些边缘、角点等特征点的检测和匹配。
**基于全局方法:**基于全局方法的光流估计算法则通过优化能量函数来计算整个图像的位移向量。这些方法通常能够获得更准确的位移估计结果,但计算复杂度较高。
4、光流估计的前提
(1)亮度恒定:同一点随着时间的变化,其亮度不会发生改变。
(2)小运动:随着时间的变化不会引起位置的剧烈变化,只有小运动情况下才能用前后帧之间单位位置变化引起的灰度变化去近似灰度对位置的偏导数。
(3)空间一致:一个场景上邻近的点投影到图像上也是邻近点,且邻近点速度一致。因为光流法基本方程约束只有一个,而要求x,y方向的速度,有两个未知变量。所以需要连立n多个方程求解。
由这三个前提可以推出约束方程为

约束方程讲解:
一、光流估计的基本假设是:同一空间点在相邻帧间的亮度保持不变。
即:
含义:在时刻 t 位于 (x,y) 的像素点,在时刻 t+dt 移动到 (x+dx,y+dy),其灰度值不变。
二、一阶泰勒展开(线性近似)
对右侧
做一阶泰勒展开(忽略高阶无穷小):

将其代入亮度恒定等式,两边消去 I(x,y,t),得到:

三、引入光流速度变量
定义光流速度:

将 dx=u⋅dt、dy=v⋅dt 代入上式,两边同时除以 dt:

记:

得到光流约束方程(Optical Flow Constraint Equation):

写成矩阵形式:

但是约束方程存在一个问题:约束方程只有 1 个方程,但要求解 2 个未知数 u,v,无法唯一求解。因此需要引入额外约束(如 Lucas-Kanade 算法假设局部区域光流一致)才能求解。
那么Lucas-Kanade怎么解决这个问题?
Lucas-Kanade 算法加了一个关键约束:在一个小窗口内,所有像素的光流(u,v)都是相同的,即 假设一小块区域里所有点运动一致,用多个像素的约束凑成超定方程,通过最小二乘算出光流速度。
二、实战案例
在开始案例之前先介绍几个在案例中重要的函数
角点检测函数
python
p0 = cv2.goodFeaturesToTrack(
image, # 输入灰度图
maxCorners, # 设定最大的角点个数,是最有可能的角点数,如果这个参数不大于0,那么表示没有角点数的限制
qualityLevel, #图像角点的最小可接受参数,质量测量值乘以这个参数就是最小特征值,小于这个数的会被抛弃。
minDistance, # 角点之间最小的欧式距离,用于分散角点
mask=None, # 检测区域。如果图像不是空的,它指定检测角的区域。
)
返回
检测到的所有角点的坐标 (numpy 数组)
金字塔 Lucas-Kanade 光流算法
python
p1, st, err =cv2.calcOpticalFlowPyrLK(
prevImg, # 前一帧灰度图
nextImg, # 当前帧灰度图
prevPts, # 前一帧图像中特征点坐标
nextPts, # 当前帧图像中特征点坐标,(输出:新坐标,一般写None)
winSize, # 搜索窗口的大小
maxLevel, # 金字塔层数
criteria, # 迭代停止条件
)
返回:
p1:在当前帧中估计出的特征点坐标(形状和 prevPts 相同)
st: 一个与prevPts一样大小的状态向量,用于表示特征点是否被成功跟踪到。
err:一个prevPts样大小的误差向量,用于表示估计误差。
接下来实现案例
1、读取文件
python
import numpy as np
import cv2
# 打开视频文件
cap = cv2.VideoCapture(r'..\data\test.avi')
# 随机生成颜色,用于绘制轨迹
color = np.random.randint(0, 255, (100, 3))
# 读取视频的第一帧
ret, old_frame = cap.read()
# 将第一帧转换为灰度图像
old_gray = cv2.cvtColor(old_frame, cv2.COLOR_BGR2GRAY)
2、特征点检测
python
# 定义特征点检测参数
feature_params = dict(maxCorners=100, # 最大角点数量
qualityLevel=0.3, # 角点质量的阈值
minDistance=7) # 最小距离,用于分散角点
# 使用角点检测方法找到特征点
# goodFeaturesToTrack(image, maxCorners, qualityLevel, minDistance, corners=None, mask=None, blockSize=None, useHarrisDetector=None, k=None)
# image:输入单通道图像,用灰度图
# maxCorners: 设定最大的角点个数,是最有可能的角点数,如果这个参数不大于0,那么表示没有角点数的限制。
# qualityLevel: 图像角点的最小可接受参数,质量测量值乘以这个参数就是最小特征值,小于这个数的会被抛弃。
# minDistance: 角点之间最小的欧式距离。
# mask: 检测区域。如果图像不是空的,它指定检测角的区域。
# 返回所有角点坐标位置:corners
p0 = cv2.goodFeaturesToTrack(old_gray, mask=None, **feature_params) # **:关键字参数解包,用于将字典解包为关键字参数。
3、处理文件的每一帧,这里定义了绘制轨迹的掩膜及定义Lucas-Kanade光流参数
python
# 创建一个与当前帧大小相同的全零掩模,用于绘制轨迹
mask = np.zeros_like(old_frame)
# 定义Lucas-Kanade光流参数
lk_params = dict(winSize=(15, 15), # 窗口大小
maxLevel=2) # 金字塔层数
# 主循环,处理视频的每一帧
while True:
# 读取下一帧
ret, frame = cap.read()
# 检查是否成功读取到帧
if not ret:
break
# 将当前帧转换为灰度图像
frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 计算光流,获取新的特征点位置和状态
# calcOpticalFlowPyrLK(prevImg, nextImg, prevPts, nextPts, status=None, err=None, winSize=None, maxLevel=None,
# criteria=None, flags=None, minEigThreshold=None)
# prevImg: 前一帧图像
# nextImg: 当前帧图像
# prevPts: 前一帧图像中特征点坐标
# nextPts: 当前帧图像中特征点坐标,可以为None
# winSize: 搜索窗口的大小
# maxLevel: 金字塔层数
# criteria: 停止迭代的准则
# 返回值:
# nextPts: 在当前帧中估计出的特征点坐标
# status: 一个与prevPts一样大小的状态向量,用于表示特征点是否被成功跟踪到。
# err: 一个prevPts样大小的误差向量,用于表示估计误差
p1, st, err = cv2.calcOpticalFlowPyrLK(old_gray, frame_gray, p0, nextPts=None, **lk_params)
# 选择好的点(状态为1的点)
good_new = p1[st == 1]
good_old = p0[st == 1]
# 绘制轨迹
for i, (new, old) in enumerate(zip(good_new, good_old)):
a, b = new # 获取新点的坐标 或者[a, b] = new
c, d = old # 获取旧点的坐标
a, b, c, d = int(a), int(b), int(c), int(d) # 转换为整数
# 在掩模上绘制线段,连接新点和旧点
mask = cv2.line(mask, (a, b), (c, d), color[i].tolist(), thickness=2)
cv2.imshow('mask', mask)
# 将掩模添加到当前帧上,生成最终图像
img = cv2.add(frame, mask)
# 显示结果图像
cv2.imshow('frame', img)
# 等待150ms,检测是否按下了Esc键(键码为27)
k = cv2.waitKey(150)
if k == 27: # 按下Esc键,退出循环
break
# 更新旧灰度图和旧特征点
old_gray = frame_gray.copy()
p0 = good_new.reshape(-1, 1, 2) # 重新整理特征点为适合下次计算的形状 (38,2)-->(38,1,2)

4、释放资源
python
# 释放资源
cap.release()
cv2.destroyAllWindows()
完整代码
python
import numpy as np
import cv2
# 打开视频文件
cap = cv2.VideoCapture(r'..\data\test.avi')
# 随机生成颜色,用于绘制轨迹
color = np.random.randint(0, 255, (100, 3))
# 读取视频的第一帧
ret, old_frame = cap.read()
# 将第一帧转换为灰度图像
old_gray = cv2.cvtColor(old_frame, cv2.COLOR_BGR2GRAY)
# 定义特征点检测参数
feature_params = dict(maxCorners=100, # 最大角点数量
qualityLevel=0.3, # 角点质量的阈值
minDistance=7) # 最小距离,用于分散角点
# 使用角点检测方法找到特征点
# goodFeaturesToTrack(image, maxCorners, qualityLevel, minDistance, corners=None, mask=None, blockSize=None, useHarrisDetector=None, k=None)
# image:输入单通道图像,用灰度图
# maxCorners: 设定最大的角点个数,是最有可能的角点数,如果这个参数不大于0,那么表示没有角点数的限制。
# qualityLevel: 图像角点的最小可接受参数,质量测量值乘以这个参数就是最小特征值,小于这个数的会被抛弃。
# minDistance: 角点之间最小的欧式距离。
# mask: 检测区域。如果图像不是空的,它指定检测角的区域。
# 返回所有角点坐标位置:corners
p0 = cv2.goodFeaturesToTrack(old_gray, mask=None, **feature_params) # **:关键字参数解包,用于将字典解包为关键字参数。
# 创建一个与当前帧大小相同的全零掩模,用于绘制轨迹
mask = np.zeros_like(old_frame)
# 定义Lucas-Kanade光流参数
lk_params = dict(winSize=(15, 15), # 窗口大小
maxLevel=2) # 金字塔层数
# 主循环,处理视频的每一帧
while True:
# 读取下一帧
ret, frame = cap.read()
# 检查是否成功读取到帧
if not ret:
break
# 将当前帧转换为灰度图像
frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 计算光流,获取新的特征点位置和状态
# calcOpticalFlowPyrLK(prevImg, nextImg, prevPts, nextPts, status=None, err=None, winSize=None, maxLevel=None,
# criteria=None, flags=None, minEigThreshold=None)
# prevImg: 前一帧图像
# nextImg: 当前帧图像
# prevPts: 前一帧图像中特征点坐标
# nextPts: 当前帧图像中特征点坐标,可以为None
# winSize: 搜索窗口的大小
# maxLevel: 金字塔层数
# criteria: 停止迭代的准则
# 返回值:
# nextPts: 在当前帧中估计出的特征点坐标
# status: 一个与prevPts一样大小的状态向量,用于表示特征点是否被成功跟踪到。
# err: 一个prevPts样大小的误差向量,用于表示估计误差
p1, st, err = cv2.calcOpticalFlowPyrLK(old_gray, frame_gray, p0, nextPts=None, **lk_params)
# 选择好的点(状态为1的点)
good_new = p1[st == 1]
good_old = p0[st == 1]
# 绘制轨迹
for i, (new, old) in enumerate(zip(good_new, good_old)):
a, b = new # 获取新点的坐标 或者[a, b] = new
c, d = old # 获取旧点的坐标
a, b, c, d = int(a), int(b), int(c), int(d) # 转换为整数
# 在掩模上绘制线段,连接新点和旧点
mask = cv2.line(mask, (a, b), (c, d), color[i].tolist(), thickness=2)
cv2.imshow('mask', mask)
# 将掩模添加到当前帧上,生成最终图像
img = cv2.add(frame, mask)
# 显示结果图像
cv2.imshow('frame', img)
# 等待150ms,检测是否按下了Esc键(键码为27)
k = cv2.waitKey(150)
if k == 27: # 按下Esc键,退出循环
break
# 更新旧灰度图和旧特征点
old_gray = frame_gray.copy()
p0 = good_new.reshape(-1, 1, 2) # 重新整理特征点为适合下次计算的形状 (38,2)-->(38,1,2)
# 释放资源
cap.release()
cv2.destroyAllWindows()