4. 经典跟踪算法(二):连续自适应均值漂移(CAMShift)算法

上一节我们聊了 Mean Shift,说白了它就是个「找密度中心」的算法。但有个问题——目标在运动过程中,大小会变啊!你想想看,一个人朝你走过来,他在画面里会越来越大。如果跟踪窗口固定不变,那肯定要出问题。

CAMShift 就是来解决这个问题的。全称是 Continuously Adaptive Mean Shift,连续自适应均值漂移。我当年第一次在项目中用这个算法时,心里还嘀咕:这不就是 Mean Shift 加了个自适应窗口吗?后来踩了不少坑才明白,这个「自适应」三个字,学问大着呢。

4.1 从 Mean Shift 到 CAMShift

先说说核心区别。Mean Shift 的窗口大小是固定的,你初始化时设多大,它就多大。CAMShift 呢?每一帧都会根据目标的「形状」重新计算窗口大小和方向。

怎么做到的?靠的是二阶矩。嗯,这里我稍微展开一下。

CAMShift 的工作流程是这样的:

  1. 用 Mean Shift 找到当前帧的目标中心位置
  2. 计算目标区域的二阶矩(说白了就是统计像素分布)
  3. 根据二阶矩算出新的窗口大小和旋转角度
  4. 用新窗口作为下一帧的初始窗口

你看,每一帧都在「自适应」。我刚开始做的时候觉得这步可有可无,直到有一次跟踪一辆转弯的车——固定窗口直接跟丢了,CAMShift 却稳稳地锁住了车身的朝向。

4.2 核心公式:二阶矩与窗口更新

这里我直接给出计算方式,不绕弯子。

假设目标区域的概率图是 \(I(x,y)\),那么二阶矩就是:

M20 = Σ Σ x² * I(x,y)
M02 = Σ Σ y² * I(x,y)
M11 = Σ Σ x * y * I(x,y)

然后我们可以算出窗口的长轴、短轴和方向角:

a = M20 / M00 - xc²
b = 2 * (M11 / M00 - xc * yc)
c = M02 / M00 - yc²

长轴 = sqrt( (a+c) + sqrt(b² + (a-c)²) ) / 2
短轴 = sqrt( (a+c) - sqrt(b² + (a-c)²) ) / 2
方向角 = 0.5 * atan2(b, a-c)

看着有点复杂对吧?其实说白了,就是在算一个椭圆,让它能最好地「包裹」住目标区域。

重要提醒: 这里的 M00 就是目标区域的零阶矩,也就是所有像素值的总和。如果目标区域太暗或者太亮,M00 会出问题。我建议你在计算前先做归一化。

4.3 CAMShift 与 Mean Shift 的对比

我整理了一个表格,方便你对比:

特性 Mean Shift CAMShift
窗口大小 固定 自适应变化
窗口方向 始终水平 可旋转
适用场景 目标大小基本不变 目标有缩放、旋转
计算量 较低 稍高(多了一步矩计算)
鲁棒性 对尺度变化敏感 对尺度变化不敏感

我个人习惯是:如果目标在画面中大小变化不超过 20%,用 Mean Shift 就够了。但如果目标会靠近或远离摄像头,或者有旋转,那必须上 CAMShift。

4.4 实战应用:用 OpenCV 实现 CAMShift

OpenCV 里已经封装好了 CAMShift,用起来很方便。我给你写个完整的例子:

import cv2
import numpy as np

# 读取视频
cap = cv2.VideoCapture('test_video.mp4')
ret, frame = cap.read()

# 选择初始跟踪区域
x, y, w, h = cv2.selectROI('Select Target', frame, False)
track_window = (x, y, w, h)

# 提取目标区域的HSV直方图
roi = frame[y:y+h, x:x+w]
hsv_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv_roi, np.array((0., 60., 32.)), np.array((180., 255., 255.)))
roi_hist = cv2.calcHist([hsv_roi], [0], mask, [180], [0, 180])
cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)

# 设置终止条件
term_crit = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1)

while True:
    ret, frame = cap.read()
    if not ret:
        break
    
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    dst = cv2.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)
    
    # CAMShift 核心调用
    ret, track_window = cv2.CamShift(dst, track_window, term_crit)
    
    # 绘制结果
    pts = cv2.boxPoints(ret)
    pts = np.int0(pts)
    img = cv2.polylines(frame, [pts], True, (0, 255, 0), 2)
    
    cv2.imshow('CAMShift Tracking', img)
    if cv2.waitKey(30) & 0xFF == 27:
        break

cap.release()
cv2.destroyAllWindows()

避坑指南: 我曾经在 HSV 的 range 参数上吃过亏。如果你跟踪的目标颜色偏暗,记得把 S 和 V 的下限调低。默认的 (0., 60., 32.) 对暗色目标不太友好。

4.5 知识体系:CAMShift 核心逻辑

下面我用一张 SVG 图来展示 CAMShift 的完整流程:

CAMShift 算法核心流程 初始化目标区域 计算HSV直方图 反向投影 Mean Shift 计算二阶矩 M20, M02, M11 更新窗口大小和方向 长轴、短轴、方向角 输出跟踪结果 下一帧循环 关键点:每一帧都执行一次完整的 Mean Shift + 矩计算 窗口自适应更新后,作为下一帧的初始搜索窗口

4.6 实际项目中的经验总结

我在几个实际项目中用过 CAMShift,这里分享一些经验:

  • 颜色空间选择: HSV 比 RGB 好得多。RGB 对光照变化太敏感,而 HSV 的 H 通道基本不受光照影响。
  • 直方图更新: 如果目标外观变化不大,可以固定直方图。但如果目标会变色(比如车灯亮起),建议每隔几帧更新一次直方图。
  • 遮挡处理: CAMShift 本身不处理遮挡。我一般会加一个置信度判断——如果反向投影的最大值低于某个阈值,就认为目标被遮挡了,暂停更新。

注意: CAMShift 对背景颜色敏感。如果背景颜色和目标颜色相近,跟踪会漂移。我曾经在跟踪一个穿红衣服的人时,背景里出现了一面红旗,结果窗口直接飘到红旗上去了。这种情况建议结合运动信息(比如帧差法)来辅助判断。

好了,CAMShift 的核心内容就这些。说白了,它就是 Mean Shift 的升级版,解决了窗口自适应的问题。虽然现在深度学习跟踪算法很火,但 CAMShift 在简单场景下依然是个轻量级的好选择——不需要 GPU,不需要训练,几行代码就能跑起来。

可可精品资料库