@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
-msimd128build and a non-SIMD fallback ship together;loadOpenCV()probes the engine and loads the right one. - 19 extra
Matmethods andcv.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/RangeErrornaming 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."
- Installation
- Requirements
- Loading and variant selection
- Memory management
- Extension API
- Argument validation
- Native OpenCV API
- TypeScript
- Performance
- Known issues
- Building from source
- Testing
- Upgrading
- License
npm install @haoking/opencvjsconst 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.
- 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.
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_SIMDvalue such asturethrows too. - One build per process. OpenCV's UMD wrapper leaks
Moduleas 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, asnpm run simd:comparedoes. - Each build lives in its own directory because Emscripten bakes the file name
opencv_js.wasminto the glue at compile time. Keep everyopencv.jsnext to its ownopencv_js.wasm, under that name.
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 theusingdeclaration (for example Node.js 24). Every Embind object has[Symbol.dispose].mat.deleteLater()only queues the object; it is freed whencv.flushPendingDeletes()runs, or by the scheduler you install withcv.setDelayFunction(fn).
{
using m = cv.matFromArray(2, 2, cv.CV_8UC1, [1, 2, 3, 4]);
console.log(m.sum()); //10
} // m.delete() runs hereEmbind 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).
| 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.
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.
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,NaNbecomes 0 (250 + 20onCV_8Uis255, not14). Floating-point Mats are written as is. - Only channel 0 is written, except by
replaceMatOnRow, which writes every channel of the row. - When
srcshares memory with the Mat being written (the same Mat, or overlapping views of one parent),srcis 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();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() 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(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() 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() 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.
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.
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:
- Native failures carry no information. This build compiles C++ exception catching
out, so a failing
CV_Assertinside OpenCV aborts. With Emscripten 6.0.12 every such failure throws the sameRuntimeError: Aborted(undefined). Build with -sASSERTIONS for more info.and printsAborted(undefined)to stderr — no function, argument or value. (The module stays usable afterwards.) - 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-rangeRector 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());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.mulSpectrumsandcv.SVDecompare added.- The dnn bindings are removed (
cv.readNet*areundefined); 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.
All verified on this build:
cv.norm(a, b, normType)does not exist (see cv.norm2); usecv.norm2.cv.Scalar.all(v)throwsTypeError: this.push is not a function(the glue calls the constructor withoutnew); usenew cv.Scalar(v, v, v, v).new cv.MinMaxLoc()andnew cv.Circle()leaveminLoc/maxLoc/centerundefined, 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. Usenew cv.Mat(rows, cols, type, scalar). cv.sum()and thecv.SVDclass are not bound;mat.sum()andcv.SVDecomp()are.- Type values follow OpenCV 5, whose channel shift is 5 rather than 3:
cv.CV_8UC3is64, not16. Use the constants, never literal numbers. - A failed assertion inside OpenCV throws
RuntimeError: Aborted(undefined)with no detail (see Argument validation).
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()andgetCost()are available too.detector.processWithROI(bgr, rects, nc)searches only the givencv.RectVector.- Detection parameters:
const p = new cv.mcc_DetectorParametersMCC(), set its fields (minImageSize,confidenceThreshold, …), thendetector.setDetectionParams(p). - Not available:
getListColorChecker()anddraw()take or return a vector of checkers, which Embind has no binding for;getDetectionParams()cannot be bound. Thecreate(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.
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).
// 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());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. Matdeclares[Symbol.dispose](): void, sousingtype-checks. The file also declaresSymbolConstructor.dispose, so projects on plain ES2022 libraries (withoutesnext.disposableor@types/node) compile as well.
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
verifyjob 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 | — |
dftgains 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.cvtColorRGBA2GRAY 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.warpAffinegains 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,
blurCV_32FC1 at 0.519x, is a speedup here, whilepyrDownmatches 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
-msimd128vectorizes.
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.
npm run benchruns two regression gates.region-opscomparesroiClone()with the nativeroi()+clone()it wraps (64×64CV_32FC1, 20000 calls).inplace-opscompares the five per-pixel write methods with a hand-written loop over the wasm heap, and prints per-pixel accessor loops (native*Ptr()andPTR()) as regression references, two orders of magnitude slower.npm run simd:compareprints the table above for your machine. It never fails because an operation is slower with SIMD — some are.
- Removed in OpenCV 5.
cv.CascadeClassifier,HOGDescriptor,AKAZE,BRISK,KAZE,AgastFeatureDetectorandgroupRectanglesdo not exist since 3.0.0; code using them throwsTypeError. 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 pointsORB,MSER,FastFeatureDetector,GFTTDetectorandSimpleBlobDetectorremain. CV_32Soverflow inaddConstant/constantSubtract/mulConstant/constantDividegivesINT_MINinstead of saturating (2147483647 + 10is-2147483648). That is OpenCV's semantics:saturate_cast<int>(double)iscvRound, which yields0x80000000out 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.jsglue +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_roundistrunc(x + 0.5): negative results were 1 too high and halves rounded up). Affected kernels includeaddWeighted, scalingconvertTo,divide,multiply, 16S filters and the scalar extension methods. Fixed in 4.0.0 bybuild/patches/opencv-wasm-v_round.patch; on 3.0.0, use{ simd: false }for signed integer data.
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:comparenpm 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.
- OpenCV: the tag in
build/opencv-version.txt(5.0.0), which must resolve to the commit inbuild/opencv-commit.txt; the build fails otherwise. - Toolchain:
build/Dockerfilepinsemscripten/emsdk:6.0.12by image digest. - Whitelist:
src/config/opencv_js.config.py, upstream's file with four documented changes (see its header;ruff.tomlexplains how to diff it against upstream). - Patches applied to the OpenCV sources (
build/patches/; each file explains itself):opencv-js-deep-clone.patchkeepsclone()a deep copy — with Emscripten ≥ 4.0.0, upstream's override never runs andclone()is shallow (opencv#30408).opencv-js-link-flags.patchexports 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.patchlets the MCC bindings compile (opencv#30409, together with the whitelist names).opencv-mcc-chart-type-init.patchinitializes the MCC detector's chart type (opencv#30410).opencv-wasm-v_round.patchmakes the SIMD build round likecvRound.
build/fingerprint.sh hashes all of these inputs into a 16-character fingerprint.
build-wasm.yml(onv*tags, or dispatched by hand) builds the three variants in parallel, smoke-tests each, and uploads them asopencv-wasm-<variant>-<fingerprint>. Averifyjob then assembles baseline + simd, runs the whole suite on both, checks variant parity and printsnpm 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 ownbuild-wasm.ymlruns started byworkflow_dispatchor a tag push, never from a pull request's run — assemblesdist/, 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 abuild-wasm.ymlrun.
./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 abuild-wasm.ymlrun, from a GitHub Release if the maintainer attached it (this is a manual step), or build it yourself.
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 |
- 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.
MIT. See LICENSE. Bug reports and pull requests are welcome on GitHub.