3. 经典跟踪算法(一):均值漂移(Mean Shift)算法原理、实现步骤、优缺点分析

说到目标跟踪,很多新手一上来就扎进深度学习里。但我个人习惯,先带大家啃一啃经典算法。为什么?因为经典算法里藏着很多基础思想,你理解了它们,后面学复杂模型会轻松很多。

今天咱们聊的均值漂移(Mean Shift),就是一个非常经典的核密度估计跟踪方法。它不依赖训练数据,跑起来飞快,在早期视频监控里应用很广。我当年做第一个跟踪项目时,用的就是它。

核心思想一句话: 在每一帧里,不断把搜索窗口往像素密度最高的地方挪,直到收敛。

3.1 算法原理:说白了就是找密度中心

想象一下,你有一堆点(比如图像里的像素颜色值)。你想找到这些点最密集的区域。均值漂移的做法很简单:

  1. 先随便选一个点作为起点
  2. 计算这个点周围一定范围内所有点的平均位置
  3. 把起点挪到这个平均位置
  4. 重复第2、3步,直到位置不再变化

嗯,就是这么朴素。但为什么它能做目标跟踪?

因为我们可以把目标区域的颜色分布,建模成一个概率密度函数。跟踪时,就在新一帧里找这个密度最大的地方。说白了,就是找跟目标颜色最像的那块区域。

我画了一张流程图,帮你理清整个逻辑:

均值漂移跟踪算法核心流程 第1步:初始化 选定目标区域,建立颜色直方图 第2步:计算反向投影 将当前帧转为概率图 第3步:计算质心 在窗口内求加权平均 第4步:移动窗口 将窗口中心移到质心位置 是否收敛? 否 是 第5步:输出结果 得到目标新位置,进入下一帧 下一帧重复第2~5步

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、粒子滤波时,你会发现很多地方都有它的影子。打好这个基础,后面会轻松很多。

可可精品资料库