4. Lucas-Kanade光流法:局部光流估计的假设与数学推导

好,咱们进入光流法的核心章节。

前面我们聊了光流的基本概念,说白了就是像素点在图像序列中的运动轨迹。但怎么算?最经典的方法之一,就是Lucas-Kanade光流法,简称LK法。我个人习惯叫它“小窗口里的运动估计”,因为它的核心思想就藏在一个局部窗口里。

4.1 LK法的三大假设

任何模型都有前提假设。LK法也不例外。我刚开始学的时候,总觉得这些假设是理论家们拍脑袋想出来的。直到我在项目中踩了坑,才明白——假设不成立,结果就是灾难

LK法基于三个基本假设:

  1. 亮度恒定假设:同一个像素点,在连续两帧中,它的亮度值不变。
  2. 小运动假设:运动幅度很小,像素位移不超过几个像素。
  3. 空间一致性假设:邻域内的像素点,具有相同的运动矢量。

为什么需要这三个假设?

亮度恒定让我们能建立方程;小运动让我们能用泰勒展开近似;空间一致性让我们有足够多的方程来求解两个未知数(u, v)。缺一个,LK法就玩不转。

我曾经在一个夜间监控项目里,直接套用LK法,结果光流场乱成一锅粥。后来排查发现——亮度恒定假设被打破了。夜间场景下,车灯忽明忽暗,像素亮度剧烈变化,光流自然算不准。嗯,这里要注意:光照变化剧烈的场景,LK法需要预处理

4.2 数学推导:从方程到解

好,我们一步步来推导。别怕,其实不复杂。

首先,根据亮度恒定假设,我们有:

I(x, y, t) = I(x + dx, y + dy, t + dt)

左边是t时刻(x,y)处的亮度,右边是t+dt时刻(x+dx, y+dy)处的亮度。两者相等。

然后,对小运动做泰勒展开:

I(x+dx, y+dy, t+dt) ≈ I(x,y,t) + ∂I/∂x * dx + ∂I/∂y * dy + ∂I/∂t * dt

忽略高阶项。代入亮度恒定等式,两边消去I(x,y,t),得到:

∂I/∂x * dx + ∂I/∂y * dy + ∂I/∂t * dt = 0

两边除以dt,令 u = dx/dt, v = dy/dt,得到:

Ix * u + Iy * v + It = 0

这就是光流基本方程。其中Ix, Iy是图像梯度,It是时间梯度。

一个方程,两个未知数——这就是光流问题的病态性所在。你想想看,一个方程怎么可能解出u和v两个变量?

这时候,第三个假设——空间一致性——就派上用场了。

假设在一个m×m的局部窗口内(比如5×5,共25个像素),所有像素的运动矢量(u, v)相同。那么每个像素都能列出一个方程:

对于窗口内每个像素i:
Ix_i * u + Iy_i * v + It_i = 0

写成矩阵形式:

[Ix_1, Iy_1]   [u]   [ -It_1 ]
[Ix_2, Iy_2] * [v] = [ -It_2 ]
[...         ]        [ ...   ]
[Ix_n, Iy_n]         [ -It_n ]

记作:A * d = b

这是一个超定方程组(方程数多于未知数)。我们用最小二乘法求解:

d = (A^T * A)^{-1} * A^T * b

展开后:

[u] = [ ΣIx^2   ΣIxIy ]^{-1}   [ -ΣIxIt ]
[v]   [ ΣIxIy   ΣIy^2 ]       [ -ΣIyIt ]

其中Σ表示对窗口内所有像素求和。

我个人习惯:在计算前,先检查矩阵A^T*A的行列式。如果行列式太小(接近0),说明窗口内纹理不足,光流不可靠。这时候我会直接跳过这个点,或者用更大的窗口重试。

4.3 代码实现:从理论到实战

理论讲完了,咱们直接上代码。OpenCV里已经封装好了LK法,但为了让你理解内部机制,我写了一个简化版。

import cv2
import numpy as np

