OpenCV.js 视频目标跟踪实战:Meanshift 与 Camshift 的原理、API 与浏览器端完整实现
本篇指南基于 OpenCV 官方 JS 教程,系统讲解 Meanshift 与 Camshift 两种基于颜色直方图反投影的目标跟踪算法:先建立“窗口质心迭代”的几何直觉,再完整给出可直接在浏览器中运行的 OpenCV.js 实现代码,最后结合 modules/video 模块源码剖析 cv::meanShift 与 cv::CamShift 的收敛逻辑、终止条件与椭圆拟合细节。读完后,你将能在 WebAssembly 环境下复现固定窗口的 Meanshift 跟踪与自适应旋转窗口的 Camshift 跟踪,并理解两者参数在 C++ 底层实现中的真实含义。
Meanshift 的核心思想:让窗口“滑向”密度峰值
Meanshift 的直觉非常简单。假设你有一组点(例如由直方图反投影得到的像素分布图),再给你一个很小的窗口(可以是一个圆形区域)。你的任务是把窗口移动到像素密度最大(或点数最多)的区域。
以原理图为例:初始窗口用蓝色圆圈表示,记为 "C1",它的原始圆心用蓝色小方块 "C1_o" 标出。如果你计算窗口内部所有点的质心,会得到 "C1_r"(用蓝色小圆标出),它才是窗口的真正质心,显然与原始圆心不重合。于是把窗口平移到质心位置,重新计算新质心;两者大概率仍不重合,继续平移、继续迭代,直到“窗口中心与质心落在同一位置(或误差不超过期望值)”为止。最终得到的就是覆盖了最大像素分布的窗口,图中记为 "C2" 的绿色圆圈——可以看到它包含了最多的点。仓库中另有对静态图像执行该过程的动画 meanshift_face.gif 可供参考。
把这个过程搬到视频跟踪中:我们传入的是目标直方图的反投影图和目标的初始位置。当目标在画面中移动时,这种移动会直接反映在反投影图的密度分布上,Meanshift 算法就会把窗口移动到新的密度最大处,从而实现对目标的连续跟踪。
OpenCV.js 中的 cv.meanShift
在 OpenCV.js 中使用 MeanShift,流程分为两步准备:
- 设定目标区域,计算它的颜色直方图,以便在每一帧中对目标做直方图反投影;
- 提供搜索窗口的初始位置。
这里只使用 HSV 中的 Hue 通道建立直方图;同时为了避免低光照等干扰,使用 cv.inRange() 函数过滤掉不符合目标颜色特征的低质量像素,再用 mask 参与 cv.calcHist()。
调用 API:
[iterations, window] = cv.meanShift(probImage, window, criteria)
| 参数 | 说明 |
|---|---|
probImage |
目标直方图的反投影图,由 cv.calcBackProject 计算得到,单通道 |
window |
初始搜索窗口(cv.Rect),函数会以返回值形式给出新位置 |
criteria |
迭代搜索算法的终止条件(cv.TermCriteria) |
| 返回值 | 一个数组:meanShift 收敛所用的迭代次数 + 新的窗口位置 |
注意 C++ 底层实现中 probImage 会被断言为单通道(见后文源码分析),且搜索窗口会被自动裁剪到图像边界内。
浏览器端完整的 MeanShift 跟踪示例
下面的代码来自仓库中的交互式演示页 js_meanshift.html(配套视频素材为同目录下的 cup.mp4):页面包含 <video> 输入、<canvas> 输出和一段可编辑执行的 <textarea> 代码,点击 Start 后 codeSnippet 中的脚本会被执行。完整跟踪脚本如下:
let video = document.getElementById('videoInput');
let cap = new cv.VideoCapture(video);
// 读取视频第一帧
let frame = new cv.Mat(video.height, video.width, cv.CV_8UC4);
cap.read(frame);
// 硬编码窗口的初始位置(x, y, width, height)
let trackWindow = new cv.Rect(150, 60, 63, 125);
// 建立 ROI,用于提取目标直方图
let roi = frame.roi(trackWindow);
let hsvRoi = new cv.Mat();
cv.cvtColor(roi, hsvRoi, cv.COLOR_RGBA2RGB);
cv.cvtColor(hsvRoi, hsvRoi, cv.COLOR_RGB2HSV);
// 用 inRange 过滤掉不符合目标颜色的低质量像素,生成 mask
let mask = new cv.Mat();
let lowScalar = new cv.Scalar(30, 30, 0);
let highScalar = new cv.Scalar(180, 180, 180);
let low = new cv.Mat(hsvRoi.rows, hsvRoi.cols, hsvRoi.type(), lowScalar);
let high = new cv.Mat(hsvRoi.rows, hsvRoi.cols, hsvRoi.type(), highScalar);
cv.inRange(hsvRoi, low, high, mask);
// 只对 Hue 通道(通道 0)计算 180 个 bin 的直方图,并归一化到 0~255
let roiHist = new cv.Mat();
let hsvRoiVec = new cv.MatVector();
hsvRoiVec.push_back(hsvRoi);
cv.calcHist(hsvRoiVec, [0], mask, roiHist, [180], [0, 180]);
cv.normalize(roiHist, roiHist, 0, 255, cv.NORM_MINMAX);
// 释放不再使用的 Mat
roi.delete(); hsvRoi.delete(); mask.delete(); low.delete(); high.delete(); hsvRoiVec.delete();
// 终止条件:最多迭代 10 次,或窗口中心移动距离小于 1 像素
let termCrit = new cv.TermCriteria(cv.TERM_CRITERIA_EPS | cv.TERM_CRITERIA_COUNT, 10, 1);
let hsv = new cv.Mat(video.height, video.width, cv.CV_8UC3);
let dst = new cv.Mat();
let hsvVec = new cv.MatVector();
hsvVec.push_back(hsv);
const FPS = 30;
function processVideo() {
try {
if (!streaming) {
// 停止时清理内存
frame.delete(); dst.delete(); hsvVec.delete(); roiHist.delete(); hsv.delete();
return;
}
let begin = Date.now();
// 读取新帧,转 HSV,并对目标直方图做反投影
cap.read(frame);
cv.cvtColor(frame, hsv, cv.COLOR_RGBA2RGB);
cv.cvtColor(hsv, hsv, cv.COLOR_RGB2HSV);
cv.calcBackProject(hsvVec, [0], roiHist, dst, [0, 180], 1);
// 应用 meanshift,得到新窗口位置;
// 同时返回收敛迭代次数(本演示中用不上,用逗号忽略)
[, trackWindow] = cv.meanShift(dst, trackWindow, termCrit);
// 把窗口画到图像上
let [x, y, w, h] = [trackWindow.x, trackWindow.y, trackWindow.width, trackWindow.height];
cv.rectangle(frame, new cv.Point(x, y), new cv.Point(x+w, y+h), [255, 0, 0, 255], 2);
cv.imshow('canvasOutput', frame);
// 按 30 FPS 调度下一帧
let delay = 1000/FPS - (Date.now() - begin);
setTimeout(processVideo, delay);
} catch (err) {
utils.printError(err);
}
};
// 启动第一帧调度
setTimeout(processVideo, 0);
几个值得注意的实现细节:
- 目标直方图只建立一次:
roiHist在第一帧的 ROI 上计算(仅 Hue 通道、180 个 bin、带 mask),之后每帧都用它做cv.calcBackProject(),scale 参数取 1。仓库的 C++/Python 示例(samples/cpp/tutorial_code/video/meanshift/camshift.cpp、samples/python/tutorial_code/video/meanshift/camshift.py)也采用同样的inRange+calcHist套路,其中 C++ 示例使用(0,60,32)~(180,255,255)的范围,即直接丢弃低饱和度、低亮度的像素——这正是教程所说“用 inRange 排除低光照干扰值”的典型做法;JS 演示页则使用(30,30,0)~(180,180,180)。 - 初始窗口是硬编码的:
new cv.Rect(150, 60, 63, 125)必须对准目标,这是该类跟踪算法的适用前提——你事先要知道(或能人工指定)目标在第一帧中的位置。 - 终止条件:
TERM_CRITERIA_EPS | TERM_CRITERIA_COUNT, 10, 1表示“最多迭代 10 次,或窗口中心移动量小于 1 像素即收敛”。 - 内存管理:OpenCV.js 运行在 WebAssembly 上,Mat 是堆对象,演示代码在每一段流程结束后调用
.delete()手动释放,停止时统一清理,避免内存泄漏。 - 帧率调度:用
setTimeout与1000/FPS - 已耗时间的方式把处理节奏稳定在 30 FPS 左右。
Camshift:让窗口大小和朝向随目标自适应
仔细看上面 MeanShift 的跟踪结果会发现一个问题:无论目标离镜头远还是近,窗口始终是同一尺寸。这不合理——我们需要让窗口随目标的尺寸和旋转一起自适应。
解决方案就是 CAMshift(Continuously Adaptive Meanshift,连续自适应 Meanshift),由 Gary Bradski 在 1998 年 WACV 论文 "Real time face and object tracking as a component of a perceptual user interface" 中提出(教程原文将年份误写为 1988,文末给出的参考文献实为 1998 年)。
Camshift 的工作方式是:先执行一次 MeanShift;当 MeanShift 收敛后,按公式
更新窗口大小(M00 为区域内像素总和,除以 256 可近似把“概率质量”换算成像素数),同时计算最佳拟合椭圆的朝向;然后以新的缩放后搜索窗口和上一轮的窗口位置再次执行 MeanShift;如此循环,直到达到所需精度。下图展示了窗口随目标大小、朝向连续自适应的跟踪过程:
OpenCV.js 中的 cv.CamShift
调用方式与 MeanShift 几乎相同,区别在于它返回一个旋转矩形(跟踪结果)和 box 参数(作为下一轮的搜索窗口):
[rotatedRect, window] = cv.CamShift(probImage, window, criteria)
| 参数 | 说明 |
|---|---|
probImage |
目标直方图的反投影图,由 cv.calcBackProject 计算得到,单通道 |
window |
初始搜索窗口(cv.Rect),函数返回值中更新为下一轮搜索窗口 |
criteria |
迭代搜索算法的终止条件(cv.TermCriteria) |
| 返回值 | 一个数组:cv.RotatedRect 旋转矩形(即最终跟踪结果)+ 新的搜索窗口 |
浏览器端完整的 CamShift 跟踪示例
演示代码位于 js_camshift.html。其目标直方图建立部分(ROI、inRange 生成 mask、calcHist、normalize、TermCriteria)与 MeanShift 演示完全相同,不再重复;差异集中在每帧处理与结果绘制上:
// ……(ROI 与直方图准备部分与 meanShift 示例相同)……
let hsv = new cv.Mat(video.height, video.width, cv.CV_8UC3);
let hsvVec = new cv.MatVector();
hsvVec.push_back(hsv);
let dst = new cv.Mat();
let trackBox = null;
const FPS = 30;
function processVideo() {
try {
if (!streaming) {
frame.delete(); dst.delete(); hsvVec.delete(); roiHist.delete(); hsv.delete();
return;
}
let begin = Date.now();
cap.read(frame);
cv.cvtColor(frame, hsv, cv.COLOR_RGBA2RGB);
cv.cvtColor(hsv, hsv, cv.COLOR_RGB2HSV);
cv.calcBackProject(hsvVec, [0], roiHist, dst, [0, 180], 1);
// 应用 camshift,得到旋转矩形与新搜索窗口
[trackBox, trackWindow] = cv.CamShift(dst, trackWindow, termCrit);
// 取旋转矩形的 4 个顶点并逐段画线
let pts = cv.rotatedRectPoints(trackBox);
cv.line(frame, pts[0], pts[1], [255, 0, 0, 255], 3);
cv.line(frame, pts[1], pts[2], [255, 0, 0, 255], 3);
cv.line(frame, pts[2], pts[3], [255, 0, 0, 255], 3);
cv.line(frame, pts[3], pts[0], [255, 0, 0, 255], 3);
cv.imshow('canvasOutput', frame);
let delay = 1000/FPS - (Date.now() - begin);
setTimeout(processVideo, delay);
} catch (err) {
utils.printError(err);
}
};
setTimeout(processVideo, 0);
与 MeanShift 版本相比只有两处本质不同:
- 调用
cv.CamShift()代替cv.meanShift(),返回的trackBox是cv.RotatedRect,trackWindow则是为下一帧准备的新搜索窗口; - 绘制时用
cv.rotatedRectPoints(trackBox)取出旋转矩形的 4 个顶点,用 4 条cv.line()连成四边形,因此画面中能看到框随目标倾斜、缩放。
源码级印证:modules/video 中的实现细节
OpenCV.js 的 cv.meanShift / cv.CamShift 最终绑定到 C++ 核心实现 modules/video/src/camshift.cpp(JS 绑定由 modules/js/src/core_bindings.cpp 等生成),读懂它能帮你准确把握 API 参数在底层的确切语义。
cv::meanShift:质心迭代与收敛判定
cv::meanShift(文件第 44~107 行)的核心流程:
- 输入校验:
CV_Assert(cn == 1)强制probImage为单通道,且窗口宽高必须为正,否则抛出StsBadArg;随后把窗口裁剪到图像边界内。 - 终止条件解析:
eps = cvRound(epsilon * epsilon)——注意实现里把 epsilon 平方了,因为它要和位移距离的平方dx*dx + dy*dy直接比较;niters取criteria.maxCount,未指定 MAX_ITER 时默认 100 次。 - 迭代主体:每轮先裁剪
cur_rect到图像范围(若窗口越界消失则回落到图像中心),然后计算该区域内概率图像的moments(mat(cur_rect));若总质量m.m00接近 0(区域内没有匹配像素)直接退出;质心偏移量为dx = m10/m00 - width/2,dy = m01/m00 - height/2,即质心相对窗口几何中心的偏差;新位置被夹在[0, size - rect]内,避免越界;当dx*dx + dy*dy < eps时判定收敛。 - 返回值:返回迭代次数
i,同时把最终的cur_rect写回window——这解释了 JS 侧为何得到“迭代次数 + 新窗口”的元组。
cv::CamShift:扩张搜索、中心矩与椭圆拟合
cv::CamShift(文件第 110~218 行)的完整逻辑:
- 先调用
meanShift让窗口收敛到密度峰值; - 扩张窗口:以常量
TOLERANCE = 10像素向四周外扩(并裁剪到图像边界),为尺寸估计留出余量; - 计算矩:对扩张后的窗口再取一次
moments,得到零阶矩m00、一阶矩m10/m01和二阶中心矩mu11/mu20/mu02;若m00接近 0,返回空RotatedRect; - 求质心与椭圆轴:
xc = m10/m00 + window.x,yc = m01/m00 + window.y;由归一化二阶中心矩a = mu20/m00、b = mu11/m00、c = mu02/m00计算square = sqrt(4b^2 + (a-c)^2)、朝向theta = atan2(2b, a - c + square),再求出沿两个主轴投影的展布量length = sqrt(rotate_a/m00) * 4、width = sqrt(rotate_c/m00) * 4(若length < width则交换并修正角度)。可以看到:论文中s = 2*sqrt(M00/256)用零阶矩估计尺寸,而当前实现改从二阶中心矩解出椭圆两轴与朝向,是更精细的等价推广;“×4”的尺度因子对应把主轴展布放大为椭圆长轴; - 更新下一轮搜索窗口:由椭圆的
xc/yc与投影半长推出一轴对齐的外接框,写回window供下一次调用; - 组装结果:
RotatedRect的宽高为length/width,角度经(PI/2 + theta)换算到[0, 360)再折叠到[0, 180)区间,中心取窗口中心。
这也解释了 CamShift 为什么“每帧都能自适应”:它返回的 window 本身就是按当前目标尺寸、朝向重新估计过的搜索框,而旋转矩形直接刻画了目标的方向——这两点都是 MeanShift 的固定矩形窗口做不到的。仓库内 modules/video/test/test_camshift.cpp(C++)与 modules/python/test/test_camshift.py(Python 绑定)提供了对这一行为的回归测试,可作为 API 语义的补充佐证。
实战要点与适用前提
- 颜色空间:两个 API 都只对 Hue 通道建直方图(演示与 C++/Python 示例一致),对目标颜色在画面中具有区分度;若背景存在大量同色像素,反投影密度图会有多个峰,跟踪可能跳变。
- mask 的作用:
cv.inRange()生成的 mask 同时参与 ROI 直方图统计与逐帧反投影的过滤(示例中calcBackProject前未再传 mask,但直方图本身已按 mask 计算),目的是抑制低饱和、低光照像素带来的虚假密度。 - 单通道约束:
probImage必须是单通道反投影图,底层会直接断言;窗口越界、宽高非法都会报错,calcBackProject的hsvVec通道索引要写成[0]。 - 终止条件:
TermCriteria(TERM_CRITERIA_EPS | TERM_CRITERIA_COUNT, 10, 1)是官方示例的默认配置;底层注意 epsilon 会被平方后与位移平方比较,且未指定最大迭代数时 C++ 侧默认 100 次。 - 运行环境:JS 演示依赖 OpenCV.js(WebAssembly 构建)、浏览器
<video>元素与cv.VideoCapture(video);代码通过utils.executeCode从可编辑的<textarea>中执行,方便直接在页面上改参试验。 - 方法边界:MeanShift/CamShift 是颜色驱动的“相关滤波”类轻量跟踪器,适合目标颜色稳定、运动速度不太快、初始位置可给定的场景;遮挡、剧烈形变或颜色相近的干扰物超出其能力范围,需要更强的跟踪器时,可转向仓库
modules/video中的其他跟踪能力(如基于光流的calcOpticalFlowPyrLK,见 js_lucas_kanade.markdown 与 js_table_of_contents_video.markdown 中的系列教程)。
延伸阅读
- 教程原文: js_meanshift.markdown
- 交互式演示页: js_meanshift.html、js_camshift.html
- 核心实现: modules/video/src/camshift.cpp(
cv::meanShiftL44-L107、cv::CamShiftL110-L218) - C++ 教程示例: meanshift.cpp、camshift.cpp
- Python 教程示例: meanshift.py、camshift.py
- 回归测试: modules/video/test/test_camshift.cpp
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

