3. 经典跟踪算法(一) Mean Shift
说到目标跟踪,很多新手一上来就扎进深度学习里。但我个人习惯,先带大家啃一啃经典算法。为什么?因为经典算法里藏着很多基础思想,你理解了它们,后面学复杂模型会轻松很多。
今天咱们聊的均值漂移(Mean Shift),就是一个非常经典的核密度估计跟踪方法。它不依赖训练数据,跑起来飞快,在早期视频监控里应用很广。我当年做第一个跟踪项目时,用的就是它。
核心思想一句话: 在每一帧里,不断把搜索窗口往像素密度最高的地方挪,直到收敛。
3.1 算法原理:说白了就是找密度中心
想象一下,你有一堆点(比如图像里的像素颜色值)。你想找到这些点最密集的区域。均值漂移的做法很简单:
- 先随便选一个点作为起点
- 计算这个点周围一定范围内所有点的平均位置
- 把起点挪到这个平均位置
- 重复第2、3步,直到位置不再变化
嗯,就是这么朴素。但为什么它能做目标跟踪?
因为我们可以把目标区域的颜色分布,建模成一个概率密度函数。跟踪时,就在新一帧里找这个密度最大的地方。说白了,就是找跟目标颜色最像的那块区域。
我画了一张流程图,帮你理清整个逻辑:
3.2 实现步骤:手把手拆解
下面我把每一步拆开讲。你跟着走一遍,就能自己写出来了。
3.2.1 目标建模
首先,我们要描述目标长什么样。最常用的方法是颜色直方图。比如在RGB空间,把每个通道分成16个区间,总共16×16×16=4096个bin。
我在项目中遇到过一个问题:直接用RGB直方图,光照一变就崩。后来我改用HSV空间的H通道,效果好了很多。你想想看,H通道对光照变化不敏感,做跟踪更稳。
# 伪代码:建立目标直方图
def build_histogram(image, region):
# region = [x, y, width, height]
roi = image[region[1]:region[1]+region[3],
region[0]:region[0]+region[2]]
# 转为HSV
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
# 计算H通道直方图,16个bin
hist = cv2.calcHist([hsv], [0], None, [16], [0, 180])
# 归一化
hist = cv2.normalize(hist, hist).flatten()
return hist
3.2.2 反向投影
有了目标直方图,怎么在新一帧里找它?答案是反向投影。
说白了,就是把当前帧的每个像素,用目标直方图查一下它属于目标的概率。概率高的地方,就是目标可能出现的位置。
# 伪代码:计算反向投影
def back_projection(frame, target_hist):
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
# 反向投影
prob = cv2.calcBackProject([hsv], [0], target_hist, [0, 180], 1)
# 可以用高斯模糊平滑一下
prob = cv2.GaussianBlur(prob, (5, 5), 0)
return prob
3.2.3 均值漂移迭代
这是核心步骤。在反向投影得到的概率图上,我们不断计算窗口内的质心,然后移动窗口。
质心的计算公式其实很简单:
# 伪代码:计算窗口质心
def compute_centroid(prob_map, window):
x, y, w, h = window
roi = prob_map[y:y+h, x:x+w]
# 计算零阶矩
M00 = roi.sum()
if M00 == 0:
return x, y # 没找到,保持不动
# 计算一阶矩
M10 = (roi * np.arange(w)).sum()
M01 = (roi * np.arange(h).reshape(-1, 1)).sum()
# 质心坐标
cx = int(M10 / M00) + x
cy = int(M01 / M00) + y
return cx, cy
然后就是循环迭代:
# 伪代码:均值漂移迭代
def mean_shift(frame, window, target_hist, max_iter=10):
prob = back_projection(frame, target_hist)
for i in range(max_iter):
cx, cy = compute_centroid(prob, window)
# 计算偏移量
dx = cx - (window[0] + window[2]//2)
dy = cy - (window[1] + window[3]//2)
# 移动窗口
window[0] += dx
window[1] += dy
# 如果偏移很小,认为收敛
if abs(dx) < 1 and abs(dy) < 1:
break
return window
我的小技巧: 迭代次数不用设太大,5-10次就够了。我曾经设了50次,结果每帧处理时间翻了好几倍,效果却没提升。另外,窗口大小最好保持跟初始目标一致,别让它自己缩放。
3.3 优缺点分析:别盲目用,先看场景
任何算法都有它的脾气。均值漂移也不例外。我把它掰开揉碎了讲给你听。
3.3.1 优点
| 优点 | 说明 |
|---|---|
| 计算速度快 | 没有复杂的矩阵运算,纯CPU就能跑实时。我曾在树莓派上跑过,30fps没问题。 |
| 实现简单 | 核心代码不到50行,调试起来很轻松。 |
| 无参数训练 | 不需要标注数据,不需要离线训练。拿到视频就能用。 |
| 对边缘遮挡有一定鲁棒性 | 只要目标大部分颜色还在,就能跟踪上。 |
3.3.2 缺点
| 缺点 | 说明 |
|---|---|
| 对颜色变化敏感 | 光照一变,或者目标颜色跟背景相近,立马跟丢。我踩过这个坑,后来加了直方图更新才缓解。 |
| 无法处理尺度变化 | 目标靠近镜头变大,或者远离变小,窗口大小不变,会框不准。 |
| 容易陷入局部最优 | 如果背景里有跟目标颜色很像的区域,算法可能跑偏。 |
| 没有记忆能力 | 目标完全被遮挡后,再出现就找不回来了。 |
避坑指南: 我曾经在一个项目里,用均值漂移跟踪行人。结果行人穿了件跟墙壁颜色一样的衣服,算法直接跟丢了。后来我改用颜色+纹理特征,才稳住。所以,如果你的场景里目标颜色跟背景容易混淆,慎用纯颜色直方图。
3.4 适用场景总结
说了这么多,到底什么时候用均值漂移?我总结了几条:
- 适合: 目标颜色鲜明、跟背景差异大、运动速度不快、没有严重遮挡的场景。比如跟踪一个红色球、跟踪人脸(肤色比较稳定)。
- 不适合: 目标快速运动、光照剧烈变化、目标频繁被遮挡、需要精确边界框的场景。
嗯,均值漂移就讲到这里。它虽然简单,但思想很经典。后面学CamShift、粒子滤波时,你会发现很多地方都有它的影子。打好这个基础,后面会轻松很多。