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)才解决。

避坑指南: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()
小技巧:maxLevel参数控制金字塔层数。层数越多,能处理的大运动越大,但计算量也越大。我一般设2-3层。

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()
注意:Farneback稠密光流计算量很大。在1080p视频上,实时处理基本不可能。我一般先降采样到640x480,或者用GPU加速。

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}")
实战经验:我在做跌倒检测时,发现光流幅值均值在跌倒瞬间会有一个尖峰。但单纯用阈值判断容易误报(比如有人突然蹲下)。后来我结合了光流方向直方图,如果方向从「向下」突然变成「静止」,才判定为跌倒。准确率从70%提到了85%。

4.6 光流法的优缺点总结

优点 缺点
能捕捉精细运动信息 计算量大,难以实时
对相机运动有一定鲁棒性 对光照变化敏感
可提取丰富的运动特征 大运动场景容易失效
无需训练,直接计算 稠密光流内存占用高

好了,这一章就到这里。光流法虽然古老,但在很多场景下依然好用。下一章我们会聊聊如何用深度学习来做光流估计,效果更好,但代价也更大。到时候见!