首页
/ OpenCV.js 在 Node.js 中的完整实战:从最小加载示例到 Emscripten 本地文件系统挂载

OpenCV.js 在 Node.js 中的完整实战:从最小加载示例到 Emscripten 本地文件系统挂载

2026-09-06 22:05:11作者:郦嵘贵Just

本文基于 OpenCV 仓库官方教程 js_nodejs.markdown 展开,讲解如何在 Node.js 环境中加载和使用 OpenCV.js:先理解 emscripten 的 Module 回调机制与 OpenCV.js 对 HTML DOM 的依赖,再分别掌握用 jimp 读写图像、用 jsdom + node-canvas 模拟 cv.imread()/cv.imshow(),以及通过 FS.mount() 将本地目录挂载进 emscripten 文件系统从而直接加载 ONNX 模型文件。读完本文,你可以独立搭建一个无浏览器依赖的 OpenCV.js 服务端图像处理与推理环境。

为什么 OpenCV.js 在 Node.js 里"水土不服"

OpenCV.js 是 C++ 源码经 emscripten 编译出的 JavaScript/WebAssembly 模块,编译产物本身不依赖 DOM;真正依赖 DOM 的是仓库中为 Web 场景附加的辅助 JS 层。从 helpers.js 的源码可以确认这一点:

  • Module['imread'] 内部先取 HTMLImageElement(或直接接收 canvas/OffscreenCanvas),再创建 canvas、调用 ctx.getImageData() 取出像素,最后交给 cv.matFromImageData() 转成 cv.Mat(见 helpers.js);
  • Module['imshow'] 则要求传入对象必须 instanceof HTMLCanvasElement,否则直接抛出 'Please input the valid canvas element or id.'(见 helpers.js);
  • cv.matFromImageData 的实现本身只是 new cv.Mat(height, width, CV_8UC4)mat.data.set(imageData.data),与 DOM 无关(见 helpers.js)。

因此教程的核心思路是:在 Node.js 里补齐这些全局对象(documentImageHTMLCanvasElementImageData),或绕过 DOM 直接用像素数据构造 cv.Mat。此外 cv.imread() 也不解码图片文件,OpenCV.js 本身不支持图像格式解码,需要借助 jimp、node-canvas 等库先把文件解码成像素。

最小示例:理解 Module 与 onRuntimeInitialized

创建 example1.js,与 opencv.js 放在同一目录:

// Define a global variable 'Module' with a method 'onRuntimeInitialized':
Module = {
  onRuntimeInitialized() {
    // this is our application:
    console.log(cv.getBuildInformation())
  }
}
// Load 'opencv.js' assigning the value to the global variable 'cv'
cv = require('./opencv.js')

执行:

node example1.js

前提:系统已安装 Node.js,且当前目录存在 opencv.js。该命令应打印 OpenCV 构建信息——getBuildInformation() 在 C++ 侧绑定自 cv::getBuildInformation()(见 core_bindings.cpp 及函数注册处 core_bindings.cpp)。

机制拆解:

  1. 全局变量 Module:emscripten 在运行时代码初始化完成后会调用 Module.onRuntimeInitialized(),所以"程序入口"就写在回调里,并使用全局 cv,与浏览器环境一致。
  2. cv = require('./opencv.js')require() 是 Node.js 的模块加载 API,这里加载当前目录的 opencv.js 文件并把返回值赋给全局 cv。加载即触发 emscripten 运行时初始化,随后回调被调用。

注意仓库自带的路由加载器 loader.js 是面向浏览器的(通过 document.createElement('script') 动态插标签,并在 wasm/simd/threads/asm.js 多个产物间按能力探测选择),在 Node.js 中不使用它,而是直接 require 单体文件。

用 jimp 读写图像:不依赖 DOM 的像素通路

OpenCV.js 不能直接 imread 一个 png/jpeg 文件。教程用 jimp(支持 jpg、png、bmp、tiff、gif)完成文件解码与编码。

项目初始化

mkdir project1
cd project1
npm init -y
npm install jimp

完整示例

保存为 exampleNodeJimp.js,并确保当前目录存在示例图 lena.jpg

const Jimp = require('jimp');

