Skip to content
haokingPublic

About

Complete opencvjs (With the lastest OpenCV 4.0.0+)

Topics

Resources

Stars

55 stars

Watchers

7 watching

Forks

Repository files navigation

opencvjs

OpenCV 5.0.0 WebAssembly

@haoking/opencvjs is OpenCV 5.0.0 compiled to WebAssembly from source, with a layer of extra cv.Mat methods on top:

  • Two wasm builds, one picked at runtime. A -msimd128 build and a non-SIMD fallback ship together; loadOpenCV() probes the engine and loads the right one.
  • 19 extra Mat methods and cv.norm2: independent region copies, fast in-place writes, scalar arithmetic and type-dispatching accessors.
  • Arguments are validated before they reach wasm. Mistakes raise a TypeError / RangeError naming the function, the argument and the value, instead of an uninformative abort or a silent write past the end of a Mat.
  • TypeScript declarations generated from the build itself, tested to match the runtime symbol for symbol.
  • Reproducible builds: pinned OpenCV commit, pinned emsdk image digest and five documented patches to upstream. No runtime or test dependencies.

Why not the official opencv.js? Upstream does not publish one for production use: the official file is a reduced tutorial build bundled with the documentation. An OpenCV maintainer in opencv#25425: "Only reduced subset of functions is provided here (size vs functionality compromise). Due to this there is no official OpenCV JS release. We don't recommend to use that file directly in your projects."

Contents

Installation

npm install @haoking/opencvjs
const loadCV = require("@haoking/opencvjs");

(async () => {
  const cv = await loadCV();
  const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
  console.log("mat::" + mat.data32F); //mat::1,2,3,4,5,6,7,8,9
  mat.delete(); // wasm memory is not garbage-collected: delete what you create
})();

The entry point returns a Promise, and cv is ready when it resolves. If you need to test readiness yourself, check typeof cv.Mat === "function", not cv.onRuntimeInitialized: artifacts built with older Emscripten kept that property after initialization, so waiting for it never ended.

Requirements

  • Node.js 22 or later. CI runs 22, 24 and 26.
  • Browsers: Chrome 85+, Firefox 79+, Safari 15+ — the floor of code generated by Emscripten 6.0.12. The SIMD build also needs WebAssembly SIMD (Chrome 91+, Firefox 89+, Safari 16.4+); elsewhere the baseline build is loaded automatically. The automated tests run on Node only (see Known issues).
  • Building from source: Docker. The toolchain runs inside the emsdk container.

Loading and variant selection

The package ships two builds of the same OpenCV:

dist/
  index.js  index.d.ts  simd-detect.js  guards.js  typed-access.js
  mat-region.js  arithmetic.js  dft.js     ← extension layer, shared by both builds
  baseline/opencv.js + opencv_js.wasm       ← no SIMD, runs everywhere
  simd/opencv.js     + opencv_js.wasm       ← compiled with -msimd128

loadOpenCV() checks with WebAssembly.validate() whether the engine supports WebAssembly SIMD and loads the matching build:

const loadCV = require("@haoking/opencvjs");

const cv = await loadCV(); // automatic: simd if supported, otherwise baseline
// await loadCV({ simd: false }) forces baseline, await loadCV({ simd: true }) forces simd

loadCV.detectSimd(); // whether the engine supports SIMD (not which build was loaded)

The environment variable OPENCV_SIMD forces a build as well: 1 / true / on / yes for simd, 0 / false / off / no for baseline. Precedence: loadOpenCV({ simd }) > OPENCV_SIMD > detection.

  • A build you ask for explicitly that is missing throws; it never falls back to the other one, which would make "run everything on SIMD" quietly test baseline. Only automatic selection falls back, with a warning. A misspelt OPENCV_SIMD value such as ture throws too.
  • One build per process. OpenCV's UMD wrapper leaks Module as an implicit global: loading the second build's glue first rewrites the loaded instance's JS helpers and then fails in Embind (Cannot register public name 'IntVector' twice). So once a build is loaded, asking for the other one throws before anything is touched, and automatic selection reuses the loaded build. To compare the builds, use two processes, as npm run simd:compare does.
  • Each build lives in its own directory because Emscripten bakes the file name opencv_js.wasm into the glue at compile time. Keep every opencv.js next to its own opencv_js.wasm, under that name.

Memory management

wasm memory is not garbage-collected. Free every cv.Mat, cv.MatVector and other Embind object you create:

  • mat.delete() frees it now.
  • using m = … frees it at the end of the block, on engines that support the using declaration (for example Node.js 24). Every Embind object has [Symbol.dispose].
  • mat.deleteLater() only queues the object; it is freed when cv.flushPendingDeletes() runs, or by the scheduler you install with cv.setDelayFunction(fn).
{
  using m = cv.matFromArray(2, 2, cv.CV_8UC1, [1, 2, 3, 4]);
  console.log(m.sum()); //10
} // m.delete() runs here

