首页
/ OpenCV.js 图像分类实战指南:基于 dnn 模块从 ONNX 模型加载到 Top-K 结果输出

OpenCV.js 图像分类实战指南:基于 dnn 模块从 ONNX 模型加载到 Top-K 结果输出

2026-09-05 12:58:32作者:农烁颖Land

本文围绕 OpenCV.js(OpenCV 的 WebAssembly 构建)的 dnn 模块,完整讲解如何在浏览器中实现图像分类流水线:从页面骨架与交互控件、推理参数配置、模型文件写入 Emscripten 虚拟文件系统,到 blobFromImage 预处理、readNet/forward 推理和 softmax + Top-K 后处理。读完本文,你可以直接复用仓库内置的交互示例(js_image_classification.html)跑通任意 ONNX/TensorFlow 分类模型,并理解每个环节背后的源码实现。

教程资源目录中的示例输入图像 apple.jpg,320×320,可作为图像分类示例的输入素材

一、目标与运行环境

该教程的目标(见 js_image_classification.markdown)是:学习如何使用 OpenCV.js 的 dnn 模块完成图像分类。教程配套一个可交互的 HTML 示例页面,其基本操作路径为:

  1. 点击 modelFile 按钮上传一个 ONNX(或 TensorFlow)模型文件;
  2. 根据所上传的模型,修改第一段代码片段(推理参数);
  3. 点击 Try it 按钮执行一次推理,输入图片也可以换成其他图像;
  4. 在右侧表格中查看 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/prob0label2/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() 变体:用 XMLHttpRequestresponseType: '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.hppMat 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.htmlcodeSnippet1):

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();
}

调用链说明:

  1. cv.readNet(modelPath) 从第四节写入的虚拟 FS 路径加载网络权重,构建 cv.dnn_Net
  2. net.setInput(input) 绑定预处理后的 blob;
  3. net.forward() 执行全图前向传播,返回输出层 Matdata32F 为各类别得分向量);
  4. performance.now() 前后相减得到纯推理耗时,展示在状态栏;
  5. 三个 delete() 分别释放输入、网络与输出对象,避免 WASM 堆内存累积。

注意 getBlobFromImage(inputSize, mean, std, swapRB, 'canvasInput') 这里传的是字符串 id,函数内部走 cv.imread('canvasInput') 分支——OpenCV.js 支持直接从 canvas 元素读取 Mat

七、后处理:数值稳定的 softmax 与 Top-K 提取

分类结果后处理由两个函数完成(分别位于 HTML 的 codeSnippet5js_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>

八、完整操作步骤

  1. opencv.js 与示例页面放在同一目录,用本地 HTTP 服务器打开 js_image_classification.html
  2. 从附录“Model Info”表格(数据源 js_image_classification_model_info.json)中选定一个模型,下载对应模型文件(链接在 JSON 的 modelUrl 字段);
  3. 点击 modelFile 按钮上传模型文件,状态栏应显示 "The model file '<文件名>' is created successfully.";
  4. 若所选模型不是默认配置的 TF Inception,按第三节参数表修改第一段代码编辑器中的 inputSize / mean / std(scale) / swapRB / needSoftmax,并确保 labelsUrl 指向对应类别文件(示例默认使用 ILSVRC2012 的 classification_classes_ILSVRC2012.txt);
  5. 需要换图时用 canvasInput 下的 file 控件选择任意图像(内部经 loadImageToCanvas 重新绘制到画布);
  6. 点击 Try it,查看 Top-3 标签、概率与推理耗时。

九、延伸:摄像头实时分类与其他 dnn 示例

同一教程族还提供摄像头实时版本 js_image_classification_with_camera.markdown,在静态图基础上用 getUserMedia 获取视频流逐帧推理(utils.jsstartCamera 已封装该能力)。整个 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)可供参考。

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