async function onRuntimeInitialized(){

  // load local image file with jimp. It supports jpg, png, bmp, tiff and gif:
  var jimpSrc = await Jimp.read('./lena.jpg');

  // `jimpImage.bitmap` property has the decoded ImageData that we can use to create a cv:Mat
  var src = cv.matFromImageData(jimpSrc.bitmap);

  // following lines is copy&paste of opencv.js dilate tutorial:
  let dst = new cv.Mat();
  let M = cv.Mat.ones(5, 5, cv.CV_8U);
  let anchor = new cv.Point(-1, -1);
  cv.dilate(src, dst, M, anchor, 1, cv.BORDER_CONSTANT, cv.morphologyDefaultBorderValue());

  // Now that we are finish, we want to write `dst` to file `output.png`. For this we create a `Jimp`
  // image which accepts the image data as a `Buffer`.
  // `write('output.png')` will write it to disk and Jimp infers the output format from given file name:
  new Jimp({
    width: dst.cols,
    height: dst.rows,
    data: Buffer.from(dst.data)
  })
  .write('output.png');

  src.delete();
  dst.delete();
}

// Finally, load the opencv.js as before. The function `onRuntimeInitialized` contains our program.
Module = {
  onRuntimeInitialized
};
cv = require('./opencv.js');
node exampleNodeJimp.js

应生成 output.png。要点:

  • Jimp.read() 是异步的,所以回调声明为 async,emscripten 会照常 await 它;
  • jimpSrc.bitmap 就是解码后的 ImageDatacv.matFromImageData() 将其变为 4 通道 CV_8UC4cv.Mat(对应 helpers.js 的实现);
  • cv.dilate() 使用 5×5 全 1 结构元素(cv.Mat.ones(5, 5, cv.CV_8U))做一次膨胀,anchor = (-1,-1) 表示结构元素中心;
  • 写回磁盘时反向构造:Buffer.from(dst.data) + 宽高,Jimp 依据文件扩展名推断编码格式;
  • 结束时务必 src.delete()dst.delete() 释放 wasm 堆上的内存——Matdelete() 即手动清理(当前仓库在 helpers.js 中还为其补充了 Symbol.dispose 支持,方便 TS 5.2+ 的 using 声明)。

用 jsdom + node-canvas 模拟 HTML DOM:让 cv.imread()/cv.imshow() 原生可用

教程第三部分安装 canvas(node-canvas)与 jsdom,为 cv.imread()/cv.imshow() 补齐 DOM 依赖:

mkdir project2
cd project2
npm init -y
npm install canvas jsdom

保存为 exampleNodeCanvas.js,当前目录需有 lena.jpg

const { Canvas, createCanvas, Image, ImageData, loadImage } = require('canvas');
const { JSDOM } = require('jsdom');
const { writeFileSync, existsSync, mkdirSync } = require("fs");

// This is our program. This time we use JavaScript async / await and promises to handle asynchronicity.
(async () => {

  // before loading opencv.js we emulate a minimal HTML DOM. See the function declaration below.
  installDOM();

  await loadOpenCV();

  // using node-canvas, we an image file to an object compatible with HTML DOM Image and therefore with cv.imread()
  const image = await loadImage('./lena.jpg');

  const src = cv.imread(image);
  const dst = new cv.Mat();
  const M = cv.Mat.ones(5, 5, cv.CV_8U);
  const anchor = new cv.Point(-1, -1);
  cv.dilate(src, dst, M, anchor, 1, cv.BORDER_CONSTANT, cv.morphologyDefaultBorderValue());

  // we create an object compatible HTMLCanvasElement
  const canvas = createCanvas(300, 300);
  cv.imshow(canvas, dst);
  writeFileSync('output.jpg', canvas.toBuffer('image/jpeg'));
  src.delete();
  dst.delete();
})();

// Load opencv.js just like before but using Promise instead of callbacks:
function loadOpenCV() {
  return new Promise(resolve => {
    global.Module = {
      onRuntimeInitialized: resolve
    };
    global.cv = require('./opencv.js');
  });
}

