4. Lucas-Kanade光流法:局部光流估计的假设与数学推导
好,咱们进入光流法的核心章节。
前面我们聊了光流的基本概念,说白了就是像素点在图像序列中的运动轨迹。但怎么算?最经典的方法之一,就是Lucas-Kanade光流法,简称LK法。我个人习惯叫它“小窗口里的运动估计”,因为它的核心思想就藏在一个局部窗口里。
4.1 LK法的三大假设
任何模型都有前提假设。LK法也不例外。我刚开始学的时候,总觉得这些假设是理论家们拍脑袋想出来的。直到我在项目中踩了坑,才明白——假设不成立,结果就是灾难。
LK法基于三个基本假设:
- 亮度恒定假设:同一个像素点,在连续两帧中,它的亮度值不变。
- 小运动假设:运动幅度很小,像素位移不超过几个像素。
- 空间一致性假设:邻域内的像素点,具有相同的运动矢量。
为什么需要这三个假设?
亮度恒定让我们能建立方程;小运动让我们能用泰勒展开近似;空间一致性让我们有足够多的方程来求解两个未知数(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法的核心内容就这些。下一章我们会聊光流法的进阶——稠密光流与深度学习方法的对比。到时候你会发现,经典方法虽然简单,但理解透彻了,对后续学习大有裨益。