Embind vectors are iterable. Each element you get is a new handle that shares its pixels with the vector's element, and must be deleted like any other:

const vec = new cv.MatVector();
const a = cv.matFromArray(1, 2, cv.CV_8UC1, [1, 2]);
vec.push_back(a);
vec.push_back(a);
for (const m of vec) {
  console.log("rows::" + m.rows); //rows::1
  m.delete();
}
(a.delete(), vec.delete());

Heap views are invalidated when memory grows. mat.data*, DATA(), PTR() and cv.HEAP* are views onto the wasm heap. Anything that allocates wasm memory afterwards — a new Mat, clone(), most cv.* calls — may grow the heap, and views taken before that are detached: their length becomes 0 and writes through them are lost. Take views after allocating, not before:

const src = cv.matFromArray(2, 2, cv.CV_32FC1, [1, 2, 3, 4]);
const dst = new cv.Mat(2, 2, cv.CV_32FC1); // allocate first …
dst.data32F.set(src.data32F); // … then take the views
console.log("dst::" + dst.data32F); //dst::1,2,3,4
(src.delete(), dst.delete());

The heap starts at 128 MB and grows on demand up to 2 GB (upstream OpenCV.js stops at 1 GB).

Extension API

Method Returns Does
mat.roiClone(rect) new Mat continuous copy of a rectangle
mat.colClone(col) new Mat continuous copy of a column
mat.diagClone(d = 0) new Mat continuous copy of a diagonal
mat.replaceMatOnRect(src, rect) — copies channel 0 of src into rect, in place
mat.rectAdd(src, rect) — adds channel 0 of src to rect, in place
mat.rectSubtract(src, rect) — subtracts channel 0 of src from rect, in place
mat.replaceMatOnRow(arr, row) — overwrites a whole row (every channel), in place
mat.replaceMatOnCol(arr, col) — overwrites channel 0 of a column, in place
mat.replaceMatOnPoint(value, row, col) / (value, point) — writes channel 0 of one element, in place
mat.addOnCol(constant, col) — adds a constant to channel 0 of a column, in place
mat.addConstant(c) new Mat src + c
mat.constantSubtract(c) new Mat c − src
mat.mulConstant(c) new Mat src × c
mat.constantDivide(c) new Mat c / src
mat.sum() number sum of all elements over all channels
mat.reshapeRows(rows) new Mat copy laid out in rows rows
mat.DATA() TypedArray the whole Mat, typed by its depth
mat.PTR(row) / mat.PTR(row, col) TypedArray one row, or the channels of one element
mat.dftSplit() { r, i } deprecated, see below
cv.norm2(a, b, normType = cv.NORM_L2) number ‖a − b‖
cv.matFromArray(rows, cols, type, array) new Mat native, wrapped with validation
loadOpenCV.detectSimd() boolean whether the engine supports WebAssembly SIMD

The examples assume const cv = await require("@haoking/opencvjs")();. The outputs in the comments come from running them on the 4.0.0 build.

Region copies

Native roi(), col() and diag() return views into the source Mat. A view is not continuous, and data* / DATA() read it as contiguous memory, which gives the wrong elements. roiClone(), colClone() and diagClone() return independent, continuous copies that are safe to read. Use the native views when you want to write back into the source.

const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
const rect = new cv.Rect(1, 1, 2, 2);

const copy = mat.roiClone(rect);
console.log("roiClone::" + copy.data32F + " " + copy.rows + "x" + copy.cols);
//roiClone::5,6,8,9 2x2

console.log("colClone::" + mat.colClone(2).data32F); //colClone::3,6,9
console.log("col::" + mat.col(2).data32F); //col::3,4,5   ← native view, read as contiguous: wrong
console.log("diagClone::" + mat.diagClone().data32F); //diagClone::1,5,9

const view = mat.roi(rect); // a view writes back into the source
view.setTo(new cv.Scalar(0, 0, 0, 0));
console.log("mat::" + mat.data32F); //mat::1,2,3,4,0,0,7,0,0
(mat.delete(), copy.delete(), view.delete());

On multi-channel Mats the native view is off by more (3×3 CV_32FC2, values 1..18):