// Using jsdom and node-canvas we define some global variables to emulate HTML DOM.
// Although a complete emulation can be archived, here we only define those globals used
// by cv.imread() and cv.imshow().
function installDOM() {
  const dom = new JSDOM();
  global.document = dom.window.document;

  // The rest enables DOM image and canvas and is provided by node-canvas
  global.Image = Image;
  global.HTMLCanvasElement = Canvas;
  global.ImageData = ImageData;
  global.HTMLImageElement = Image;
}
node exampleNodeCanvas.js

应生成 output.jpg。对照 helpers.js 的源码,可以精确理解 installDOM() 为何要注入这四个全局量:

  • global.documentcv.imread() 需要 document.createElement('canvas')document.getElementById
  • global.HTMLImageElement = Image:node-canvas 的 Image 通过 instanceof HTMLImageElement 检查,cv.imread(image) 才会走 drawImage → getImageData 路径;
  • global.HTMLCanvasElement = Canvascv.imshow()instanceof HTMLCanvasElement 校验依赖它;
  • global.ImageDatacv.imshow() 内部 new ImageData(new Uint8ClampedArray(img.data), cols, rows) 需要全局构造器。

loadOpenCV() 与最小示例等价,只是把"运行时就绪"包装成了 Promise:onRuntimeInitialized: resolve 使 await loadOpenCV() 在运行时初始化完成后继续。node-canvas 的 loadImage('./lena.jpg') 负责真正的图像解码,随后 canvas.toBuffer('image/jpeg')cv.imshow() 画好的内容编码成 JPEG 写盘。

操作 emscripten 文件系统:NODEFS 挂载与相对路径解析

最后一部分解决一个高频需求:让 emscripten 里的 C++ 代码(例如 cv::FileStorage、DNN 读 ONNX 模型)直接访问本地磁盘。教程指出:OpenCV 库是 C++ 代码,opencv.js 只是其经 emscripten 的 C++ 编译器翻译出的 JS/WASM;这些 C++ 源码用标准文件 API 访问文件系统。浏览器里没有本地磁盘,emscripten 只能在内存中模拟文件系统;而 Node.js 下可以进一步把真实本地目录挂进来,免去把文件内容拷贝进内存。

教程示例(保存为 exampleNodeCanvasData.js)会自动下载 face_detection_yunet_2023mar.onnxopencv.jslena.jpg 三个文件(若目录中尚不存在),然后加载 YuNet 人脸检测模型并画出检测框:

const { Canvas, createCanvas, Image, ImageData, loadImage } = require('canvas');
const { JSDOM } = require('jsdom');
const { writeFileSync, existsSync, mkdirSync } = require('fs');
const https = require('https');

