OpenCV.js 视频采集与显示实战:WebRTC getUserMedia、Canvas 逐帧读取与 cv.VideoCapture 完整流程
本文基于 OpenCV 仓库的官方教程 js_video_display 展开,讲解如何在浏览器中通过 WebRTC 捕获摄像头实时视频流,将画面逐帧转入 cv.Mat 并用 OpenCV.js 处理显示。读完后你将掌握:navigator.mediaDevices.getUserMedia 获取媒体流、Canvas 2D API 逐帧取像素、cv.VideoCapture.read() 的底层实现与参数约束,以及用 setTimeout 控制帧率(30fps 延迟补偿)的完整可运行方案。
目标与总体思路
教程的目标很明确:从摄像头(内置或 USB)捕获视频并显示。具体示例是把摄像头画面转成灰度视频再显示。
OpenCV.js 本身运行在浏览器里,不能直接访问硬件设备。因此它采用的方案是:
- 用 WebRTC(
navigator.mediaDevices.getUserMedia)拿到摄像头的实时媒体流; - 用 HTML
<video>元素承载该流并解码; - 用 HTML
<canvas>元素作为 OpenCV.js 与浏览器之间的“帧缓冲”,逐帧把 video 绘制到 canvas,再通过getImageData()读出像素数据填进cv.Mat。
要捕获视频,需要在网页中加入以下 HTML 元素(教程原文列举):
- 一个
<video>元素:直接显示来自摄像头的画面; - 一个
<canvas>元素:把 video 逐帧转移到 canvas 的 ImageData; - 另一个
<canvas>元素:显示 OpenCV.js 处理后的输出。
第一步:用 WebRTC 获取媒体流
先用 navigator.mediaDevices.getUserMedia 获取媒体流,并把它挂到 <video> 元素上播放:
let video = document.getElementById("videoInput"); // video is the id of video tag
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(function(stream) {
video.srcObject = stream;
video.play();
})
.catch(function(err) {
console.log("An error occurred! " + err);
});
仓库中的示例工具类 Utils.startCamera 对这段逻辑做了工程化封装:除了 getUserMedia 本身,它还支持按分辨率约束请求(qvga 为 320×240、vga 为 640×480),通过 canplay 事件触发回调,并提供 stopCamera() 正确释放 stream 的 video track(video.pause()、video.srcObject = null、stream.getVideoTracks()[0].stop())。停止采集时正确释放媒体流是生产代码中容易遗漏的一点,可直接参考 utils.js 中的 stopCamera 实现。
注意:当你播放的是视频文件而不是摄像头时,这一步是不必要的——把
<video>的src指向视频文件即可。但要留意:HTML video 元素只支持 Ogg (Theora)、WebM (VP8/VP9) 或 MP4 (H.264) 这几种封装/编码格式,其他格式在浏览器中无法直接播放。
第二步:Canvas 逐帧读取与处理
浏览器拿到摄像头流之后,用 Canvas 2D API 的 CanvasRenderingContext2D.drawImage() 把视频绘制到 canvas 上,再用 图像显示教程 中介绍的方法读取 canvas 并显示。对于视频播放,cv.imshow() 需要每隔 delay 毫秒执行一次,教程推荐用 setTimeout() 调度。若视频是 30fps,延迟应取 1000/30 - 处理耗时:
let canvasFrame = document.getElementById("canvasFrame"); // canvasFrame is the id of <canvas>
let context = canvasFrame.getContext("2d");
let src = new cv.Mat(height, width, cv.CV_8UC4);
let dst = new cv.Mat(height, width, cv.CV_8UC1);
const FPS = 30;
function processVideo() {
let begin = Date.now();
context.drawImage(video, 0, 0, width, height);
src.data.set(context.getImageData(0, 0, width, height).data);
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY);
cv.imshow("canvasOutput", dst); // canvasOutput is the id of another <canvas>;
// schedule next one.
let delay = 1000/FPS - (Date.now() - begin);
setTimeout(processVideo, delay);
}
// schedule first one.
setTimeout(processVideo, 0);
逐帧管线可以概括为:drawImage(video) → getImageData() → src.data.set(...) → OpenCV 处理 → cv.imshow() → setTimeout 自我调度。帧率控制的关键是 delay = 1000/FPS - (Date.now() - begin):用本帧实际处理耗时长出的时间从帧间隔中扣除,避免处理耗时把有效帧率拉低。
cv.VideoCapture:对上面流程的源码级封装
OpenCV.js 用上述方法实现了 cv.VideoCapture (videoSource)——使用它时无需手动添加隐藏 canvas 元素。
参数说明(继承自原文档):
@param videoSource视频的 id(字符串)或<video>元素本身;@returncv.VideoCapture实例。
read (image) 参数说明:
@param image一张与视频同尺寸、且类型为cv.CV_8UC4的图像(出于性能考虑,图像必须预先以CV_8UC4类型、视频同尺寸构造,而不是每帧新建)。
这个封装的真实实现在 modules/js/src/helpers.js:
Module['VideoCapture'] = function(videoSource) {
var video = null;
if (typeof videoSource === 'string') {
video = document.getElementById(videoSource);
} else {
video = videoSource;
}
if (!(video instanceof HTMLVideoElement)) {
throw new Error('Please input the valid video element or id.');
}
var canvas = document.createElement('canvas'); // 自动创建隐藏 canvas
canvas.width = video.width;
canvas.height = video.height;
var ctx = canvas.getContext('2d');
this.video = video;
this.read = function(frame) {
if (!(frame instanceof cv.Mat)) {
throw new Error('Please input the valid cv.Mat instance.');
}
if (frame.type() !== cv.CV_8UC4) {
throw new Error('Bad type of input mat: the type should be cv.CV_8UC4.');
}
if (frame.cols !== video.width || frame.rows !== video.height) {
throw new Error('Bad size of input mat: the size should be same as the video.');
}
ctx.drawImage(video, 0, 0, video.width, video.height);
frame.data.set(ctx.getImageData(0, 0, video.width, video.height).data);
};
};
从源码结构看,cv.VideoCapture 内部自动 document.createElement('canvas') 并同步其尺寸,这正是教程所说“无需手动添加隐藏 canvas”的原因。read() 中有三个硬性校验,写代码前值得记住:
- 传入的
frame必须是cv.Mat实例; frame.type()必须是cv.CV_8UC4(RGBA,与 canvas ImageData 的像素布局一致);frame.cols/rows必须分别等于video.width/video.height,否则抛异常。
read() 的最后两行 ctx.drawImage(...) + frame.data.set(ctx.getImageData(...).data) 与手工方案中的逐帧读取完全等价——封装只是把“隐藏 canvas + drawImage + getImageData”这一步藏进了对象方法里。
简化后的播放代码
有了 cv.VideoCapture,上面手写的播放代码可以简化为:
let src = new cv.Mat(height, width, cv.CV_8UC4);
let dst = new cv.Mat(height, width, cv.CV_8UC1);
let cap = new cv.VideoCapture(videoSource);
const FPS = 30;
function processVideo() {
let begin = Date.now();
cap.read(src);
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY);
cv.imshow("canvasOutput", dst);
// schedule next one.
let delay = 1000/FPS - (Date.now() - begin);
setTimeout(processVideo, delay);
}
// schedule first one.
setTimeout(processVideo, 0);
注意:停止时记得 delete 掉 src 和 dst。 OpenCV.js 的 Mat 数据分配在 WASM 堆上,不做 delete() 会造成内存泄漏;长时间运行的页面应像仓库示例一样在停止采集时成对释放(见下文完整示例)。
完整可运行示例:Start/Stop 摄像头灰度显示
仓库提供了与该教程配套的交互示例页面 js_video_display.html。页面包含一个 Start/Stop 按钮、一个 <video id="videoInput">、一个输出 <canvas id="canvasOutput"> 和一个可编辑的代码区;点击 Start 时通过 utils.startCamera('qvga', ...) 以 320×240 分辨率请求摄像头,其内嵌的处理代码是:
let video = document.getElementById('videoInput');
let src = new cv.Mat(video.height, video.width, cv.CV_8UC4);
let dst = new cv.Mat(video.height, video.width, cv.CV_8UC1);
let cap = new cv.VideoCapture(video);
const FPS = 30;
function processVideo() {
try {
if (!streaming) {
// clean and stop.
src.delete();
dst.delete();
return;
}
let begin = Date.now();
// start processing.
cap.read(src);
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY);
cv.imshow('canvasOutput', dst);
// schedule the next one.
let delay = 1000/FPS - (Date.now() - begin);
setTimeout(processVideo, delay);
} catch (err) {
utils.printError(err);
}
};
// schedule the first one.
setTimeout(processVideo, 0);
页面侧的配套逻辑(同样来自 js_video_display.html):
startAndStop.addEventListener('click', () => {
if (!streaming) {
utils.startCamera('qvga', onVideoStarted, 'videoInput');
} else {
utils.stopCamera();
onVideoStopped();
}
});
function onVideoStarted() {
streaming = true;
videoInput.width = videoInput.videoWidth; // 用实际视频尺寸更新 HTML 属性
videoInput.height = videoInput.videoHeight;
utils.executeCode('codeEditor'); // 启动 processVideo 循环
}
这个示例演示了三件教程正文之外但实战必须处理的事:
- 尺寸同步:
startCamera启动后,video.videoWidth/videoHeight才是摄像头实际给出的帧尺寸,需回填到 HTML 属性上;由于cv.Mat的宽高等于video.width/height,而cap.read(src)要求 Mat 尺寸与 video 完全一致,尺寸必须对齐; - 停止即清理:
streaming标志翻转为 false 后,下一轮processVideo执行src.delete(); dst.delete();并返回,不再自我调度; - 错误兜底:
try/catch捕获 WASM 异常并通过utils.printError展示(该函数会借助cv.exceptionFromPtr还原 C++ 侧异常信息,见 utils.js)。
cv.imshow 的另一半:Mat 到 canvas 的转换
cv.imshow("canvasOutput", dst) 的输出端实现在 modules/js/src/helpers.js:它先把 dst 用 convertTo 归一化为 CV_8U,再按通道数转成 CV_8UC4(GRAY2RGBA 或 RGB2RGBA;只接受 1、3、4 通道,否则抛 Bad number of channels 异常),然后构造 ImageData 并通过 ctx.putImageData(imgData, 0, 0) 写回 canvas。这意味着本教程灰度化之后的 CV_8UC1 结果能被 imshow 直接显示,但通道数异常的 Mat 不会静默成功,这一点在调试“画面不更新”类问题时值得对照检查。
适用前提与限制小结
- 摄像头采集依赖 WebRTC
getUserMedia,运行页面须满足浏览器安全上下文(HTTPS 或本地回环)并弹出权限授权; - 播放视频文件时跳过
getUserMedia,但<video>只支持 Ogg (Theora)、WebM (VP8/VP9)、MP4 (H.264) 格式; cv.VideoCapture.read()的入参必须是与视频同尺寸的CV_8UC4Mat,建议复用同一块 Mat 而不是每帧新建,以获得稳定的性能表现;- 帧率控制遵循
delay = 1000/FPS - 处理耗时的补偿公式,FPS按需调整(示例取 30); - 停止采集时务必
delete()所有cv.Mat并释放媒体流,避免 WASM 堆内存泄漏与摄像头占用。
相关延伸阅读:图像显示教程(cv.imshow 与 canvas 读取的基础方法)、js_video_display 源码示例、OpenCV.js 绑定辅助实现 与 示例工具库 Utils。
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 StartedRust0623
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