// prettier-ignore
const mat = cv.matFromArray(3, 3, cv.CV_32FC2,
  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
console.log("colClone::" + mat.colClone(2).data32F); //colClone::5,6,11,12,17,18
console.log("col::" + mat.col(2).data32F); //col::5,6,7,8,9,10   ← wrong
mat.delete();

A single row (mat.row(i)) is continuous, so reading it directly is fine.

In-place writes

replaceMatOnRect, rectAdd, rectSubtract, replaceMatOnRow, replaceMatOnCol, replaceMatOnPoint and addOnCol modify the Mat they are called on:

  • Values written into integer Mats saturate like OpenCV's saturate_cast: rounded half to even, clamped to the type's range, NaN becomes 0 (250 + 20 on CV_8U is 255, not 14). Floating-point Mats are written as is.
  • Only channel 0 is written, except by replaceMatOnRow, which writes every channel of the row.
  • When src shares memory with the Mat being written (the same Mat, or overlapping views of one parent), src is read in full before anything is written.
  • They work on native roi() / col() views and write at the view's own position.
  • Indices are validated once on entry; the loops then address the wasm heap directly, two orders of magnitude faster than calling an accessor per pixel.
const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
const rect = new cv.Rect(1, 1, 2, 2);
const patch = cv.matFromArray(2, 2, cv.CV_32FC1, [11, 12, 13, 14]);

mat.replaceMatOnRect(patch, rect);
console.log("replaceMatOnRect::" + mat.data32F); //replaceMatOnRect::1,2,3,4,11,12,7,13,14
mat.rectAdd(patch, rect);
console.log("rectAdd::" + mat.data32F); //rectAdd::1,2,3,4,22,24,7,26,28
mat.rectSubtract(patch, rect);
console.log("rectSubtract::" + mat.data32F); //rectSubtract::1,2,3,4,11,12,7,13,14

mat.replaceMatOnRow([21, 22, 23], 0);
console.log("replaceMatOnRow::" + mat.data32F); //replaceMatOnRow::21,22,23,4,11,12,7,13,14
mat.replaceMatOnCol([31, 32, 33], 2);
console.log("replaceMatOnCol::" + mat.data32F); //replaceMatOnCol::21,22,31,4,11,32,7,13,33
mat.addOnCol(100, 0);
console.log("addOnCol::" + mat.data32F); //addOnCol::121,22,31,104,11,32,107,13,33

mat.replaceMatOnPoint(0, 1, 1); // (value, row, col)
mat.replaceMatOnPoint(-1, new cv.Point(2, 0)); // cv.Point: x = column, y = row
console.log("replaceMatOnPoint::" + mat.data32F); //replaceMatOnPoint::121,22,-1,104,0,32,107,13,33
(mat.delete(), patch.delete());

const bytes = cv.matFromArray(1, 3, cv.CV_8UC1, [250, 10, 3]);
bytes.replaceMatOnRow([270, -5, 2.5], 0);
console.log("saturated::" + bytes.data); //saturated::255,0,2
bytes.delete();

Scalar arithmetic

addConstant, constantSubtract and mulConstant compute saturate(x · alpha + beta) per element with convertTo; constantDivide computes saturate(c / x) with cv.divide, where an integer division by zero gives 0. All four return a new Mat and work on every depth with 1–4 channels (constantDivide also on N-dimensional Mats such as a 3-channel calcHist result).

const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
console.log("addConstant::" + mat.addConstant(10).data32F); //addConstant::11,12,13,14,15,16,17,18,19
console.log("constantSubtract::" + mat.constantSubtract(10).data32F); //constantSubtract::9,8,7,6,5,4,3,2,1
console.log("mulConstant::" + mat.mulConstant(10).data32F); //mulConstant::10,20,30,40,50,60,70,80,90
console.log("constantDivide::" + mat.constantDivide(12).data32F); //constantDivide::12,6,4,3,2.4000000953674316,2,1.7142857313156128,1.5,1.3333333730697632
mat.delete();

const bytes = cv.matFromArray(1, 4, cv.CV_8UC1, [10, 4, 2, 0]);
console.log("constantDivide 8U::" + bytes.constantDivide(1000).data); //constantDivide 8U::100,250,255,0
bytes.delete();

The constant must be a number other than NaN; ±Infinity is accepted (see Argument validation). (The examples above leak their results for brevity; delete them in real code.)

sum and reshapeRows

sum() returns the scalar sum of all elements over all channels — unlike OpenCV's cv.sum(), which returns a per-channel Scalar and is not in this build's whitelist. reshapeRows(rows) returns a copy laid out in rows rows — unlike native reshape(), which returns a header sharing memory (and is not bound in this build). Both work on non-continuous views.

const mat = cv.matFromArray(
  2,
  2,
  cv.CV_32FC3,
  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
);
console.log("sum::" + mat.sum()); //sum::78
const flat = mat.reshapeRows(1);
console.log("reshapeRows::" + flat.data32F + " " + flat.rows + "x" + flat.cols);
//reshapeRows::1,2,3,4,5,6,7,8,9,10,11,12 1x4
(mat.delete(), flat.delete());

cv.norm2

cv.norm2(src1, src2, normType = cv.NORM_L2) is ‖src1 − src2‖, with normType as in C++ cv::norm(src1, src2, normType): NORM_INF, NORM_L1, NORM_L2, NORM_L2SQR, and NORM_HAMMING / NORM_HAMMING2 (CV_8UC1 only, counting the differing bits of src1 XOR src2), each optionally OR-ed with NORM_RELATIVE for ‖src1 − src2‖ / ‖src2‖. Differences of integer Mats are exact, not saturated to the depth.

const a = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
const b = cv.matFromArray(
  3,
  3,
  cv.CV_32FC1,
  [9, 10, 11, 12, 13, 14, 15, 16, 17],
);
console.log("norm2::" + cv.norm2(a, b)); //norm2::24
console.log("L1::" + cv.norm2(a, b, cv.NORM_L1)); //L1::72
(a.delete(), b.delete());

Native cv.norm(a, b, normType) does not exist in this build. Embind dispatches on argument count only, and the two C++ overload sets collide at 2 and 3 arguments, so the generator kept only norm(src, normType, mask). cv.norm(a, b) therefore passes the second Mat as an integer normType and fails at an assertion inside OpenCV.

DATA and PTR

DATA() returns a TypedArray over the whole Mat; PTR(row) the elements of one row (cols × channels); PTR(row, col) the channels of one element. They pick the accessor by depth() — picking the wrong one by hand reinterprets the bytes silently. PTR() checks both indices; DATA() is meaningful on continuous Mats only.

const mat = cv.matFromArray(3, 3, cv.CV_8UC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
console.log("DATA::" + mat.DATA()); //DATA::1,2,3,4,5,6,7,8,9
console.log("PTR(1)::" + mat.PTR(1)); //PTR(1)::4,5,6
console.log("PTR(1,2)::" + mat.PTR(1, 2)); //PTR(1,2)::6
mat.delete();
Depth data* *Ptr *At TypedArray
CV_8U data ucharPtr ucharAt Uint8Array
CV_8S data8S charPtr charAt Int8Array
CV_16U data16U ushortPtr ushortAt Uint16Array
CV_16S data16S shortPtr shortAt Int16Array
CV_32S data32S intPtr intAt Int32Array
CV_32F data32F floatPtr floatAt Float32Array
CV_64F data64F doublePtr doubleAt Float64Array

The native *Ptr / *At accessors check nothing: on a 3×3 CV_32FC1, floatPtr(9, 9) returns a view into memory that belongs to something else.

dftSplit (deprecated)

dftSplit() splits the CCS-packed single-channel output of cv.dft() into real and imaginary Mats ({ r, i }). Nothing supports the correctness of its unpacking convention, and its only historical consumer returned NaN. It is kept for compatibility only. For complex spectrum multiplication use native cv.mulSpectrums(), which works on CCS data directly.

cv.matFromArray

The native cv.matFromArray(rows, cols, type, array) is wrapped so that bad input fails before anything is allocated: type must be a known depth with 1–4 channels, rows and cols non-negative integers, and array.length exactly rows × cols × channels. Without the wrapper a short array leaves the rest of the Mat uninitialized, and an unknown type (a misspelt constant is undefined) leaks the Mat the glue already allocated.

Argument validation

Every extension method validates its arguments before calling into wasm and throws a standard TypeError (wrong type) or RangeError (wrong value) whose message names the function, the argument, the value received and what was expected:

const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
try {
  mat.roiClone(new cv.Rect(1, 1, 10, 10));
} catch (e) {
  console.log(e instanceof RangeError); //true
  console.log(e.message);
  //Mat.roiClone(rect): Rect(x=1, y=1, width=10, height=10) does not fit in the 3×3 (rows×cols) Mat; it needs 0 ≤ x and x+width ≤ cols=3, 0 ≤ y and y+height ≤ rows=3
}
mat.delete();

More messages, verbatim:

Mat.addOnCol(constant, col): col = 7 is out of range; the valid range is 0..2

Mat.PTR(row, col): row = 9 is out of range; the valid range is 0..2

cv.matFromArray(rows, cols, type, array): a 3×3 CV_32FC1(5) Mat needs 9 elements
(3×3×1 channel), got 3; TypedArray.set() does not complain about a short source, and
the rest of the Mat would be uninitialized heap memory

cv.norm2(src1, src2, normType): src1 is CV_32FC1(5) and src2 is CV_8UC1(0); the types
must match

Mat.sum(): the receiver has been deleted; a Mat cannot be used after delete()

What this layer prevents:

  1. Native failures carry no information. This build compiles C++ exception catching out, so a failing CV_Assert inside OpenCV aborts. With Emscripten 6.0.12 every such failure throws the same RuntimeError: Aborted(undefined). Build with -sASSERTIONS for more info. and prints Aborted(undefined) to stderr — no function, argument or value. (The module stays usable afterwards.)
  2. Out-of-range indices do not fail at all. Embind's *Ptr(row, col) checks nothing, and the in-place writes address the heap directly, so an out-of-range Rect or column would silently overwrite somebody else's memory.

Each index is validated once per call, never per pixel: rows and cols are Embind getters, and every read crosses the language boundary.

Operands versus data. The constant of addConstant, constantSubtract, mulConstant, constantDivide and addOnCol is an operand applied to the whole Mat: non-numbers and NaN are rejected (addConstant(undefined) used to return a Mat full of NaN without an error), while ±Infinity is accepted — it is a valid IEEE-754 value and the usual sentinel in cost and distance maps. Values written as data (replaceMatOnRow, replaceMatOnCol, replaceMatOnPoint) may be NaN or ±Infinity; writing NaN to mark a pixel invalid is legitimate.

const mat = cv.matFromArray(2, 2, cv.CV_32FC1, [1, 2, 3, 4]);
const inf = mat.addConstant(Infinity);
console.log("" + inf.DATA()); //Infinity,Infinity,Infinity,Infinity
(mat.delete(), inf.delete());

Native OpenCV API

Everything else on cv is OpenCV's own JavaScript API, and the OpenCV.js tutorials apply. The whitelist (src/config/opencv_js.config.py) is upstream's, with these differences:

  • cv.mulSpectrums and cv.SVDecomp are added.
  • The dnn bindings are removed (cv.readNet* are undefined); the dnn C++ code is still compiled in, because other modules use it.
  • The MCC color checker classes are bound under their real names (upstream's entries do not match the 5.0.0 class names, so its build binds none of them).

OpenCV 5 removed CascadeClassifier, HOGDescriptor, AKAZE, BRISK, KAZE, AgastFeatureDetector and groupRectangles from its C++ API; see Known issues.

Pitfalls in the native API

All verified on this build:

  • cv.norm(a, b, normType) does not exist (see cv.norm2); use cv.norm2.
  • cv.Scalar.all(v) throws TypeError: this.push is not a function (the glue calls the constructor without new); use new cv.Scalar(v, v, v, v).
  • new cv.MinMaxLoc() and new cv.Circle() leave minLoc / maxLoc / center undefined, for the same reason; pass all arguments.
  • There is no new cv.Mat(size, type, scalar): Embind dispatches on argument count, so three arguments mean (rows, cols, type) and silently give a malformed Mat. Use new cv.Mat(rows, cols, type, scalar).
  • cv.sum() and the cv.SVD class are not bound; mat.sum() and cv.SVDecomp() are.
  • Type values follow OpenCV 5, whose channel shift is 5 rather than 3: cv.CV_8UC3 is 64, not 16. Use the constants, never literal numbers.
  • A failed assertion inside OpenCV throws RuntimeError: Aborted(undefined) with no detail (see Argument validation).

Color checker detection (MCC)

cv.mcc_CCheckerDetector finds Macbeth ColorChecker charts (MCC24), DigitalSG charts (SG140) and DKK charts (VINYL18) in a BGR image:

const detector = new cv.mcc_CCheckerDetector(); // CCheckerDetector::create()
detector.setColorChartType(cv.mcc_ColorChart.MCC24);
if (detector.process(bgr, 1)) {
  // bgr: CV_8UC3 in BGR order; 1 = number of charts to look for
  const checker = detector.getBestColorChecker();
  const box = checker.getBox(); // Point2fVector with the 4 corners
  const means = checker.getChartsRGB(false); // 24×1 CV_64FC3: mean RGB per patch
  // … use them, then:
  (box.delete(), means.delete(), checker.delete());
}
detector.delete();
  • checker.getChartsRGB() (with statistics) returns a CV_64F Mat with one row per patch and channel and five columns: pixel count, mean, standard deviation, minimum, maximum. getChartsYCbCr() has the same layout in YCbCr; getCenter() and getCost() are available too.
  • detector.processWithROI(bgr, rects, nc) searches only the given cv.RectVector.
  • Detection parameters: const p = new cv.mcc_DetectorParametersMCC(), set its fields (minImageSize, confidenceThreshold, …), then detector.setDetectionParams(p).
  • Not available: getListColorChecker() and draw() take or return a vector of checkers, which Embind has no binding for; getDetectionParams() cannot be bound. The create(net) constructor throws (Cannot construct mcc_CCheckerDetector due to unbound types) because dnn has no bindings, so detection always uses the classical detector.

A new detector looks for MCC24 charts until told otherwise. (Upstream 5.0.0 leaves the chart type uninitialized, so detection depended on leftover heap memory; build/patches/opencv-mcc-chart-type-init.patch fixes that.) test/unit/mcc.test.js detects a synthetic MCC24 chart and checks its position and every patch color.

FaceDetectorYN

cv.FaceDetectorYN (YuNet) replaces Haar-cascade face detection. Embind maps OpenCV's static create() to the constructor: new cv.FaceDetectorYN(modelPath, configPath, size, ...) (3–9 arguments), where modelPath is a path in the Emscripten virtual file system that you must write the ONNX model to first. This project has not verified this path (there is no model in the repository and no test covers it).

Quick reference

// Constructors
let mat = new cv.Mat(); // empty
mat = new cv.Mat(rows, cols, type);
mat = new cv.Mat(rows, cols, type, new cv.Scalar(b, g, r, a));
mat = new cv.Mat(new cv.Size(width, height), type);
mat = cv.matFromArray(rows, cols, type, array);
mat = cv.matFromImageData(ctx.getImageData(0, 0, w, h)); // browser, CV_8UC4
mat = cv.Mat.zeros(rows, cols, type); // also ones(), eye()

// Copy and convert
const copy = src.clone(); // deep copy
src.copyTo(dst, mask);
src.convertTo(dst, rtype, alpha, beta);

// Value types (plain JS classes in the glue)
new cv.Point(x, y);
new cv.Size(width, height);
new cv.Rect(x, y, width, height);
new cv.Scalar(v0, v1, v2, v3);

// Vectors
const vec = new cv.MatVector();
vec.push_back(mat);
const first = vec.get(0); // a new handle: delete it too
(first.delete(), vec.delete());

TypeScript

package.json points types at dist/index.d.ts; no @types/* package is needed:

import loadOpenCV = require("@haoking/opencvjs");

const cv = await loadOpenCV();
const mat = cv.matFromArray(3, 3, cv.CV_32FC1, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
const roi = mat.roiClone(new cv.Rect(1, 1, 2, 2)); // roi: Mat
const data: Float32Array = roi.data32F;
(mat.delete(), roi.delete());

The declarations are generated from the build (build/gen-types.js, run by npm run assemble): top-level symbols come from the runtime's Object.keys(cv) and Mat members from the whole prototype chain, and test/types/dts-consistency.test.js asserts that the declared and runtime symbol sets are equal in both directions. Hand-written OpenCV.js declarations in the ecosystem declare SIFT, PCA and FlannBasedMatcher, which this build lacks, and miss FaceDetectorYN, which it has — so code type-checks and then throws.

  • Guaranteed: every declared symbol exists and every existing symbol is declared (1634 top-level symbols and 75 Mat members in 4.0.0).
  • Not guaranteed: precise parameter types of native functions, which would need OpenCV's C++ signatures and Embind's overload rules. Native bindings without a hand-written signature are (...args: any[]): any; the extension layer has precise signatures.
  • Mat declares [Symbol.dispose](): void, so using type-checks. The file also declares SymbolConstructor.dispose, so projects on plain ES2022 libraries (without esnext.disposable or @types/node) compile as well.

Performance

SIMD speedups

Measured with npm run simd:compare: each build in its own process, the first round discarded, the minimum of two passes with alternating start order.

  • arm64: node v22.22.2 / darwin-arm64, the 4.0.0 build (Emscripten 6.0.12). The noise floor, calibrated with two identical binaries, is ±3%.
  • x86-64: node v24.21.0 / linux-x64, the 4.0.0 build, two runs of the CI verify job on shared GitHub Actions runners (not noise-calibrated); the ranges span both runs.
Operation arm64 x86-64 Upstream 2020
absdiff 8UC3 256² 14.0x 11.5–16.8x —
add 8UC1 256² 12.3x 8.6–10.6x —
resize 8UC4 256²→128² 4.4x 2.5–3.1x 1.77x
GaussianBlur 8UC1 256² k=5 3.3x 1.6–2.1x 3.36x
pyrDown 32FC4 256² 3.0x 3.0–3.4x 3.09x
Sobel 32FC1 256² 2.1x 2.1–2.3x —
blur 32FC1 256² k=5 1.6x 1.6–1.7x 0.519x
warpAffine 8UC1 256² 1.2x 4.3–4.8x —
cvtColor RGBA2GRAY 8UC4 256² 0.99x 0.63–0.82x —
roiClone 32² of 64² 1.00x 1.00–1.04x —
replaceMatOnRect 32² into 64² 1.04x 0.96–1.00x —
dft 32FC1 256² 0.95x 0.94–1.01x —
  • dft gains nothing from SIMD: 0.95x on arm64 and 0.94–1.01x on x86-64 here, 0.90x on the 3.x builds, 0.91x on OpenCV 4.14.0.
  • cvtColor RGBA2GRAY is slower with SIMD on x86-64 (0.63–0.82x here, 0.84x on OpenCV 4.14.0, 1.01x on the 3.x build) and neutral on arm64.
  • warpAffine gains far more on x86-64 than on arm64 because its baseline build is slow there (120–200 ms against 19 ms on arm64). On arm64, Emscripten 6.0.12 made the baseline 1.7x faster, which shrank the speedup from 2.1x to 1.2x.
  • Shared runners vary: the two x86-64 runs differ by up to 30% on single operations, so small differences there are noise.
  • Upstream's 2020 per-kernel data does not carry over: its counterexample, blur CV_32FC1 at 0.519x, is a speedup here, while pyrDown matches it. Measure the operations you care about.
  • The two extension-layer cases are within noise, as expected: they are a memory copy and a JS loop over the heap, neither of which -msimd128 vectorizes.

When to turn SIMD off

Rarely. If your workload is dominated by cv.dft() (including DFT_INVERSE), or by color conversion on x86-64, the baseline build is as fast or faster:

const cv = await require("@haoking/opencvjs")({ simd: false });

or OPENCV_SIMD=0 node app.js. The choice applies to the whole process — one build per process — so you also give up the large gains on absdiff, add and the filters. Measure with your own data before deciding.

Benchmarks

  • npm run bench runs two regression gates. region-ops compares roiClone() with the native roi() + clone() it wraps (64×64 CV_32FC1, 20000 calls). inplace-ops compares the five per-pixel write methods with a hand-written loop over the wasm heap, and prints per-pixel accessor loops (native *Ptr() and PTR()) as regression references, two orders of magnitude slower.
  • npm run simd:compare prints the table above for your machine. It never fails because an operation is slower with SIMD — some are.

Known issues

  • Removed in OpenCV 5. cv.CascadeClassifier, HOGDescriptor, AKAZE, BRISK, KAZE, AgastFeatureDetector and groupRectangles do not exist since 3.0.0; code using them throws TypeError. This is not a whitelist choice: OpenCV 5 deleted them from its C++ API, together with the Haar cascade data files. For face detection see FaceDetectorYN; for feature points ORB, MSER, FastFeatureDetector, GFTTDetector and SimpleBlobDetector remain.
  • CV_32S overflow in addConstant / constantSubtract / mulConstant / constantDivide gives INT_MIN instead of saturating (2147483647 + 10 is -2147483648). That is OpenCV's semantics: saturate_cast<int>(double) is cvRound, which yields 0x80000000 out of range on wasm and x86 alike. The in-place writes do not go through OpenCV and saturate correctly.
  • dftSplit() is unverified and deprecated; see dftSplit.
  • The browser path is not tested. CI runs Node 22 / 24 / 26 only. The npm package is the split build (opencv.js glue + opencv_js.wasm) plus a CommonJS extension layer: in a browser, use a bundler for the extension layer; a plain <script> gets native OpenCV only. See also Single-file build.
  • SIMD is slower for dft (see Performance).
  • Releases up to 3.0.0: the simd build rounded floats to integers incorrectly (upstream's WebAssembly SIMD v_round is trunc(x + 0.5): negative results were 1 too high and halves rounded up). Affected kernels include addWeighted, scaling convertTo, divide, multiply, 16S filters and the scalar extension methods. Fixed in 4.0.0 by build/patches/opencv-wasm-v_round.patch; on 3.0.0, use { simd: false } for signed integer data.

Building from source

The wasm artifacts are not in git. To build them and assemble dist/ (Docker required):

./build/build.sh                # → build/out/baseline/    opencv.js + opencv_js.wasm
./build/build.sh --simd         # → build/out/simd/        opencv.js + opencv_js.wasm
./build/build.sh --single-file  # → build/out/singlefile/  opencv.js with the wasm embedded
npm run assemble                # build/out/{baseline,simd} + src/js/ → dist/ (with index.d.ts)
npm test
npm run bench
npm run simd:compare

npm run assemble -- <baseline dir> <simd dir> takes the artifact directories as arguments (for example ones downloaded from CI). The simd directory may be missing locally; dist/ then holds baseline only and the parity tests skip. Set OPENCV_REQUIRE_SIMD=1 to make a missing simd build an error, as CI does.

Build inputs

  • OpenCV: the tag in build/opencv-version.txt (5.0.0), which must resolve to the commit in build/opencv-commit.txt; the build fails otherwise.
  • Toolchain: build/Dockerfile pins emscripten/emsdk:6.0.12 by image digest.
  • Whitelist: src/config/opencv_js.config.py, upstream's file with four documented changes (see its header; ruff.toml explains how to diff it against upstream).
  • Patches applied to the OpenCV sources (build/patches/; each file explains itself):
    • opencv-js-deep-clone.patch keeps clone() a deep copy — with Emscripten ≥ 4.0.0, upstream's override never runs and clone() is shallow (opencv#30408).
    • opencv-js-link-flags.patch exports the runtime symbols the extension layer and users rely on (HEAP*, Embind error classes, flushPendingDeletes / setDelayFunction, instance counters, FS_createPreloadedFile) and raises the memory limit to 2 GB.
    • opencv-js-mcc-bindings.patch lets the MCC bindings compile (opencv#30409, together with the whitelist names).
    • opencv-mcc-chart-type-init.patch initializes the MCC detector's chart type (opencv#30410).
    • opencv-wasm-v_round.patch makes the SIMD build round like cvRound.

build/fingerprint.sh hashes all of these inputs into a 16-character fingerprint.

CI

  • build-wasm.yml (on v* tags, or dispatched by hand) builds the three variants in parallel, smoke-tests each, and uploads them as opencv-wasm-<variant>-<fingerprint>. A verify job then assembles baseline + simd, runs the whole suite on both, checks variant parity and prints npm run simd:compare.
  • ci.yml (on pushes and pull requests) downloads the artifacts whose fingerprint matches the commit under test — only from this repository's own build-wasm.yml runs started by workflow_dispatch or a tag push, never from a pull request's run — assembles dist/, runs the suite once per build on Node 22 / 24 / 26, and runs the benchmark gates on Node 24. If no artifacts match — the build inputs just changed — it fails and asks for a build-wasm.yml run.

Single-file build

./build/build.sh --single-file embeds the wasm in opencv.js, producing one file (about 13 MB) that a browser <script> can load without hosting a separate .wasm.

  • The wasm is embedded as a UTF-8–encoded binary string (Emscripten's default since 4.0.18, about 17% smaller than base64), so the script must be decoded as UTF-8: serve it as text/javascript; charset=utf-8, or load it from a page with <meta charset="utf-8"> or with <script charset="utf-8">.
  • It is the baseline build without SIMD, because a <script> include has no fallback for engines without SIMD.
  • It contains native OpenCV only, without the extension layer.
  • It is not in the npm package. Get it from the CI artifact opencv-wasm-singlefile-<fingerprint> of a build-wasm.yml run, from a GitHub Release if the maintainer attached it (this is a manual step), or build it yourself.

Testing

npm test runs node:test on the assembled dist/ — the code users install — and fails right away if dist/ is out of sync with src/js/. 251 tests in 4.0.0:

  • region copies across 7 depths × 4 channel counts × 3 methods, and clone() deep-copy semantics;
  • regression tests for every fixed defect (native views, saturation, norm2, constantDivide, heap growth, getter-based rects, …);
  • argument validation: the shape of every error and the absence of partial writes;
  • SIMD detection, single-build loading, .d.ts-versus-runtime consistency, the artifact's OpenCV version;
  • MCC color checker detection on a synthetic chart;
  • baseline-versus-simd output parity on 16 operations (bit-exact for integer kernels and the extension layer; for float kernels max|a − b| ≤ 1e-5 × max(‖baseline‖∞, 1), OpenCV's own criterion);
  • a smoke test of a freshly built artifact (exports, deep clone(), Symbol.dispose, vector iteration, a 1.25 GB allocation).
Variable Effect
OPENCV_DIST=<dir> test another assembled dist/
OPENCV_SIMD=0 / 1 force a build (npm run test:baseline / npm run test:simd)
OPENCV_PARITY_REQUIRED=1 fail instead of skipping the parity tests when dist/simd/ is missing
OPENCV_ARTIFACT=<dir> run the smoke test on a raw artifact directory
OPENCV_SMOKE_REQUIRED=1 fail instead of skipping the smoke test when no artifact is given
OPENCV_SMOKE_SINGLE_FILE=1 the artifact is the single-file variant

Upgrading

  • From 3.x: see the 4.0.0 entry in CHANGELOG.md — Node.js 22+, newer browsers, a different abort shape and a few runtime symbols removed from cv.
  • From 1.x: see docs/MIGRATION-2.0.md, then the CHANGELOG.

License

MIT. See LICENSE. Bug reports and pull requests are welcome on GitHub.

About

Complete opencvjs (With the lastest OpenCV 4.0.0+)

Topics

Resources

Stars

55 stars

Watchers

7 watching

Forks

Releases

Packages

Contributors

Languages