OpenCV.js 在 Node.js 中的完整实战:从最小加载示例到 Emscripten 本地文件系统挂载
本文基于 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 里补齐这些全局对象(document、Image、HTMLCanvasElement、ImageData),或绕过 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)。
机制拆解:
- 全局变量
Module:emscripten 在运行时代码初始化完成后会调用Module.onRuntimeInitialized(),所以"程序入口"就写在回调里,并使用全局cv,与浏览器环境一致。 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就是解码后的ImageData,cv.matFromImageData()将其变为 4 通道CV_8UC4的cv.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 堆上的内存——Mat的delete()即手动清理(当前仓库在 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.document:cv.imread()需要document.createElement('canvas')与document.getElementById;global.HTMLImageElement = Image:node-canvas 的Image通过instanceof HTMLImageElement检查,cv.imread(image)才会走 drawImage → getImageData 路径;global.HTMLCanvasElement = Canvas:cv.imshow()的instanceof HTMLCanvasElement校验依赖它;global.ImageData:cv.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.onnx、opencv.js、lena.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:
loadOpenCV() 的两个关键参数与回调值得逐行理解:
| 参数/回调 | 默认值 | 作用 |
|---|---|---|
rootDir |
/work |
emscripten 虚拟文件系统内的挂载点目录 |
localRootDir |
process.cwd() |
要挂载的本地目录;/work/foo.txt 将解析为本地 ./foo.txt |
Module.preRun() |
— | 在库代码执行之前回调:创建 rootDir(FS.analyzePath + FS.mkdir),并调用 FS.mount(FS.filesystems.NODEFS, { root: localRootDir }, rootDir)——类似 POSIX 的 mount,把 Node.js 本地文件系统以 NODEFS 格式挂进来 |
Module.onRuntimeInitialized() |
— | 运行时就绪后调用 cv.FS.chdir(rootDir),使 C++ 侧相对路径按"本地当前目录的相对路径"解析 |
正因为做了挂载与 chdir,new 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。
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
