4、光流法原理与实战:光流的概念(稀疏与稠密)、Lucas-Kanade算法、Farneback稠密光流、基于光流的运动特征提取
大家好,欢迎来到第四章。
前面几章我们聊了聊视频处理的基础,从帧差法到背景建模。说实话,那些方法在简单场景下够用,但一旦遇到相机抖动、复杂背景,效果就有点拉胯了。这时候,光流法就登场了。
光流,说白了就是「像素点的运动轨迹」。你想想看,视频里每一帧都在变化,这些变化本质上就是像素在移动。光流法就是帮我们算出这些像素「从哪里来,到哪里去」。
4.1 光流的基本假设
光流法不是凭空算的,它有两个核心假设:
- 亮度恒定:同一个点在相邻帧之间,亮度不变。比如你盯着一个白色小球,它在第1帧是白的,第2帧也应该是白的。
- 小运动:相邻帧之间的位移不能太大。说白了,就是物体不能「瞬移」。
这两个假设听着简单,但实际项目中经常被打破。我记得有一次做夜间监控,灯光闪烁导致亮度忽明忽暗,光流直接崩了。后来我加了一层直方图均衡化,才勉强稳住。
4.2 稀疏光流 vs 稠密光流
光流分两种:稀疏和稠密。区别很简单:
| 类型 | 计算对象 | 典型算法 | 速度 | 适用场景 |
|---|---|---|---|---|
| 稀疏光流 | 只跟踪少数特征点(如角点) | Lucas-Kanade | 快 | 目标跟踪、特征点匹配 |
| 稠密光流 | 计算每个像素的运动 | Farneback | 慢 | 运动分割、行为识别 |
我个人习惯是:如果只需要知道「有没有东西在动」,用稠密光流;如果要知道「具体哪个点在动」,用稀疏光流。
4.3 Lucas-Kanade 稀疏光流
Lucas-Kanade(LK)算法是稀疏光流的代表。它的思路很巧妙:
- 假设一个像素点周围的小窗口(比如3x3)内,所有像素的运动一致。
- 利用最小二乘法求解运动向量。
嗯,这里要注意:LK算法要求窗口内的运动是「一致的」。如果窗口跨越了运动边界(比如一半是车,一半是背景),结果就会出错。我曾经在车流监控中踩过这个坑,后来改用多尺度LK(金字塔LK)才解决。
下面是一个OpenCV实现LK光流的简单示例:
import cv2
import numpy as np
# 读取第一帧
cap = cv2.VideoCapture('video.mp4')
ret, old_frame = cap.read()
old_gray = cv2.cvtColor(old_frame, cv2.COLOR_BGR2GRAY)
# 检测角点作为特征点
p0 = cv2.goodFeaturesToTrack(old_gray, mask=None, maxCorners=100,
qualityLevel=0.3, minDistance=7)
# 光流参数
lk_params = dict(winSize=(15, 15), maxLevel=2,
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))
while True:
ret, frame = cap.read()
if not ret:
break
frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 计算光流
p1, st, err = cv2.calcOpticalFlowPyrLK(old_gray, frame_gray, p0, None, **lk_params)
# 选择有效点
good_new = p1[st == 1]
good_old = p0[st == 1]
# 绘制轨迹
for i, (new, old) in enumerate(zip(good_new, good_old)):
a, b = new.ravel()
c, d = old.ravel()
frame = cv2.line(frame, (a, b), (c, d), (0, 255, 0), 2)
frame = cv2.circle(frame, (a, b), 3, (0, 0, 255), -1)
old_gray = frame_gray.copy()
p0 = good_new.reshape(-1, 1, 2)
cv2.imshow('LK Optical Flow', frame)
if cv2.waitKey(30) & 0xFF == 27:
break
cap.release()
cv2.destroyAllWindows()
4.4 Farneback 稠密光流
稠密光流,顾名思义,就是给每个像素都算一个运动向量。OpenCV里最常用的是Farneback算法。
Farneback的思路和LK完全不同。它用多项式展开来近似每个像素邻域的亮度分布,然后通过比较相邻帧的多项式系数来估计运动。说白了,就是把图像看成连续函数,然后求导数。
代码实现也很直接:
import cv2
import numpy as np
cap = cv2.VideoCapture('video.mp4')
ret, frame1 = cap.read()
prvs = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
while True:
ret, frame2 = cap.read()
if not ret:
break
next = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
# 计算稠密光流
flow = cv2.calcOpticalFlowFarneback(prvs, next, None,
pyr_scale=0.5, levels=3, winsize=15,
iterations=3, poly_n=5, poly_sigma=1.2,
flags=0)
# 提取水平和垂直分量
hsv = np.zeros_like(frame1)
hsv[..., 1] = 255
mag, ang = cv2.cartToPolar(flow[..., 0], flow[..., 1])
hsv[..., 0] = ang * 180 / np.pi / 2
hsv[..., 2] = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX)
bgr = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
cv2.imshow('Farneback Optical Flow', bgr)
if cv2.waitKey(30) & 0xFF == 27:
break
prvs = next
cap.release()
cv2.destroyAllWindows()
4.5 基于光流的运动特征提取
光流算出来了,然后呢?在行为识别中,我们通常不直接用光流图,而是提取一些统计特征。常用的有:
- 光流幅值均值:反映整体运动强度。打架场景的幅值通常比走路高很多。
- 光流方向直方图:统计运动方向分布。比如跑步时方向比较集中,而徘徊时方向比较分散。
- 光流散度:反映运动是「扩散」还是「收缩」。比如一个人摔倒时,身体会突然扩散。
- 光流旋度:反映旋转运动。比如挥手、转身。
举个例子,提取光流幅值均值:
def extract_flow_magnitude(flow):
"""提取光流幅值均值"""
mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1])
return np.mean(mag)
# 在视频循环中调用
flow = cv2.calcOpticalFlowFarneback(prev_gray, gray, None, ...)
magnitude = extract_flow_magnitude(flow)
print(f"当前帧光流幅值均值: {magnitude:.2f}")
4.6 光流法的优缺点总结
| 优点 | 缺点 |
|---|---|
| 能捕捉精细运动信息 | 计算量大,难以实时 |
| 对相机运动有一定鲁棒性 | 对光照变化敏感 |
| 可提取丰富的运动特征 | 大运动场景容易失效 |
| 无需训练,直接计算 | 稠密光流内存占用高 |
好了,这一章就到这里。光流法虽然古老,但在很多场景下依然好用。下一章我们会聊聊如何用深度学习来做光流估计,效果更好,但代价也更大。到时候见!