3. 运动检测基础:帧差法、背景减除与光流法

运动检测,说白了就是让摄像头“看见”画面里什么东西在动。我做了这么多年嵌入式视觉,发现很多新手一上来就追着深度学习跑,结果在资源受限的板子上跑不动。其实,经典方法在大多数场景下又快又稳。

这一章,我们聊三种最实用的运动检测手段:帧差法、背景减除法(MOG2/KNN)和光流法(Lucas-Kanade)。它们各有脾气,选对了,你的对焦追踪系统就成功了一半。

运动检测三大方法对比 帧差法 相邻帧像素相减 计算简单,速度快 对光照敏感 易出现“空洞” 背景减除法 MOG2 / KNN 自适应背景建模 抗干扰能力强 内存占用稍高 光流法 Lucas-Kanade 像素级运动矢量 可追踪运动方向 计算量大 选择建议:快速原型用帧差法,稳定场景用背景减除,需要运动方向用光流法

3.1 帧差法:最简单的运动检测

帧差法的思路特别直白:把当前帧和上一帧对应像素相减。如果差值大于某个阈值,就认为这个像素“动”了。

公式长这样:

D(x,y) = |I_t(x,y) - I_{t-1}(x,y)|
Motion(x,y) = 1 if D(x,y) > T else 0

其中 T 是阈值。我习惯设 25-50,具体看你的场景噪声水平。

核心流程:

  1. 读取当前帧和上一帧
  2. 转灰度图(减少计算量)
  3. 两帧相减取值
  4. 阈值二值化
  5. 形态学操作去噪(腐蚀+膨胀)

来个 OpenCV 实现:

import cv2
import numpy as np

cap = cv2.VideoCapture(0)
ret, prev_frame = cap.read()
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)

while True:
    ret, frame = cap.read()
    if not ret:
        break
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    
    # 帧差
    diff = cv2.absdiff(prev_gray, gray)
    _, thresh = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY)
    
    # 去噪
    kernel = np.ones((5,5), np.uint8)
    clean = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
    
    cv2.imshow('Motion', clean)
    prev_gray = gray.copy()
    
    if cv2.waitKey(30) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

我曾经踩过的坑:帧差法对光照变化极其敏感。有一次在室外做测试,云飘过来遮住太阳,整个画面都变成了“运动区域”。后来我加了光照补偿,才稳住。

帧差法的优缺点很明显:

优点缺点
计算量极小,适合嵌入式只能检测运动,不能提取完整物体
实现简单,5行代码搞定对光照变化敏感
对场景变化响应快运动物体内部容易出现空洞

3.2 背景减除法:MOG2 与 KNN

帧差法有个硬伤:它只能检测“变化”,但不知道什么是“背景”。背景减除法就是来解决这个问题的——它先建立一个背景模型,然后用当前帧减去背景,剩下的就是前景(运动物体)。

OpenCV 提供了两种主流实现:MOG2 和 KNN。我个人更常用 MOG2,因为它能自动适应光照变化。

MOG2(混合高斯模型)

MOG2 对每个像素用多个高斯分布建模。背景用几个高斯分布表示,前景用另外的分布表示。算法会自动更新这些分布。

import cv2

cap = cv2.VideoCapture(0)
# 创建MOG2背景减除器
bg_subtractor = cv2.createBackgroundSubtractorMOG2(
    history=500,      # 历史帧数
    varThreshold=16,  # 方差阈值
    detectShadows=True  # 检测阴影
)

while True:
    ret, frame = cap.read()
    if not ret:
        break
    
    # 应用背景减除
    fg_mask = bg_subtractor.apply(frame)
    
    # 去噪
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3))
    fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
    
    cv2.imshow('MOG2 Foreground', fg_mask)
    
    if cv2.waitKey(30) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

参数调优建议:

  • history:值越大,背景更新越慢。固定场景用 500,动态场景用 200。
  • varThreshold:值越小,对运动越敏感,但噪声也越多。我一般从 16 开始调。
  • detectShadows:建议开启,否则影子会被误判为运动物体。

KNN(K近邻)

KNN 是另一种背景建模方法。它用最近邻分类的思路判断像素属于背景还是前景。相比 MOG2,KNN 在纹理丰富的场景中表现更好。

# 创建KNN背景减除器
bg_subtractor = cv2.createBackgroundSubtractorKNN(
    history=500,
    dist2Threshold=400.0,  # 距离平方阈值
    detectShadows=True
)

# 使用方式与MOG2完全一致
fg_mask = bg_subtractor.apply(frame)
对比项MOG2KNN
建模方式混合高斯分布K近邻分类
计算速度较快稍慢
阴影检测支持支持
适用场景光照渐变场景纹理复杂场景
内存占用中等较高

注意:背景减除法在摄像头抖动时效果很差。我曾经在一个移动机器人上测试,车一颠簸,整个画面都变成了前景。后来加了帧间配准才解决。

3.3 光流法初步:Lucas-Kanade

前面两种方法只能告诉你“哪里在动”,但光流法能告诉你“往哪个方向动”。这对追踪对焦目标来说,太关键了。

Lucas-Kanade 光流法基于三个假设:

  1. 亮度恒定:同一个点的亮度在相邻帧之间不变
  2. 小运动:运动幅度不能太大
  3. 空间一致:相邻像素的运动方向相似

嗯,这里要注意。第三个假设意味着 LK 光流适合跟踪角点或纹理丰富的区域,不适合纯色区域。

import cv2
import numpy as np

cap = cv2.VideoCapture(0)
# Shi-Tomasi角点检测参数
feature_params = dict(maxCorners=100, qualityLevel=0.3, minDistance=7)
# LK光流参数
lk_params = dict(winSize=(15,15), maxLevel=2,
                 criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))

ret, old_frame = cap.read()
old_gray = cv2.cvtColor(old_frame, cv2.COLOR_BGR2GRAY)
p0 = cv2.goodFeaturesToTrack(old_gray, mask=None, **feature_params)

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)
    
    if p1 is not None:
        good_new = p1[st==1]
        good_old = p0[st==1]
        
        for new, old in zip(good_new, good_old):
            a, b = new.ravel()
            c, d = old.ravel()
            cv2.line(frame, (int(a), int(b)), (int(c), int(d)), (0,255,0), 2)
            cv2.circle(frame, (int(a), int(b)), 3, (0,0,255), -1)
    
    cv2.imshow('LK Optical Flow', frame)
    old_gray = frame_gray.copy()
    p0 = good_new.reshape(-1,1,2)
    
    if cv2.waitKey(30) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

LK光流的关键参数:

  • winSize:搜索窗口大小。大窗口适合大运动,但计算慢。我常用 15x15。
  • maxLevel:金字塔层数。层数越多,能处理更大的运动。2-3 层足够。
  • criteria:迭代终止条件。一般设 10 次迭代或 0.03 像素精度。

你想想看,如果我们要追踪一个快速移动的球,帧差法只能知道球在动,背景减除法能提取出球的轮廓,但光流法能告诉你球往哪个方向飞、速度大概多少。这对对焦系统来说,就是提前预判的依据。

我的经验:在实际项目中,我通常把三种方法组合使用。先用帧差法做快速预检,确认有运动后再用背景减除法提取精确轮廓,最后用光流法跟踪特征点。这样既保证了实时性,又兼顾了精度。