def lucas_kanade_single_point(img1, img2, point, window_size=5):
    """
    计算单个点的LK光流
    point: (x, y) 格式
    """
    # 计算梯度
    Ix = cv2.Sobel(img1, cv2.CV_64F, 1, 0, ksize=3)
    Iy = cv2.Sobel(img1, cv2.CV_64F, 0, 1, ksize=3)
    It = img2.astype(np.float64) - img1.astype(np.float64)
    
    x, y = int(point[0]), int(point[1])
    half_w = window_size // 2
    
    # 提取局部窗口
    x_start = max(0, x - half_w)
    x_end = min(img1.shape[1], x + half_w + 1)
    y_start = max(0, y - half_w)
    y_end = min(img1.shape[0], y + half_w + 1)
    
    # 构建A矩阵和b向量
    Ix_win = Ix[y_start:y_end, x_start:x_end].flatten()
    Iy_win = Iy[y_start:y_end, x_start:x_end].flatten()
    It_win = It[y_start:y_end, x_start:x_end].flatten()
    
    A = np.vstack([Ix_win, Iy_win]).T
    b = -It_win.reshape(-1, 1)
    
    # 最小二乘求解
    try:
        ATA = A.T @ A
        if np.linalg.det(ATA) < 1e-6:
            return None  # 不可靠点
        d = np.linalg.inv(ATA) @ A.T @ b
        return d.flatten()
    except:
        return None

# 使用OpenCV内置函数(推荐)
def lucas_kanade_opencv(img1, img2, points):
    """
    使用OpenCV的calcOpticalFlowPyrLK
    """
    # 参数设置
    lk_params = dict(
        winSize=(15, 15),
        maxLevel=2,
        criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03)
    )
    
    # 计算光流
    next_points, status, error = cv2.calcOpticalFlowPyrLK(
        img1, img2, points, None, **lk_params
    )
    
    # 筛选有效点
    good_new = next_points[status == 1]
    good_old = points[status == 1]
    
    return good_old, good_new

关键参数说明

参数作用我的建议
winSize局部窗口大小运动大就设大点,但别超过31×31
maxLevel金字塔层数处理大运动时设为2-3层
criteria迭代终止条件默认值通常够用

4.4 金字塔LK:解决大运动问题

还记得第二个假设——小运动吗?如果物体运动超过几个像素,泰勒展开的近似误差就会爆炸。

解决办法是图像金字塔。说白了,就是把图像一层层缩小。在顶层,大运动变成了小运动;然后逐层向下修正。

我曾经做一个无人机航拍的项目,地面车辆运动很快,直接用单层LK根本追不上。加上3层金字塔后,效果立竿见影。嗯,这里有个坑:金字塔层数不是越多越好。层数太多,顶层图像太小,纹理信息丢失,反而会误匹配。

# 手动实现金字塔LK的核心思路
def pyramid_lk(img1, img2, points, levels=3):
    """
    金字塔LK光流
    """
    # 构建高斯金字塔
    pyramid1 = [img1]
    pyramid2 = [img2]
    for _ in range(levels):
        pyramid1.append(cv2.pyrDown(pyramid1[-1]))
        pyramid2.append(cv2.pyrDown(pyramid2[-1]))
    
    # 从顶层开始计算
    for level in range(levels, -1, -1):
        # 缩放点坐标到当前层
        scale = 1.0 / (2 ** level)
        pts_level = points * scale
        
        # 在当前层计算光流
        _, new_pts = lucas_kanade_opencv(
            pyramid1[level], pyramid2[level], pts_level
        )
        
        # 更新点坐标
        points = new_pts * (2 ** level)
    
    return points

4.5 实战避坑指南

最后,分享几个我踩过的坑:

  • 纹理缺失区域:白墙、天空等均匀区域,A^T*A矩阵奇异,光流不可靠。我一般会加一个纹理置信度阈值。
  • 遮挡问题:物体移出视野或被遮挡,LK法会给出错误结果。可以用前后向一致性检查来过滤。
  • 光照突变:比如闪光灯、阴影变化。预处理时做直方图均衡化或归一化,能缓解不少。
  • 边缘效应:窗口靠近图像边界时,数据不足。我习惯在边界处缩小窗口或直接跳过。

我的调试小技巧:把光流矢量画成箭头图,叠加在原图上。一眼就能看出哪些区域的光流是合理的,哪些是噪声。如果箭头方向杂乱无章,先检查假设是否成立。

好了,LK法的核心内容就这些。下一章我们会聊光流法的进阶——稠密光流与深度学习方法的对比。到时候你会发现,经典方法虽然简单,但理解透彻了,对后续学习大有裨益。