OpenCV.js dnn 图像分类实战:基于摄像头的实时分类推理完整指南
【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv
本文基于 OpenCV 仓库中的 JavaScript 教程文档 js_image_classification_with_camera.markdown,讲解如何用 OpenCV.js 的dnn模块实现“摄像头实时画面 + 深度学习模型”的图像分类应用。通过本文,读者将掌握从摄像头采集视频帧、cv.blobFromImage前处理、cv.readNet/net.forward()模型推理,到 softmax 后处理与 Top-K 结果展示的全链路实现,并可复用到任意 ONNX/TensorFlow 分类模型。
教程目标与配套示例
原教程文档给出的目标非常明确:
学习如何使用 OpenCV.js 的 dnn 模块,完成一个带摄像头的图像分类示例。
教程配套的可交互示例页面是 js_image_classification_with_camera.html,它由以下部分组成:
- 一个
Start/Stop按钮,控制摄像头采集的开始与停止; - 一个
<video id="videoInput">元素(400×400)用于显示摄像头原始画面; - 一个结果表格,展示模型输出的 Top-3 标签(Label)与概率(Probability),以及本次推理耗时;
- 一个
modelFile文件上传控件,用于把本地 ONNX 模型文件写入 Emscripten 虚拟文件系统; - 六个
textarea.code代码编辑区(codeEditor~codeEditor5),分别展示并在线可执行六段核心代码,读者可以直接在浏览器里修改参数并实时观察效果; - 一个 Model Info 附录区域,由 js_dnn_example_helper.js 中的
drawInfoTable()从 js_image_classification_model_info.json 动态渲染,列出各模型对应的下载链接与推理参数。
原文档还特别提示:如果不了解如何从摄像头采集视频,应先阅读 js_video_display.markdown(视频采集入门教程)。
模型推理参数:决定“能否换模型”的关键配置
示例第一段代码(codeSnippet)定义了与具体模型强相关的推理参数,这是把示例适配到新模型时唯一必须修改的配置区:
inputSize = [224, 224]; mean = [104, 117, 123]; std = 1; swapRB = false; // record if need softmax function for post-processing needSoftmax = false; // url for label file, can from local or Internet labelsUrl = "见模型信息 JSON 中的 labelsUrl 字段";各参数含义与来源:
| 参数 | 含义 | 说明 |
|---|---|---|
inputSize | 网络输入尺寸[宽, 高] | 传给cv.blobFromImage,必须与模型实际输入匹配(如 224×224) |
mean | 均值向量 | blobFromImage中对每个通道减去的均值,归一化方式取决于模型训练框架 |
std | 标准差 | 在示例的getBlobFromImage中作为scale传入,即每个像素除以1/std |
swapRB | 是否交换 R/B 通道 | BGR 训练框架(Caffe/CV)模型通常为true,RGB 框架(PyTorch)为false |
needSoftmax | 输出是否需要补 softmax | 若模型末尾已带 softmax 层(如 TensorFlow Inception 图)则为false |
labelsUrl | 类别标签 txt 地址 | 每行一个标签,可来自本地或网络,loadLables()会fetch并按换行切分 |
仓库中 js_image_classification_model_info.json 给出了 5 个 ONNX 分类模型与 1 个 TensorFlow 模型的完整参数对照表,是调参时最直接的参考。整理如下(模型下载链接以 JSON 文件中的实际 URL 为准):
| 框架 | 模型 | inputSize | mean | std | scale | swapRB | needSoftmax |
|---|---|---|---|---|---|---|---|
| onnx | googlenet | 224, 224 | 103.939, 116.779, 123.675 | 1, 1, 1 | 1 | false | true |
| onnx | squeezenet | 224, 224 | 0.485, 0.456, 0.406 | 0.229, 0.224, 0.225 | 0.003921 | true | true |
| onnx | resnet | 224, 224 | 123.675, 116.28, 103.53 | 58.395, 57.12, 57.375 | 1 | true | true |
| onnx | vgg16 | 224, 224 | 103.939, 116.779, 123.68 | 1, 1, 1 | 1 | false | true |
| onnx | densenet121 | 224, 224 | 123.675, 116.28, 103.53 | 0.229, 0.224, 0.225 | 0.003921 | true | true |
| tensorflow | inception | 224, 224 | 123, 117, 104 | 1 | 1 | true | false |
从这张表可以直观看到:不同训练框架(Caffe 风格 mean 约 104/117/123,PyTorch 风格 mean 0.485…、scale 0.003921)的归一化参数差异很大,needSoftmax也随模型末层结构而变化——这正是“换模型时只改第一段代码”的必要性所在。
核心代码一:视频捕获主循环与每帧推理
示例第二段代码(codeSnippet1)是整个应用的骨架,包含三块:帧缓冲与cv.VideoCapture的创建、每帧推理入口main()、以及驱动主循环的processVideo()。
let frame = new cv.Mat(video.height, video.width, cv.CV_8UC4); let cap = new cv.VideoCapture(video); main = async function(frame) { const labels = await loadLables(labelsUrl); const input = getBlobFromImage(inputSize, mean, std, swapRB, frame); let net = cv.readNet(modelPath); net.setInput(input); const start = performance.now(); const result = net.forward(); const time = performance.now()-start; const probs = softmax(result); const classes = getTopClasses(probs, labels); updateResult(classes, time); setTimeout(processVideo, 0); input.delete(); net.delete(); result.delete(); } function processVideo() { try { if (!streaming) { return; } cap.read(frame); main(frame); } catch (err) { utils.printError(err); } } setTimeout(processVideo, 0);从源码结构看,这段循环有几个值得注意的设计:
frame必须是CV_8UC4且尺寸与视频一致。这是 js_video_display.markdown 中明确给出的性能约束:cv.VideoCapture.read()会把<video>经 canvas 读出的 RGBA 像素直接写入该Mat的data,避免中途转换。setTimeout(processVideo, 0)的协作式调度。每帧推理结束后(main是 async 函数)再排队下一帧,天然形成“上一帧完成才读下一帧”的节奏,避免帧堆积;这与视频入门教程推荐的setTimeout定时驱动方式一致(该教程建议 30fps 视频使用delay = 1000/FPS - 处理耗时,这里用 0 表示“尽快处理,由推理耗时自然限速”)。- 每帧都执行
cv.readNet(modelPath)并net.delete()。模型已被loadModel()写入 Emscripten 文件系统(路径modelPath),这里逐帧重新加载便于演示单帧完整链路;在追求帧率的生产代码中,把readNet提到主循环外只加载一次是常见优化(源码中net.delete()的写法说明这是刻意配合逐帧加载的资源管理)。 - Emscripten 内存必须手动
delete()。input、net、result三处显式释放,这是 OpenCV.js 中所有 JS 侧持有的Mat/Net对象的通用要求。
核心代码二:前处理、标签加载与模型写入文件系统
以下三段工具函数定义在公共助手文件 js_dnn_example_helper.js 中,被本示例和同目录的其他 dnn 示例(物体检测、姿态估计、风格迁移等)共用。
1. 标签加载loadLables
loadLables = async function(labelsUrl) { let response = await fetch(labelsUrl); let label = await response.text(); label = label.split('\n'); return label; }fetch一个每行一个类别名的 txt 文件(分类模型常用 ILSVRC2012/ImageNet 标签表,地址见 js_image_classification_model_info.json 中的labelsUrl字段),按换行切分为数组,下标与网络输出通道一一对应。
2. 图像转 BlobgetBlobFromImage
getBlobFromImage = function(inputSize, mean, std, swapRB, image) { let mat; if (typeof(image) === 'string') { mat = cv.imread(image); } else { mat = image; } let matC3 = new cv.Mat(mat.matSize[0], mat.matSize[1], cv.CV_8UC3); cv.cvtColor(mat, matC3, cv.COLOR_RGBA2BGR); let input = cv.blobFromImage(matC3, std, new cv.Size(inputSize[0], inputSize[1]), new cv.Scalar(mean[0], mean[1], mean[2]), swapRB); matC3.delete(); return input; }这个函数把“摄像头帧/本地图片 → 模型输入”的标准前处理压缩为三步:
- 通道准备:输入是
CV_8UC3则直接使用,是路径字符串则cv.imread,否则(如CV_8UC4视频帧)先用cv.cvtColor转成CV_8UC3的 BGR; - 缩放 + 归一化:
cv.blobFromImage(src, scale, size, mean, swapRB)一步完成缩放到inputSize、减去mean、乘以scale,并按swapRB决定是否 R/B 互换; - 资源回收:中间量
matC3用完即delete()。
注意示例中把第一段的std直接当作scale传入,因此模型信息表里的scale列(如 squeezenet 的 0.003921,即 1/255)实际承担了“像素值缩放到 0~1”的职责,std列则是blobFromImage语义下的归一化参数——理解这层映射关系是正确移植其他模型的前提。
3. 模型文件写入虚拟文件系统loadModel
loadModel = async function(e) => { return new Promise((resolve) => { let file = e.target.files[0]; let path = file.name; let reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = function(ev) { if (reader.readyState === 2) { let buffer = reader.result; let data = new Uint8Array(buffer); cv.FS_createDataFile('/', path, data, true, false, false); resolve(path); } } }); }浏览器无法直接让 WASM 版 OpenCV 读取本地文件,这里的方案是:FileReader把用户通过modelFile控件选中的 ONNX 文件读成ArrayBuffer,再用 Emscripten 的cv.FS_createDataFile()写入虚拟文件系统的根目录(路径即原文件名)。示例页面在change事件回调中调用它并保存返回值:
let modelPath = ""; let modelFile = document.getElementById('modelFile'); modelFile.addEventListener('change', async (e) => { initStatus(); modelPath = await loadModel(e); document.getElementById('status').innerHTML = `The model file '${modelPath}' is created successfully.`; });此后cv.readNet(modelPath)就能按普通文件路径加载模型。这也是 Web 端加载模型最通用的模式:模型以文件形式(ONNX、TensorFlow protobuf)预先存在,运行时落入虚拟文件系统,由dnn读取。
核心代码三:后处理——softmax 与 Top-K 提取
最后一组代码(codeSnippet5及助手中的getTopClasses)负责把网络输出的原始 logits/prob 向量变成人类可读的标签与置信度。
softmax = function(result) { let arr = result.data32F; if (needSoftmax) { const maxNum = Math.max(...arr); const expSum = arr.map((num) => Math.exp(num - maxNum)).reduce((a, b) => a + b); return arr.map((value, index) => { return Math.exp(value - maxNum) / expSum; }); } else { return arr; } }result.data32F直接从输出Mat取出 32 位浮点数组;只有当needSoftmax === true(模型末层没有 softmax)时才做数值稳定的 softmax(先减maxNum防止exp上溢)。若模型输出已是概率(如 TensorFlow Inception 图,needSoftmax为false),则原样返回。
getTopClasses = function(probs, labels, topK = 3) { probs = Array.from(probs); let indexes = probs.map((prob, index) => [prob, index]); let sorted = indexes.sort((a, b) => { if (a[0] === b[0]) {return 0;} return a[0] < b[0] ? -1 : 1; }); sorted.reverse(); let classes = []; for (let i = 0; i < topK; ++i) { let prob = sorted[i][0]; let index = sorted[i][1]; let c = { label: labels[index], prob: (prob * 100).toFixed(2) } classes.push(c); } return classes; }getTopClasses把概率数组转为[prob, index]对,降序排序后取前topK(默认 3)个,返回{label, prob}对象数组;prob已被换算为百分比并保留两位小数。页面端的updateResult()再把它们填进结果表格的label0/1/2与prob0/1/2单元格,并在状态栏显示模型路径与Inference time: xx.xx ms(即performance.now()前后差值)。
摄像头捕获与运行流程
示例的运行入口由页面脚本控制,关键流程是:
let video = document.getElementById('videoInput'); let streaming = false; let startAndStop = document.getElementById('startAndStop'); startAndStop.addEventListener('click', () => { if (!streaming) { utils.clearError(); utils.startCamera('qvga', onVideoStarted, 'videoInput'); } else { utils.stopCamera(); onVideoStopped(); } });- 点击 Start 时调用
utils.startCamera('qvga', ...)(实现见 utils.js),底层即 js_video_display.markdown 讲解的navigator.mediaDevices.getUserMedia({ video: true, audio: false }):浏览器通过 WebRTC 拿到摄像头媒体流赋给<video>元素,请求分辨率档位为 QVGA(320×240)——对 224×224 的输入网络而言,小分辨率采集能显著降低每帧前处理与传输开销; onVideoStarted()把streaming置为true,并执行codeEditor、codeEditor1两段代码,创建cv.Mat/cv.VideoCapture并启动processVideo()循环;- 点击 Stop 时
utils.stopCamera()释放摄像头,onVideoStopped()复位状态。
因此完整的运行时序为:上传模型 → 写入虚拟文件系统 → 点击 Start 打开摄像头 →cap.read(frame)逐帧读取 RGBA 帧 →blobFromImage前处理 →readNet+forward()推理 → softmax + Top-K → 表格刷新与耗时显示 →setTimeout(processVideo, 0)进入下一帧。
需要注意的环境前提:
getUserMedia在浏览器中通常要求安全上下文(https://或localhost),本地纯file://方式打开页面时摄像头接口可能不可用;- OpenCV.js 需先由
utils.loadOpenCv()加载完成(示例在回调后才启用 Start 按钮),加载来源配置见 js_setup.markdown 与 js_usage.markdown; labelsUrl使用fetch跨域获取时,目标地址需允许匿名访问(仓库示例使用 OpenCV 官方数据目录中的 ImageNet 标签表)。
小结与可复用要点
- 本教程文档(js_image_classification_with_camera.markdown)是 OpenCV.js dnn 系列教程中“实时视频版”的图像分类篇,其完整可运行形态由 js_image_classification_with_camera.html、js_dnn_example_helper.js 和 js_image_classification_model_info.json 三者共同构成;
- 换一个分类模型只需改一处:第一段的
inputSize / mean / std / swapRB / needSoftmax / labelsUrl,参数对照直接查模型信息 JSON; - Web 端加载模型的标准姿势是
FileReader+cv.FS_createDataFile()写入 Emscripten 虚拟文件系统,再cv.readNet(path); - 视频帧必须以
CV_8UC4、与视频等尺寸的Mat供cv.VideoCapture.read()写入;所有 JS 侧创建的Mat/Net用完都要delete(); - 主循环用
setTimeout(processVideo, 0)协作式调度,推理耗时可用performance.now()精确测量并展示,便于评估模型在浏览器端的实际吞吐。
【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考