OpenCV.js 图像分类实战指南:基于 dnn 模块从 ONNX 模型加载到 Top-K 结果输出
本文围绕 OpenCV.js(OpenCV 的 WebAssembly 构建)的 dnn 模块,完整讲解如何在浏览器中实现图像分类流水线:从页面骨架与交互控件、推理参数配置、模型文件写入 Emscripten 虚拟文件系统,到 blobFromImage 预处理、readNet/forward 推理和 softmax + Top-K 后处理。读完本文,你可以直接复用仓库内置的交互示例(js_image_classification.html)跑通任意 ONNX/TensorFlow 分类模型,并理解每个环节背后的源码实现。
一、目标与运行环境
该教程的目标(见 js_image_classification.markdown)是:学习如何使用 OpenCV.js 的 dnn 模块完成图像分类。教程配套一个可交互的 HTML 示例页面,其基本操作路径为:
- 点击 modelFile 按钮上传一个 ONNX(或 TensorFlow)模型文件;
- 根据所上传的模型,修改第一段代码片段(推理参数);
- 点击 Try it 按钮执行一次推理,输入图片也可以换成其他图像;
- 在右侧表格中查看 Top-3 分类标签与概率,状态栏显示模型路径与推理耗时(毫秒)。
1.1 获取并加载 opencv.js
OpenCV.js 的获取与引入方式在 js_usage.markdown 中有完整说明,核心要点:
- 预构建的
opencv.js可以从每个 release 的opencv-{VERSION_NUMBER}-docs.zip中获得,也可以从在线文档站点直接下载对应版本的opencv.js(想要最新构建时选5.x); - 也可以按 js_setup.markdown 使用 Emscripten 自行构建(
emcmake python ./platforms/js/build_js.py build_js)。
示例页面通过 utils.js 中的 Utils.loadOpenCv() 动态注入 <script> 标签加载 opencv.js,并兼容三种就绪状态:同步脚本、cv 为 Promise 类型(需 await)、以及注册 onRuntimeInitialized 回调。这也是 OpenCV.js 官方推荐写法:
cv = (cv instanceof Promise) ? await cv : cv;
官方教程同时建议使用本地 Web 服务器托管页面,而不是直接以 file:// 方式打开——本例中的标签文件、模型信息 JSON 都通过 fetch 拉取,跨域与同源限制下必须有 HTTP 服务。
二、页面骨架:画布、结果表格与两个文件输入
交互示例的 DOM 结构(js_image_classification.html)由以下部分构成:
<canvas id="canvasInput" width="400" height="400">:输入图像画布,页面加载时会在其中绘制一张默认图片;<input type="file" id="fileInput" accept="image/*">:更换输入图像;<input type="file" id="modelFile">:上传模型文件(核心入口,未上传时点击 Try it 会提示 "Please upload model file by clicking the button first.");<table id="result">:初始visibility: hidden,推理成功后显示三行(label0/prob0至label2/prob2)的 Top-3 标签与概率;<p id="status">:状态栏,显示 "Running function main()..."、模型路径与Inference time: xx.xx ms;- 六个
<textarea class="code">代码编辑器:对应 6 段可编辑的示例代码,Utils.executeCode()会对编辑器内容执行eval,因此读者可以直接在页面上改写参数与逻辑做实验。
页面加载流程:utils.loadOpenCv() 就绪后移除 Try it 按钮的 disabled 属性;drawInfoTable() 拉取模型信息 JSON 并在附录区渲染出各模型的参数表格。
三、推理参数配置:各模型如何设置 mean / std / swapRB / softmax
第一段代码片段(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 = "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt";
各字段含义:
| 参数 | 含义 | 说明 |
|---|---|---|
inputSize |
网络输入尺寸 | 绝大多数 ImageNet 分类网络为 [224, 224],getBlobFromImage 中会以 cv.Size(inputSize[0], inputSize[1]) 传入 |
mean |
均值减除向量 | 注意以 BGR 顺序给出(OpenCV 默认通道序);如 TF inception 的 RGB 均值 123/117/104 对应 BGR 的 104/117/123 |
std |
缩放系数 scalefactor | 在 blobFromImage 调用中该参数位置实际是标量 scalefactor(详见第五节) |
swapRB |
是否交换 R、B 通道 | 用于模型在 RGB 空间训练时的通道对齐 |
needSoftmax |
输出是否需要额外 softmax | 取决于网络输出层是否已带 Softmax |
labelsUrl |
标签文件地址 | 每行一个类别名,支持本地或网络地址 |
默认参数(mean=[104,117,123]、std=1、swapRB=false、needSoftmax=false)正是针对 TensorFlow 版 Inception 图(tensorflow_inception_graph.pb)配置:其输出已是概率,无需再套 softmax。
示例附录区渲染的完整模型参数来自 js_image_classification_model_info.json,各模型下载链接保存在该文件的 modelUrl 字段中。整理如下:
ONNX 模型(均来自 ONNX 模型仓库,均为 224×224 输入):
| 模型 | mean | std | scale | swapRB | needSoftmax | 模型文件 |
|---|---|---|---|---|---|---|
| googlenet | 103.939, 116.779, 123.675 | 1, 1, 1 | 1 | false | true | googlenet-8.onnx |
| squeezenet | 0.485, 0.456, 0.406 | 0.229, 0.224, 0.225 | 0.003921 | true | true | squeezenet1.1-7.onnx |
| resnet (resnet50) | 123.675, 116.28, 103.53 | 58.395, 57.12, 57.375 | 1 | true | true | resnet50-v2-7.onnx |
| vgg16 | 103.939, 116.779, 123.68 | 1, 1, 1 | 1 | false | true | vgg16-bn-7.onnx |
| densenet121 | 123.675, 116.28, 103.53 | 0.229, 0.224, 0.225 | 0.003921 | true | true | densenet-8.onnx |
TensorFlow 模型:
| 模型 | mean | std | scale | swapRB | needSoftmax | 模型文件 |
|---|---|---|---|---|---|---|
| inception | 123, 117, 104 | 1 | 1 | true | false | tensorflow_inception_graph.pb |
从源码结构看,本教程的简化预处理管线 getBlobFromImage 只把 std 作为标量 scalefactor 传入 blobFromImage(见下文),因此表格中带分通道 std/scale 的模型(如 squeezenet、densenet121)若严格按表使用,应把 scale(0.003921)作为该参数传入;而分通道归一化需要更完整的预处理接口。
四、模型加载:把文件写入 Emscripten 虚拟文件系统
浏览器里的 OpenCV.js 运行在 WebAssembly 之上,cv.readNet(path) 读取的是 Emscripten 虚拟文件系统中的路径,而不是浏览器本地磁盘。因此 js_dnn_example_helper.js 提供了 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);
}
}
});
}
流程:<input type=file> 的 change 事件拿到 File 对象 → FileReader 以 ArrayBuffer 读出 → 包成 Uint8Array → 调用 cv.FS_createDataFile('/', 文件名, data, true, false, false) 在虚拟 FS 根目录下创建同名文件 → 返回路径字符串 modelPath。
utils.js 中另有一个 createFileFromUrl() 变体:用 XMLHttpRequest(responseType: 'arraybuffer')从网络下载后走同样的 FS_createDataFile 落盘,适用于模型托管在服务器上的场景。
五、预处理:从 Canvas 到归一化后的 Blob
标签加载很简单——fetch 标签 URL 后按换行切分:
loadLables = async function(labelsUrl) {
let response = await fetch(labelsUrl);
let label = await response.text();
label = label.split('\n');
return label;
}
图像输入则从 Canvas(或图像路径)转换为模型输入张量:
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;
}
要点解析:
- Canvas 读取的像素是 RGBA 四通道,而分类网络要求 3 通道,所以先
cvtColor转成CV_8UC3的 BGR 图; cv.blobFromImage对应 C++ 侧接口,其签名见 dnn.hpp:Mat blobFromImage(InputArray image, double scalefactor=1.0, const Size& size=Size(), const Scalar& mean=Scalar(), bool swapRB=false, ...)。可以看到 JS 调用中第二个实参(教程里命名为std)对应的正是scalefactor,随后依次是输入尺寸、均值向量与swapRB标志——这与上文“std 实为 scalefactor”的说明相互印证;- 返回的
input是一个 4 维 blob(NCHW)Mat,推理完成后需input.delete()释放 Emscripten 堆内存(OpenCV.js 的 Mat 均为显式管理,务必 delete)。
六、推理主循环:readNet → setInput → forward
第二段代码片段是一次完整的单帧推理(js_image_classification.html 中 codeSnippet1):
main = async function() {
const labels = await loadLables(labelsUrl);
const input = getBlobFromImage(inputSize, mean, std, swapRB, 'canvasInput');
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);
input.delete();
net.delete();
result.delete();
}
调用链说明:
cv.readNet(modelPath)从第四节写入的虚拟 FS 路径加载网络权重,构建cv.dnn_Net;net.setInput(input)绑定预处理后的 blob;net.forward()执行全图前向传播,返回输出层Mat(data32F为各类别得分向量);- 用
performance.now()前后相减得到纯推理耗时,展示在状态栏; - 三个
delete()分别释放输入、网络与输出对象,避免 WASM 堆内存累积。
注意 getBlobFromImage(inputSize, mean, std, swapRB, 'canvasInput') 这里传的是字符串 id,函数内部走 cv.imread('canvasInput') 分支——OpenCV.js 支持直接从 canvas 元素读取 Mat。
七、后处理:数值稳定的 softmax 与 Top-K 提取
分类结果后处理由两个函数完成(分别位于 HTML 的 codeSnippet5 与 js_dnn_example_helper.js):
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;
}
}
实现上做了经典的数值稳定处理:先减去最大值 maxNum 再取指数,避免 exp 上溢;needSoftmax=false 时(如 TF Inception)直接返回原始输出。
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;
}
将 [prob, index] 配对排序、倒序后取前 topK(默认 3)项,概率换算成百分比并保留两位小数,标签名通过原始下标回查 labels 数组——这一步保证了重排后标签与得分不脱节。
页面侧的 updateResult(classes, time) 把三行结果写入 #result 表格(显示后其 visibility 改为 visible),并在状态栏输出 Model: <modelPath> 与 Inference time: <ms>。
八、完整操作步骤
- 将
opencv.js与示例页面放在同一目录,用本地 HTTP 服务器打开 js_image_classification.html; - 从附录“Model Info”表格(数据源 js_image_classification_model_info.json)中选定一个模型,下载对应模型文件(链接在 JSON 的
modelUrl字段); - 点击 modelFile 按钮上传模型文件,状态栏应显示 "The model file '<文件名>' is created successfully.";
- 若所选模型不是默认配置的 TF Inception,按第三节参数表修改第一段代码编辑器中的
inputSize/mean/std(scale) /swapRB/needSoftmax,并确保labelsUrl指向对应类别文件(示例默认使用 ILSVRC2012 的classification_classes_ILSVRC2012.txt); - 需要换图时用 canvasInput 下的 file 控件选择任意图像(内部经
loadImageToCanvas重新绘制到画布); - 点击 Try it,查看 Top-3 标签、概率与推理耗时。
九、延伸:摄像头实时分类与其他 dnn 示例
同一教程族还提供摄像头实时版本 js_image_classification_with_camera.markdown,在静态图基础上用 getUserMedia 获取视频流逐帧推理(utils.js 的 startCamera 已封装该能力)。整个 OpenCV.js dnn 教程目录(见 js_table_of_contents_dnn.markdown)还包含目标检测、语义分割、风格迁移与姿态估计等示例,均复用本文的“模型落盘 → readNet → forward → 后处理”范式,可对照 js_dnn_example_helper.js 快速改造。需要 GPU 加速方向时,资源目录中另备有 WebNN polyfill 与 Electron 版页面(doc/js_tutorials/js_assets/js_image_classification_webnn_polyfill.html)可供参考。
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