(async () => {
const createFileFromUrl = function (path, url, maxRedirects = 10) {
  console.log('Downloading ' + url + '...');
  return new Promise((resolve, reject) => {
    const download = (url, redirectCount) => {
      if (redirectCount > maxRedirects) {
        reject(new Error('Too many redirects'));
      } else {
        let connection = https.get(url, (response) => {
          if (response.statusCode === 200) {
            let data = [];
            response.on('data', (chunk) => {
              data.push(chunk);
            });

            response.on('end', () => {
              try {
                writeFileSync(path, Buffer.concat(data));
                resolve();
              } catch (err) {
                reject(new Error('Failed to write file ' + path));
              }
            });
          } else if (response.statusCode === 302 || response.statusCode === 301) {
            connection.abort();
            download(response.headers.location, redirectCount + 1);
          } else {
            reject(new Error('Failed to load ' + url + ' status: ' + response.statusCode));
          }
        }).on('error', (err) => {
          reject(new Error('Network Error: ' + err.message));
        });
      }
    };
    download(url, 0);
  });
};

if (!existsSync('./face_detection_yunet_2023mar.onnx')) {
  await createFileFromUrl('./face_detection_yunet_2023mar.onnx', 'https://media.githubusercontent.com/media/opencv/opencv_zoo/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx')
}

if (!existsSync('./opencv.js')) {
  await createFileFromUrl('./opencv.js', 'https://docs.opencv.org/5.x/opencv.js')
}

if (!existsSync('./lena.jpg')) {
  await createFileFromUrl('./lena.jpg', 'https://docs.opencv.org/5.x/lena.jpg')
}

await loadOpenCV();

const image = await loadImage('./lena.jpg');
const src = cv.imread(image);
let srcBGR = new cv.Mat();
cv.cvtColor(src, srcBGR, cv.COLOR_RGBA2BGR);

// Load the deep learning model file. Notice how we reference local files using relative paths just
// like we normally would do
let netDet = new cv.FaceDetectorYN("./face_detection_yunet_2023mar.onnx", "", new cv.Size(320, 320), 0.9, 0.3, 5000);
netDet.setInputSize(new cv.Size(src.cols, src.rows));
let out = new cv.Mat();
netDet.detect(srcBGR, out);

let faces = [];
for (let i = 0, n = out.data32F.length; i < n; i += 15) {
  let left = out.data32F[i];
  let top = out.data32F[i + 1];
  let right = (out.data32F[i] + out.data32F[i + 2]);
  let bottom = (out.data32F[i + 1] + out.data32F[i + 3]);
  left = Math.min(Math.max(0, left), src.cols - 1);
  top = Math.min(Math.max(0, top), src.rows - 1);
  right = Math.min(Math.max(0, right), src.cols - 1);
  bottom = Math.min(Math.max(0, bottom), src.rows - 1);

  if (left < right && top < bottom) {
    faces.push({
      x: left,
      y: top,
      width: right - left,
      height: bottom - top,
      x1: out.data32F[i + 4] < 0 || out.data32F[i + 4] > src.cols - 1 ? -1 : out.data32F[i + 4],
      y1: out.data32F[i + 5] < 0 || out.data32F[i + 5] > src.rows - 1 ? -1 : out.data32F[i + 5],
      x2: out.data32F[i + 6] < 0 || out.data32F[i + 6] > src.cols - 1 ? -1 : out.data32F[i + 6],
      y2: out.data32F[i + 7] < 0 || out.data32F[i + 7] > src.rows - 1 ? -1 : out.data32F[i + 7],
      x3: out.data32F[i + 8] < 0 || out.data32F[i + 8] > src.cols - 1 ? -1 : out.data32F[i + 8],
      y3: out.data32F[i + 9] < 0 || out.data32F[i + 9] > src.rows - 1 ? -1 : out.data32F[i + 9],
      x4: out.data32F[i + 10] < 0 || out.data32F[i + 10] > src.cols - 1 ? -1 : out.data32F[i + 10],
      y4: out.data32F[i + 11] < 0 || out.data32F[i + 11] > src.rows - 1 ? -1 : out.data32F[i + 11],
      x5: out.data32F[i + 12] < 0 || out.data32F[i + 12] > src.cols - 1 ? -1 : out.data32F[i + 12],
      y5: out.data32F[i + 13] < 0 || out.data32F[i + 13] > src.rows - 1 ? -1 : out.data32F[i + 13],
      confidence: out.data32F[i + 14]
    })
  }
}
out.delete();

faces.forEach(function(rect) {
  cv.rectangle(src, {x: rect.x, y: rect.y}, {x: rect.x + rect.width, y: rect.y + rect.height}, [0, 255, 0, 255]);
  if(rect.x1>0 && rect.y1>0)
    cv.circle(src, {x: rect.x1, y: rect.y1}, 2, [255, 0, 0, 255], 2)
  if(rect.x2>0 && rect.y2>0)
    cv.circle(src, {x: rect.x2, y: rect.y2}, 2, [0, 0, 255, 255], 2)
  if(rect.x3>0 && rect.y3>0)
    cv.circle(src, {x: rect.x3, y: rect.y3}, 2, [0, 255, 0, 255], 2)
  if(rect.x4>0 && rect.y4>0)
    cv.circle(src, {x: rect.x4, y: rect.y4}, 2, [255, 0, 255, 255], 2)
  if(rect.x5>0 && rect.y5>0)
    cv.circle(src, {x: rect.x5, y: rect.y5}, 2, [0, 255, 255, 255], 2)
});

const canvas = createCanvas(image.width, image.height);
cv.imshow(canvas, src);
writeFileSync('output3.jpg', canvas.toBuffer('image/jpeg'));
console.log('The result is saved.')
src.delete(); srcBGR.delete();
})();

