OpenCV 视频目标跟踪实战:Meanshift 与 Camshift 算法原理、源码解析与代码实现
本文以 OpenCV 官方教程 doc/tutorials/others/meanshift.markdown 为主体脉络,系统讲解在视频序列中基于直方图反投影跟踪运动物体的 Meanshift 与 Camshift(Continuously Adaptive Meanshift,连续自适应均值漂移) 两种经典算法。文章不仅给出可直接运行的 C++/Python 示例,还深入 video 模块源码(modules/video/src/camshift.cpp)剖析二者的迭代收敛细节,帮助你既能跑通官方 Demo,又能理解 OpenCV 内部究竟在做什么。
读完本文,你将掌握:目标颜色直方图的建立与直方图反投影流程、cv.meanShift() 与 cv.CamShift() 的完整调用链与参数语义、固定窗口跟踪与自适应旋转窗口跟踪的差异,以及如何把结果绘制到画面中形成实时跟踪效果。
一、Meanshift:把窗口挪向像素密度最大的地方
1.1 直觉与原理
Meanshift(均值漂移)的核心思想非常直观:假设你面对一组数据点(例如一幅直方图反投影图上的像素分布),并已经给定一个小窗口(通常视为圆形或矩形),任务就是把这个窗口移动到"像素密度最大"(即点数最集中)的区域。
迭代过程如下:
- 以初始窗口中心(记为
C1_o)圈定窗口内的所有点; - 计算窗口内所有点的质心(centroid,记为
C1_r),它通常与窗口中心并不重合; - 把窗口中心移动到刚才算出的质心位置,得到新窗口;
- 再次计算新窗口内的质心……如此反复迭代;
- 当窗口中心与其内部质心重合(或偏差小于允许误差)时停止——此时窗口便落在了像素分布最密集的位置(下图中的绿色圆
C2)。
该过程可用下面的示意图直观表达(原图出自官方教程插图):
在视频跟踪场景中,通常把直方图反投影图像和目标的初始位置窗口作为输入。当物体移动时,运动会立即反映在反投影图像的变化上,Meanshift 因而能把窗口推向新的最大密度区域,实现逐帧跟踪。
从源码层面看,OpenCV 的 meanShift 正是"不断把窗口中心平移至其内部质心"这一过程的直接实现(见 modules/video/src/camshift.cpp#L44-L107):
- 对窗口覆盖的子图像调用
moments()计算零阶矩m00、一阶矩m10/m01; - 得到质心偏移量
dx = m10/m00 - window.width*0.5、dy = m01/m00 - window.height*0.5; - 若质心与窗口中心的偏移平方和
dx*dx + dy*dy < eps则提前终止,否则窗口中心平移后进入下一轮迭代; - 返回实际执行的迭代次数
i。
需要说明的是,meanShift 只允许在单通道反投影图上运行(CV_Assert(cn == 1)),且要求窗口宽高为正数,否则会抛出参数错误。其中终止条件 eps 的语义值得注意:源码将 criteria.epsilon 先 cvRound(eps*eps) 后用于判断"窗口中心位移平方"是否足够小——这正是官方示例把 TermCriteria 阈值设为 1(像素)即可取得良好效果的原因。
1.2 目标建模三件套:ROI、色相直方图与反投影
在 OpenCV 中使用 Meanshift 前,需要完成三步准备工作:
- 框选目标 ROI:从视频首帧手动给定初始跟踪窗口
(x, y, w, h); - 统计目标色相直方图:把 ROI 转到 HSV 色彩空间,只取 H(Hue,色相)通道统计直方图,这样对光照强度变化相对鲁棒;
- 过滤低饱和度/低亮度像素:为规避暗光下颜色值失真,用
cv.inRange()把低饱和、低亮度的像素置 0,只让"有颜色"的像素参与直方图统计。
官方示例中采用的色相过滤区间为 S ∈ [60, 255]、V ∈ [32, 255](Python 示例见 samples/python/tutorial_code/video/meanshift/meanshift.py,C++ 对应版本见 samples/cpp/tutorial_code/video/meanshift/meanshift.cpp)。
完成 ROI 直方图建模后,每一帧的处理流程固定为:
- 当前帧转 HSV → 仅取 H 通道 →
calcBackProject()生成反投影图dst(像素值越大表示越像目标); cv.meanShift(dst, track_window, term_crit)获得新窗口位置;- 绘制矩形框并显示。
下面的完整 Python 实现可以直接运行(需要传入一段交通视频,例如 slow_traffic_small.mp4):
import numpy as np
import cv2 as cv
import argparse
parser = argparse.ArgumentParser(description='This sample demonstrates the meanshift algorithm.')
parser.add_argument('image', type=str, help='path to image file')
args = parser.parse_args()
cap = cv.VideoCapture(args.image)
# take first frame of the video
ret, frame = cap.read()
# setup initial location of window
x, y, w, h = 300, 200, 100, 50 # simply hardcoded the values
track_window = (x, y, w, h)
# set up the ROI for tracking
roi = frame[y:y+h, x:x+w]
hsv_roi = cv.cvtColor(roi, cv.COLOR_BGR2HSV)
mask = cv.inRange(hsv_roi, np.array((0., 60., 32.)), np.array((180., 255., 255.)))
roi_hist = cv.calcHist([hsv_roi], [0], mask, [180], [0, 180])
cv.normalize(roi_hist, roi_hist, 0, 255, cv.NORM_MINMAX)
# Setup the termination criteria, either 10 iteration or move by at least 1 pt
term_crit = (cv.TERM_CRITERIA_EPS | cv.TERM_CRITERIA_COUNT, 10, 1)
while True:
ret, frame = cap.read()
if not ret:
break
hsv = cv.cvtColor(frame, cv.COLOR_BGR2HSV)
dst = cv.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)
# apply meanshift to get the new location
ret, track_window = cv.meanShift(dst, track_window, term_crit)
# Draw it on image
x, y, w, h = track_window
img2 = cv.rectangle(frame, (x, y), (x + w, y + h), 255, 2)
cv.imshow('img2', img2)
k = cv.waitKey(30) & 0xff
if k == 27: # Esc 退出
break
cv.destroyAllWindows()
C++ 版本结构完全相同,只是需要显式声明 Rect track_window(300, 200, 100, 50)、TermCriteria term_crit(TermCriteria::EPS | TermCriteria::COUNT, 10, 1),并自行管理 calcHist/calcBackProject 的通道与区间参数(见 samples/cpp/tutorial_code/video/meanshift/meanshift.cpp#L45-L82)。Java 版本可参考 samples/java/tutorial_code/video/meanshift/MeanshiftDemo.java。
官方示例在真实交通视频上的跟踪结果如下,矩形框会持续框住目标车辆:
运行提示:示例中
(x, y, w, h) = (300, 200, 100, 50)是硬编码的初值。换用别的视频时,需要按目标实际位置重新框选,否则第一帧直方图建的是背景而非目标。
二、Camshift:让窗口大小与旋转跟着目标变
2.1 Meanshift 的局限
仔细看上面的结果会发现一个问题:无论汽车离镜头是远还是近,跟踪窗口的大小始终不变。目标靠近时窗口偏大、远离时窗口偏小,固定尺寸显然不是好的跟踪形态。理想的窗口应当随目标的尺度变化与旋转角度自适应调整。
2.2 算法由来与更新规则
这一问题的解决方案出自 "OpenCV Labs",即 CAMshift(Continuously Adaptive Meanshift),由 Gary Bradsky 在 1998 年发表的论文 "Computer Vision Face Tracking for Use in a Perceptual User Interface" 中提出。
Camshift 的工作方式可以概括为三步循环:
- 先执行一次 Meanshift,使窗口收敛到目标的质心位置;
- 按公式
依据窗口内反投影像素的零阶矩
M00更新窗口尺寸,同时计算能够最佳拟合目标形状的旋转外接椭圆的方向; - 用新尺度的搜索窗口 + 上一轮窗口位置再次执行 Meanshift……如此反复,直到达到所需精度。
这样得到的就不再是固定矩形,而是一个带旋转角度、可缩放的目标框。
从实现上看,OpenCV 的 cv::CamShift(见 modules/video/src/camshift.cpp#L110-L218)完整复现了上述思路:
- 首先调用内部
meanShift()收敛到质心(window参数在此函数中会以输入输出方式被就地更新); - 以
TOLERANCE = 10像素对窗口做外扩后再计算二阶矩mu11/mu20/mu02,进而用atan2(2b, a-c+sqrt(...))求解椭圆主轴方向角theta; - 通过把二阶矩旋转到主轴坐标系,计算出椭圆的长半轴对应长度
length = sqrt(rotate_a/m00)*4与短轴width,并处理了theta为 0° 或约 90° 时长短轴互换的边界情形; - 返回一个
RotatedRect box,其成员包含目标中心center、尺寸size与角度angle(角度值最终规整到[0, 180)区间),随后调用方可用RotatedRect::boundingRect()得到下一轮所需的搜索窗口(该约定在 modules/video/include/opencv2/video/tracking.hpp#L64-L83 的 API 文档中有明确说明)。
下图为 Camshift 跟踪人脸时的收敛示意动画帧(出自官方教程插图):
2.3 Camshift 在 OpenCV 中的使用
Camshift 的使用与 Meanshift 几乎相同——目标建模、直方图反投影、终止条件全部复用——唯一区别在于跟踪函数:
cv.meanShift()返回窗口位置,绘制轴对齐矩形;cv.CamShift()返回RotatedRect(旋转矩形),绘制的是带倾斜角度的四边形外框,而窗口参数(track_window)仍由函数就地更新,可直接传给下一帧。
完整的 Python 示例见 samples/python/tutorial_code/video/meanshift/camshift.py,核心循环如下:
while True:
ret, frame = cap.read()
if not ret:
break
hsv = cv.cvtColor(frame, cv.COLOR_BGR2HSV)
dst = cv.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)
# apply camshift to get the new location
ret, track_window = cv.CamShift(dst, track_window, term_crit)
# Draw rotated rectangle on image via its 4 corner points
pts = cv.boxPoints(ret)
pts = np.int0(pts)
img2 = cv.polylines(frame, [pts], True, 255, 2)
cv.imshow('img2', img2)
k = cv.waitKey(30) & 0xff
if k == 27:
break
要点解读:
roi_hist的建模代码与 Meanshift 版本完全一致(H 通道 180 级直方图 +NORM_MINMAX归一化到[0,255]);cv.CamShift()的返回值ret是RotatedRect,其中ret[0]/ret.center为中心点、ret[1]/ret.size为(宽, 高)、ret[2]/ret.angle为旋转角;- 绘制时不能直接用
rectangle,而应通过cv.boxPoints(ret)取出旋转矩形的 4 个角点,再用polylines连线成框。
C++ 对应示例在 samples/cpp/tutorial_code/video/meanshift/camshift.cpp#L64-L85 中,用 RotatedRect::points() 取出 4 个角点后逐边连线绘制;Java 版本见 samples/java/tutorial_code/video/meanshift/CamshiftDemo.java。
Camshift 在交通视频上的实际跟踪效果如下,可以看到框随汽车尺度变化:
提示:官方还额外提供了一个可交互的 Python 演示
camshift.py(位于 samples/python/snippets/camshift.py),允许用鼠标实时框选目标后观看跟踪过程,非常适合边改边理解算法行为。对应 C++ 交互版本可见 samples/cpp/snippets/camshiftdemo.cpp。
三、两算法对比与适用边界
| 维度 | Meanshift | Camshift |
|---|---|---|
| 返回结果 | 迭代后窗口 Rect(位置 + 固定宽高),及迭代次数 |
RotatedRect(中心 + 尺寸 + 旋转角),window 就地更新供下轮使用 |
| 窗口形态 | 轴对齐、尺寸恒定 | 旋转外接椭圆/矩形,尺寸与角度自适应 |
| 窗口更新 | 每轮只平移窗口中心到质心 | 收敛后再用零阶矩 M00 重算尺寸,并外扩 TOLERANCE=10 后重新估计 |
| 适用场景 | 目标尺度基本不变的粗跟踪 | 目标远近变化明显、存在姿态旋转的跟踪 |
| 源码位置 | modules/video/src/camshift.cpp 中 cv::meanShift() |
同文件中 cv::CamShift()(内部复用 meanShift) |
| 公共 API 声明 | modules/video/include/opencv2/video/tracking.hpp | modules/video/include/opencv2/video/tracking.hpp |
需要如实说明的工程边界:
- 两类算法都基于颜色直方图反投影,本质假设是"目标颜色显著区别于背景"。当目标与背景颜色接近、目标颜色剧烈变化,或存在大面积遮挡时,跟踪容易漂移,这是直方图外观模型的固有局限;
meanShift与CamShift均为轻量级、CPU 友好的经典跟踪器,适合实时场景,但面对快速运动、形变剧烈的目标不如现代的检测式/判别式跟踪器鲁棒;- 官方教程在 doc/tutorials/others/table_of_content_other.markdown 中将其与背景减除、光流等内容并列,可结合背景减除(
tutorial_background_subtraction)与光流(tutorial_optical_flow)对比不同跟踪思路的取舍。Bradski 1998 年的原始论文可作为进一步研究 Camshift 数学推导的参考文献。
四、小结与下一步建议
以 doc/tutorials/others/meanshift.markdown 为纲,可以快速搭起一套"目标建模 → 逐帧反投影 → 漂移搜索/自适应搜索"的完整链路:
- 取首帧 ROI,在 HSV 空间用
inRange滤除低饱和低亮像素后统计 H 通道直方图并归一化; - 每帧
cvtColor+calcBackProject得到概率图; - 需要固定框用
cv.meanShift,需要自适应旋转框用cv.CamShift,终止条件统一交给TERM_CRITERIA_EPS | TERM_CRITERIA_COUNT(迭代上限 10、位移 ≤1 像素); - 读一遍 modules/video/src/camshift.cpp 对应实现,把"质心迭代 + 二阶矩求椭圆"的原理落实到代码行。
延伸练习:动手修改官方 camshift 演示代码(samples/python/snippets/camshift.py),尝试调整 TERM_CRITERIA 中的最大迭代次数与位移阈值、inRange 的 S/V 下界,以及直方图通道(例如改用 H+S 两通道建模),观察各自对跟踪稳定性与漂移速度的影响——这是理解参数语义最直接的方式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00



