首页
/ OpenCV.js 视频目标跟踪实战:Meanshift 与 Camshift 的原理、API 与浏览器端完整实现

OpenCV.js 视频目标跟踪实战:Meanshift 与 Camshift 的原理、API 与浏览器端完整实现

2026-09-06 14:08:42作者:殷蕙予

本篇指南基于 OpenCV 官方 JS 教程,系统讲解 Meanshift 与 Camshift 两种基于颜色直方图反投影的目标跟踪算法:先建立“窗口质心迭代”的几何直觉,再完整给出可直接在浏览器中运行的 OpenCV.js 实现代码,最后结合 modules/video 模块源码剖析 cv::meanShiftcv::CamShift 的收敛逻辑、终止条件与椭圆拟合细节。读完后,你将能在 WebAssembly 环境下复现固定窗口的 Meanshift 跟踪与自适应旋转窗口的 Camshift 跟踪,并理解两者参数在 C++ 底层实现中的真实含义。

MeanShift 原理示意:窗口质心迭代收敛到密度最大区域

Meanshift 的核心思想:让窗口“滑向”密度峰值

Meanshift 的直觉非常简单。假设你有一组点(例如由直方图反投影得到的像素分布图),再给你一个很小的窗口(可以是一个圆形区域)。你的任务是把窗口移动到像素密度最大(或点数最多)的区域。

以原理图为例:初始窗口用蓝色圆圈表示,记为 "C1",它的原始圆心用蓝色小方块 "C1_o" 标出。如果你计算窗口内部所有点的质心,会得到 "C1_r"(用蓝色小圆标出),它才是窗口的真正质心,显然与原始圆心不重合。于是把窗口平移到质心位置,重新计算新质心;两者大概率仍不重合,继续平移、继续迭代,直到“窗口中心与质心落在同一位置(或误差不超过期望值)”为止。最终得到的就是覆盖了最大像素分布的窗口,图中记为 "C2" 的绿色圆圈——可以看到它包含了最多的点。仓库中另有对静态图像执行该过程的动画 meanshift_face.gif 可供参考。

把这个过程搬到视频跟踪中:我们传入的是目标直方图的反投影图目标的初始位置。当目标在画面中移动时,这种移动会直接反映在反投影图的密度分布上,Meanshift 算法就会把窗口移动到新的密度最大处,从而实现对目标的连续跟踪。

OpenCV.js 中的 cv.meanShift

在 OpenCV.js 中使用 MeanShift,流程分为两步准备:

  1. 设定目标区域,计算它的颜色直方图,以便在每一帧中对目标做直方图反投影;
  2. 提供搜索窗口的初始位置。

这里只使用 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.cppsamples/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() 手动释放,停止时统一清理,避免内存泄漏。
  • 帧率调度:用 setTimeout1000/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 收敛后,按公式

s=2×M00256s = 2 \times \sqrt{\frac{M_{00}}{256}}

更新窗口大小(M00 为区域内像素总和,除以 256 可近似把“概率质量”换算成像素数),同时计算最佳拟合椭圆的朝向;然后以新的缩放后搜索窗口和上一轮的窗口位置再次执行 MeanShift;如此循环,直到达到所需精度。下图展示了窗口随目标大小、朝向连续自适应的跟踪过程:

CamShift 跟踪动画:窗口尺寸与朝向随目标连续自适应

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、calcHistnormalizeTermCriteria)与 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 版本相比只有两处本质不同:

  1. 调用 cv.CamShift() 代替 cv.meanShift(),返回的 trackBoxcv.RotatedRect,trackWindow 则是为下一帧准备的新搜索窗口;
  2. 绘制时用 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 直接比较;niterscriteria.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 行)的完整逻辑:

  1. 先调用 meanShift 让窗口收敛到密度峰值;
  2. 扩张窗口:以常量 TOLERANCE = 10 像素向四周外扩(并裁剪到图像边界),为尺寸估计留出余量;
  3. 计算矩:对扩张后的窗口再取一次 moments,得到零阶矩 m00、一阶矩 m10/m01 和二阶中心矩 mu11/mu20/mu02;若 m00 接近 0,返回空 RotatedRect;
  4. 求质心与椭圆轴:xc = m10/m00 + window.x,yc = m01/m00 + window.y;由归一化二阶中心矩 a = mu20/m00b = mu11/m00c = mu02/m00 计算 square = sqrt(4b^2 + (a-c)^2)、朝向 theta = atan2(2b, a - c + square),再求出沿两个主轴投影的展布量 length = sqrt(rotate_a/m00) * 4width = sqrt(rotate_c/m00) * 4(若 length < width 则交换并修正角度)。可以看到:论文中 s = 2*sqrt(M00/256) 用零阶矩估计尺寸,而当前实现改从二阶中心矩解出椭圆两轴与朝向,是更精细的等价推广;“×4”的尺度因子对应把主轴展布放大为椭圆长轴;
  5. 更新下一轮搜索窗口:由椭圆的 xc/yc 与投影半长推出一轴对齐的外接框,写回 window 供下一次调用;
  6. 组装结果: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 必须是单通道反投影图,底层会直接断言;窗口越界、宽高非法都会报错,calcBackProjecthsvVec 通道索引要写成 [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.markdownjs_table_of_contents_video.markdown 中的系列教程)。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