如果你手头有一个目标跟踪的需求——比如在摄像头画面里点一下某个人然后希望程序一直把这个目标框住那 Camshift 算法大概率是你最早接触的方案之一。Camshift 全称是 Continuously Adaptive Mean Shift中文一般叫连续自适应均值漂移它在计算机视觉里算是老将了但在 OpenCV 里至今保留着完整实现改一改就能跑起来。我最近在一个模拟项目里重新用了它过程中把原理、代码、坑都捋了一遍这篇文章就把这整个过程写给你适合刚接触目标跟踪、想快速跑通一个 Demo 的人也适合已经跑过 Mean Shift 但想理解两者差异的朋友。1. 一个经典需求点一下目标然后让它跟着走1.1 Mean Shift 是什么密度爬山的朴素思想先说 Mean Shift因为 Camshift 是它的直接升级版。Mean Shift 最早是个聚类和概率密度估计的方法。你可以把它理解成在一张密度图上找山峰给一堆数据点每个点周围都聚集了一些邻居Mean Shift 会从一个初始位置出发不停往周围点最密集的方向挪动最后收敛到密度最高的地方。这个思路搬到图像里逻辑很顺。假设你知道目标大概在哪目标的颜色特征可以用一个直方图来表示图像每个位置都可以算出这里像目标的程度这就构成了一张概率分布图。Mean Shift 从初始窗口出发反复把窗口中心挪向窗口内概率值加权的重心直到中心移动很小或迭代次数到了。整个过程很像一个球在重力作用下滚到谷底只不过这里找的是最高点。代码上OpenCV 里的 Mean Shift 并不单独暴露成一个函数而是嵌在 Camshift 内部。你可以把 Camshift 当成附带窗口自适应的 Mean Shift 迭代器每次迭代还是会走重心变化那一套。1.2 为什么直接搬 Mean Shift 不够Mean Shift 能干活但有个很明显的短板搜索窗口大小是固定的。你在初始帧手动框了一个目标窗口多大就一直是多大。目标走近时你在画面上明明应该越变越大窗口却还停留在原来的尺寸目标走远时窗口又显得太大把背景也包进来了概率分布一平均中心就容易偏。我把这个问题说得具体一点。假设你在跟踪一个穿红色上衣的人初始窗口精确框住上衣。人走到镜头前上衣区域在画面里变大了但窗口还是原来那么点窗口内的概率加权重心确实会变化可窗口本身不会跟着扩张。你看到的结果就是跟踪框永远只有一小块甚至因为窗口被目标内部占满中心点会跳来跳去。所以 Mean Shift 更像是一个局部寻峰器它擅长在已知位置附近收敛到目标的中心但对尺度和姿态变化毫无招架之力。为了解决这个问题Camshift 在 Mean Shift 的每次收敛之后会额外做一步根据当前窗口内的颜色分布信息重新计算窗口的大小、方向和形变。这才是它名字里 Adaptive——自适应——的真正含义。2. 目标表达方式颜色直方图和反向投影的配合2.1 为什么选颜色以及为什么是 HSVCamshift 把目标描述成一个颜色直方图也就是统计目标区域内每种颜色出现的次数。这个方法有一个很实在的优点不需要训练。目标是你点了第一帧框出来的算法现场统计那一小块的颜色分布就算完成了认识目标这一步。问题来了用哪种颜色空间如果直接用 RGB对光照特别敏感。同一个红色物体在阳光下和阴影里RGB 数值可能差得很远。HSV 把颜色信息拆成了三个部分色调 H、饱和度 S、明度 V。其中 H 通道表达的是这是什么颜色和亮度基本解耦。所以通常做法是只取 H 通道建直方图把 V 和 S 作为辅助筛选条件这样不同亮度下的同色物体在特征空间里会靠得比较近。这里有个实操细节H 通道的取值范围在 OpenCV 里是 0 到 179对应色环上的 360 度所以直方图的 bin 数量经常取 180一个 bin 对应一个色度等级。你也可以取 30 或 60 个 bin 做粗粒度匹配bin 越少抗噪声能力越强但区分度越差。在构建直方图之前你通常还要用一个 mask 把低饱和度和低亮度的像素滤掉。因为纯黑、纯白、灰色区域的 H 值没有意义会让直方图包含大量噪声。我在代码里会用cv2.inRange(hsv, (0, 50, 50), (180, 255, 255))这类条件把太暗和太灰的像素排除在外。2.2 反向投影把目标搜索变成概率图找峰直方图建好之后怎么用它去全图搜索目标这里就用到反向投影Back Projection。反向投影做的事情非常简单对图像里每个像素查一下它的 H 值在目标直方图里对应 bin 的数值然后把这个数值写成输出图上对应位置的像素值。直方图里数值越大代表这个颜色在目标里出现越频繁所以反向投影输出图中越亮的像素就是越像目标位置的位置。原本一张普通的彩色帧经过反向投影后会变成一张单通道的概率图亮的地方是目标颜色集中的区域暗的地方是背景区域。整个过程像一个过滤器把和目标颜色像不像显性化了出来。所以说到底Camshift 跟踪的并不是目标本身的图像而是这张反向投影概率图上的峰。只要目标颜色和背景颜色差异够大峰就会又高又集中在目标上反过来如果背景也有大片相似颜色概率图就会出现一堆假峰后面十有八九会跟丢。这一点是 Camshift 的立身之本也是它的死穴。2.3 一个具体例子帮你建立画面感假设场景里有一个橙色的水杯背景是浅灰色桌面。你先框住水杯统计直方图后发现 20 到 40 这个 H 值区间占了绝大多数权重。反向投影之后水杯所在位置会出现一个高亮的椭圆区域桌面因为灰色饱和度太低对应的 H 值在直方图里几乎没有权重输出近乎黑色。紧接着 Camshift 在这个概率图上做 Mean Shift 迭代第一次迭代后窗口中心移动到高亮区域的重心随后算法根据二值矩重新计算窗口尺寸把窗口扩张到和亮斑差不多大。反复几次窗口就稳定包裹住水杯。整个过程里你看不到目标的具体纹理、边缘、角点算法从头到尾只关心哪里颜色像目标。这个例子解释了为什么 Camshift 对形变不敏感。人转身、弯腰、衣物褶皱变化都不影响颜色分布的大致形状。但如果是跟踪一只和背景颜色相近的猫或者一个穿了跟墙壁差不多颜色衣服的人反向投影图就会乱成一团Camshift 基本无能为力。3. Camshift 相对 Mean Shift 的真正改进点3.1 固定窗口的致命伤前面提过Mean Shift 的窗口大小是固定的。但你可能会有疑问既然目标大小在变化为什么不在每次迭代时用窗口内的概率分布去重新估计目标大小呢这正是 Camshift 的切入点。Camshift 在每次 Mean Shift 收敛到新的中心后会统计当前窗口内所有像素的质量。这里说的质量就是反向投影图的像素值。通过一阶矩和二阶矩算法能够算出目标在当前帧的质心坐标、长轴长度、短轴长度和旋转角度。接下来就用这些数值重新设定下一帧搜索窗口的位置、大小和方向。这样窗口不再是死板的矩形而是一个可以缩放、旋转的椭圆或旋转矩形。3.2 零阶矩决定窗口级二阶矩修正方向具体到数学上Camshift 会计算搜索窗口内概率图的空间矩。零阶矩是窗口内所有像素值之和它大致反映了目标在当前尺度下占据的像素面积。经典算法里会用零阶矩去调节窗口边长常见的近似做法是把窗口尺寸设为和sqrt(M00)成比例的量这样目标变大时 M00 变大窗口跟着变大目标变小时 M00 变小窗口跟着收缩。二阶矩则用来判断目标分布的朝向。当你跟踪一个有一定长宽比的目标时比如一个人站着长条形二阶矩会拟合出椭圆的主轴方向窗口会偏转成倾斜的旋转矩形。在 OpenCV 的cv2.CamShift返回结果里你会拿到一个包含中心点、尺寸和角度的旋转矩形这个结果就是算法对目标姿态的估计。我个人的看法是Camshift 的尺度自适应能解决很多问题但它不是真正学到了目标的几何结构。它的尺度估计完全依赖颜色分布的胖瘦。如果目标由两种颜色构成比如黑白条纹的衣服颜色分布很散M00 算出来可能忽大忽小窗口就会疯狂抖动。3.3 心里要有数Camshift 不是万能的尺度估计有一个常见的误解是Camshift 能自动处理目标任意尺度和旋转的变化。实际跑过你就知道它只能处理目标整体颜色还能保持相似轮廓的情况。当你跟踪的目标旋转到侧面颜色分布依然存在窗口方向会跟着变但如果你跟踪的目标是侧脸转正脸肤色分布没太大变化窗口其实看不出姿态差异。另外窗口的自适应调整是有反馈的。一旦窗口没有完全框住目标比如只框住了目标的一部分那这部分直方图信息就不完整反向投影的真实目标区域会变得更小下一帧窗口继续缩小最终缩成一个点或者干脆跳到背景里去。这种误差积累是 Camshift 最常见的失败模式后面我会专门展开讲怎么缓解。4. OpenCV 复现完整代码与运行说明4.1 环境与准备我用的是 Python 加 OpenCV这是最简单的一条路。你需要安装好 OpenCV 和 NumPy两者都是标准的开源库不会引入额外负担。具体安装方式看你自己环境Python 环境下用包管理工具一条命令就好这里不展开。代码结构很简单打开摄像头或视频文件读第一帧让你手动框选目标然后进入循环。4.2 目标初始化初始化是整个跟踪器的地基。你要保证框选区域里的颜色足够代表目标整体如果你只框了目标的左半边直方图就只能学到左半边的颜色之后目标转个身右半边颜色一出现跟踪器就不认识了。所以实操时框选范围尽量覆盖目标主体但不要包含太多背景。框选之后要计算直方图代码如下import cv2 import numpy as np cap cv2.VideoCapture(0) # 0 是摄像头也可以是视频文件路径 ret, frame cap.read() if not ret: raise RuntimeError(无法读取画面) # 手动选择目标区域 r cv2.selectROI(select ROI, frame, showCrosshairTrue) cv2.destroyWindow(select ROI) x, y, w, h r track_window (x, y, w, h) # 转 HSV取 ROI hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) roi hsv[y:yh, x:xw] # 过滤低饱和、低亮度的噪声 mask cv2.inRange(roi, (0, 50, 50), (180, 255, 255)) roi_hist cv2.calcHist([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)有一点要解释cv2.selectROI会弹出窗口让你框选我建议在初始化时把目标放在光线均匀的位置尽量不要让目标刚好处于强逆光或轮廓被遮挡的状态。mask的阈值(0, 50, 50)是经验值如果你发现跟踪目标本身颜色很浅或者很暗要适当调低这两个阈值。4.3 跟踪主循环初始化完成后进入循环。每一帧的流程是转 HSV做反向投影调用cv2.CamShift然后用返回的旋转矩形绘制跟踪框。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_rect, track_window cv2.CamShift(dst, track_window, term_crit) # 将旋转矩形转为四个角点并绘制 pts cv2.boxPoints(ret_rect) pts np.int0(pts) img cv2.polylines(frame, [pts], True, (0, 255, 0), 2) cv2.imshow(Camshift Tracking, img) if cv2.waitKey(30) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()在实际运行中你会看到返回的ret_rect不仅包含位置还包含旋转角度和尺寸这就是前面提到的二阶矩拟合结果。track_window会被更新成新的搜索窗口位置供下一帧使用。4.4 完整代码与可调参数以上两段代码合并就是完整的可运行 Demo。想调效果优先关注这几个参数term_crit中的迭代次数 10越大跟踪越稳但耗时越高越小越容易跟不上快速运动。mask中的(0, 50, 50)阈值限制直方图纳入哪些像素低饱和度背景多时要调高一点。直方图 bin 数量代码里取了 180也可以改成 60 或 30bin 越少对颜色渐变更宽容但容易混入相似色。我不建议一上来就调参数先原样跑一遍观察目标静止、移动、靠近摄像头三种情况下的表现再针对问题去调。很多项目最后发现不是参数的问题而是初始化框选得太随意导致的。5. 实测容易跟丢的场景和我的调参记录5.1 光照突变颜色特征的天敌我印象最深的一次测试是在我们某实验室的窗口边目标是一块黄色便签纸。刚开始跟踪正常但云飘过来又散开阳光从直射变成散射黄色便签的反向投影图一下子从清晰的高亮块变成模糊的一团窗口直接跳到旁边的白色文件夹上。原因不复杂虽然 HSV 把色调和亮度分开了但极端光照变化下像素的 H 值也会漂移饱和度变化更明显直方图匹配的命中率剧烈下降。缓解办法有两个方向。一是初始化时多框几秒的目标区域收集不同姿态和光照下的颜色分布二是离线事先统计更大范围的颜色分布简单说就是把直方图建得更胖一点接收一定范围的颜色偏移。但我们当时时间有限最直接有效的办法是切换跟踪目标或改用其他算法。5.2 相似背景和相似目标直方图分不清另一个高频问题是背景颜色和目标颜色接近。有一回我在测试中跟踪一个穿深蓝色外套的人画面边缘有一把深蓝色椅子结果目标一靠近椅子反向投影图里椅子的亮度和人差不多Camshift 的搜索窗口开始犹豫最后硬生生被椅子吸过去。这个问题本质上不是跟踪算法的问题而是颜色特征无法区分目标与背景。一种经验做法是给直方图增加约束比如同时使用 H 和 S 两个通道做二维直方图而不是只用 H 通道。二维直方图能保留更多颜色信息对颜色相近但饱和度不同的目标更友好。缺点是计算量稍大、统计更稀疏bin 设置不好反而更容易跟丢。5.3 遮挡之后为什么很难恢复Camshift 没有外观模板更新机制它只靠初始化时那一次直方图走天下。目标一旦被完全遮挡反向投影图里就没有了目标区域搜索窗口会被等亮度或类似颜色的背景填满。遮挡结束之后窗口可能已经漂移到完全不同的位置就算目标重新出现窗口也找不回来了。我在项目里对这个问题做过一个补偿每一帧跟踪成功后用当前窗口内重新统计的直方图和历史直方图做加权更新形成一种简易的自适应更新。这个方案在小幅外观变化时有效但如果目标在遮挡期间改变了颜色特征比如脱了外套反而会引入脏数据。后来我更倾向的做法是目标被遮挡时暂停窗口尺度更新只记录最后可靠的位置等目标重新出现再用检测器辅助找回。5.4 调参经验汇总现象可能原因调整策略窗口漂移到背景背景颜色和目标相似使用 HS 二维直方图或调高 mask 对饱和度的要求窗口越缩越小目标尺度变小或跟踪误差积累限制窗口最小尺寸不在每帧都完全信任尺度更新窗口剧烈抖动直方图 bin 太多、颜色分散减少 bin 数量增大迭代次数目标快速移动时跟丢迭代次数不足、搜索范围太小提高term_crit迭代次数或先做运动预测再搜索光照变化后丢失H 值漂移、饱和度变化扩大直方图接受范围或增加多光照直方图模板这些调整不可能一次性到位。我一般会先跑一个带实时画面和反向投影图的调试窗口边看概率图边调阈值。概率图清楚跟踪结果基本不会差概率图乱七八糟调后面的参数意义不大。6. Camshift、KCF 与光流选型对比与使用建议6.1 三种思路的根本差异Camshift 走的是颜色分布路线它不关心目标纹理只关心颜色概率分布。KCF 走的是表观模型路线它从目标区域提取特征训练一个岭回归分类器在下一帧搜索候选区域中响应最高的位置典型特征是多通道 HOG 加颜色特征。光流法则完全换了一条路它跟踪的是图像中的角点或特征点通过相邻帧间的像素运动估算目标位置。这三个方法对应了三种目标建模方式统计颜色、统计表观、统计运动。没有谁绝对更优只看你面临什么场景。6.2 实测对比我在某模拟项目里分别用这三种方法跟踪过同一段视频。目标是一个在室内移动的玩具车车身是鲜明的红色背景有些书架和阴影。Camshift 在玩具车颜色和背景差异明显时稳得惊人旋转框还能跟随车的角度变化。但如果玩具车经过书架附近红色书脊一出现Camshift 立刻分心。KCF 在目标被部分遮挡时表现更好它能抓住纹理信息不太容易被相同颜色的物体干扰但 KCF 对快速尺度变化和剧烈形变比较敏感目标旋转或姿态变化太大时分类器学到的表观不匹配。光流法则整体表现一般因为玩具车表面纹理少可跟踪的角点不多但如果在目标身上贴一些特征明显的贴纸光流的效果会明显改善。从速度上看三者都能在普通 CPU 上实时运行Camshift 的耗时常被当成入门级水平但真正瓶颈通常不在算法本身而在反向投影和后续处理。KCF 速度也很快但参数比 Camshift 隐晦调起来更费劲。6.3 我现在的选择标准做了几个项目之后我现在选型基本看三个问题。第一目标颜色是否独特且稳定。如果答案是肯定的我会优先考虑 Camshift因为实现简单、可解释性最强出问题了也好排查。第二目标是否具备丰富纹理。如果目标表面纹理多、背景杂乱我会选 KCF 这类判别式方法它更擅长在复杂背景中分辨目标。第三是否需要长时间持续跟踪。只要时间线拉长Camshift 的直方图老化问题就会暴露这时候我会给它叠加一个定期重采样的机制或者干脆把跟踪任务交给检测器辅助用跟踪结果做帧间平滑。Camshift 不是一个能打遍天下的算法但它非常适合作为你理解目标跟踪的第一块跳板。把它的直方图、反向投影、矩估计这三个环节吃透再看 KCF、相关滤波乃至深度跟踪器你会很容易发现它们都在解决同样的问题目标长什么样、下帧去哪找、找到后怎么更新模型。搞懂了这些底层逻辑选什么工具都只是顺手的事。