/**
 * Loads opencv.js.
 *
 * Installs HTML Canvas emulation to support `cv.imread()` and `cv.imshow`
 *
 * Mounts given local folder `localRootDir` in emscripten filesystem folder `rootDir`. By default it will mount the local current directory in emscripten `/work` directory. This means that `/work/foo.txt` will be resolved to the local file `./foo.txt`
 * @param {string} rootDir The directory in emscripten filesystem in which the local filesystem will be mount.
 * @param {string} localRootDir The local directory to mount in emscripten filesystem.
 * @returns {Promise} resolved when the library is ready to use.
 */
function loadOpenCV(rootDir = '/work', localRootDir = process.cwd()) {
  if(global.Module && global.Module.onRuntimeInitialized && global.cv && global.cv.imread) {
   Promise.resolve()
  }
  return new Promise(resolve => {
    installDOM()
    global.Module = {
      onRuntimeInitialized() {
        // We change emscripten current work directory to 'rootDir' so relative paths are resolved
        // relative to the current local folder, as expected
        cv.FS.chdir(rootDir)
        resolve()
      },
      preRun() {
        // preRun() is another callback like onRuntimeInitialized() but is called just before the
        // library code runs. Here we mount a local folder in emscripten filesystem and we want to
        // do this before the library is executed so the filesystem is accessible from the start
        const FS = global.Module.FS
        // create rootDir if it doesn't exists
        if(!FS.analyzePath(rootDir).exists) {
          FS.mkdir(rootDir);
        }
        // create localRootFolder if it doesn't exists
        if(!existsSync(localRootDir)) {
          mkdirSync(localRootDir, { recursive: true});
        }
        // FS.mount() is similar to Linux/POSIX mount operation. It basically mounts an external
        // filesystem with given format, in given current filesystem directory.
        FS.mount(FS.filesystems.NODEFS, { root: localRootDir}, rootDir);
      }
    };
    global.cv = require('./opencv.js')
  });
}

function installDOM(){
  const dom = new JSDOM();
  global.document = dom.window.document;
  global.Image = Image;
  global.HTMLCanvasElement = Canvas;
  global.ImageData = ImageData;
  global.HTMLImageElement = Image;
}
node exampleNodeCanvasData.js

生成 output3.jpg

YuNet 人脸检测在 lena.jpg 上的检测结果 output3.jpg

loadOpenCV() 的两个关键参数与回调值得逐行理解:

参数/回调 默认值 作用
rootDir /work emscripten 虚拟文件系统内的挂载点目录
localRootDir process.cwd() 要挂载的本地目录;/work/foo.txt 将解析为本地 ./foo.txt
Module.preRun() 在库代码执行之前回调:创建 rootDirFS.analyzePath + FS.mkdir),并调用 FS.mount(FS.filesystems.NODEFS, { root: localRootDir }, rootDir)——类似 POSIX 的 mount,把 Node.js 本地文件系统以 NODEFS 格式挂进来
Module.onRuntimeInitialized() 运行时就绪后调用 cv.FS.chdir(rootDir),使 C++ 侧相对路径按"本地当前目录的相对路径"解析

正因为做了挂载与 chdirnew cv.FaceDetectorYN("./face_detection_yunet_2023mar.onnx", ...) 可以直接用相对路径读模型,无需把 ONNX 字节先读进内存——这对 js_dnn 教程 里涉及的加载深度学习模型等场景尤为实用。FaceDetectorYN 构造参数依次为:模型路径、配置(此处为空)、输入尺寸 (320,320)、置信度阈值 0.9、NMS IoU 阈值 0.3、最大检测数 5000;检测结果按每 15 个 float 一组解析(x, y, w, h + 5 个关键点 + confidence)。

小结:三条技术路径的取舍

需求 方案 依赖 说明
仅调用核心算子(dilate、cvtColor 等) jimp + matFromImageData jimp 不经 DOM,最直接
想沿用 cv.imread()/cv.imshow() 习惯 jsdom + node-canvas 注入 DOM 全局 canvas、jsdom 与浏览器代码最接近
需要 C++ 侧读本地文件(ONNX 模型等) FS.mount(NODEFS) + FS.chdir canvas、jsdom(可选) 相对路径即本地路径

三个示例均可独立运行;进阶细节可参考仓库中 modules/js 的绑定与测试代码(如 modules/js/test 下的 test_imgproc.js 等)以及浏览器端教程目录 doc/js_tutorials

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