img2num
Version:
Img2Num is a raster vectorization library - it converts images to SVGs
515 lines (514 loc) • 17.5 kB
JavaScript
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
//#region src/imageToUint8ClampedArray.js
/**
* @packageDocumentation
* Convenience image conversion utility to ensure type compatibility with the library.
*
* @file Convenience utility function.
*
* @module image-utils
* @license MIT
* @copyright Ryan Millard 2026
* @author Ryan Millard
* @since 0.0.0
*
* @exports imageToUint8ClampedArray
*/
/**
* @summary Convert an image file into a `Uint8ClampedArray` of pixel data (RGBA).
*
* @function imageToUint8ClampedArray
* @async
* @description
* Reads an image file (PNG, JPEG, etc.) and returns its pixel data as a `Uint8ClampedArray`.
* Each pixel consists of four consecutive values: red, green, blue, and alpha (RGBA).
* Also returns the image's original width and height. Useful for canvas operations,
* image processing, WebGL textures, or computer vision tasks.
*
*
* @param {File} file - The image file to process. Must be a valid `File` object, e.g., from an `<input type="file">` element.
*
* @returns {Promise<{pixels: Uint8ClampedArray, width: number, height: number}>}
* A Promise resolving to an object containing:
* - `pixels`: A `Uint8ClampedArray` of RGBA pixel values.
* - `width`: Width of the image in pixels.
* - `height`: Height of the image in pixels.
*
* @throws {Error} Will not throw in current implementation, but could reject if the image fails to load.
*
* @example
* const fileInput = document.querySelector("#fileInput");
* fileInput.addEventListener("change", async (event) => {
* const file = event.target.files[0];
* const { pixels, width, height } = await imageToUint8ClampedArray(file);
* console.log("Width:", width, "Height:", height);
* console.log("Pixels:", pixels);
* });
*
* @todo Add error handling for invalid or corrupt image files.
* @variation Standard image file input
*/
function imageToUint8ClampedArray(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const { data } = ctx.getImageData(0, 0, img.width, img.height);
resolve({
pixels: data,
width: img.width,
height: img.height
});
};
img.src = URL.createObjectURL(file);
});
}
//#endregion
//#region src/target/node/worker.js
var __dirname = dirname(fileURLToPath(import.meta.url));
async function createWorker() {
const worker = new Worker(resolve(__dirname, "./wasmWorker.js"));
return {
postMessage: (msg) => worker.postMessage(msg),
onMessage: (fn) => worker.on("message", fn),
onError: (fn) => worker.on("error", fn),
terminate: () => worker.terminate()
};
}
//#endregion
//#region src/wasmClient.js
/**
* @packageDocumentation
* Advanced low-level interface for communicating with the WASM worker.
* Provides granular control over calling WASM functions asynchronously,
* handling memory transfers, and managing worker lifecycle.
*
* @file Manages function call requests to the WASM worker.
* @internal
*
* @module wasm-client
* @license MIT
* @copyright Ryan Millard 2026
* @author Ryan Millard
* @since 0.0.0
* @description This module provides low-level image processing functions using WASM.
* You must specify exact C++ function signatures and manage the input data carefully.
* This allows more granular control.
*/
/**
* Worker instance that handles communication with the WASM module.
* @type {Worker | null}
* @private
*/
var worker;
/**
* Incremental ID counter for correlating requests and responses.
* @type {number}
* @private
*/
var idCounter = 0;
/**
* Maps request IDs to their corresponding promise callbacks.
* @type {Map<number, {resolve: Function, reject: Function}>}
* @private
*/
var callbacks = /* @__PURE__ */ new Map();
/**
* Flag to ensure the WASM worker is initialized only once.
* @type {boolean}
* @private
*/
var initialized = false;
/**
* Handle response messages from the WASM worker.
* @param {{ id: number, output?: any, returnValue?: any, error?: string }} data
*/
function handleMessage(data) {
const cb = callbacks.get(data.id);
if (!cb) return;
callbacks.delete(data.id);
if (data.error) cb.reject(new Error(data.error));
else cb.resolve({
output: data.output,
returnValue: data.returnValue
});
}
/**
* @summary Initialize the WASM worker.
*
* @description
* Sets up message and error handlers. Safe to call multiple times;
* subsequent calls are no-ops. After initialization, functions can be called
* via {@link callWasm}.
*
* @function initWasmWorker
*
* @example
* import { initWasmWorker } from "./wasmClient.js";
*
* initWasmWorker();
*
* @since 0.0.0
*/
async function initWasmWorker() {
if (initialized) return;
worker = await createWorker();
worker.onMessage(handleMessage);
worker.onError((event) => {
const output = event.message || "WASM worker error";
const err = /* @__PURE__ */ new Error(`[Img2Num wasmClient] Error: ${output}`);
for (const [_id, cb] of callbacks) cb.reject(err);
callbacks.clear();
});
initialized = true;
}
/**
* @summary Call a function in the WASM worker.
*
* @description
* Directly send a request to the WASM worker to call the specified function,
* passing specific buffers and and arguments.
*
* @async
* @function callWasm
* @param {Object} __named_parameters - Options for the WASM call.
* @property {string} __named_parameters.funcName - The name of the WASM function to invoke.
* @property {Object} [__named_parameters.args={}] - Named arguments to pass to the WASM function.
* @property {string[]} [__named_parameters.bufferKeys=[]] - Keys of arguments that should be transferred as ArrayBuffers.
* @property {string} [__named_parameters.returnType="void"] - Expected return type.
*
* @returns {Promise<{output: any, returnValue: any}>} Resolves with the result of the WASM function call.
*
* @throws {Error} If the worker has not been initialized.
*
* @example
* import { callWasm, initWasmWorker } from "./wasmClient.js";
*
* initWasmWorker();
*
* const result = await callWasm({
* funcName: "gaussian_blur_fft",
* args: { pixels, width, height, sigma_pixels: 5 },
* bufferKeys: ["pixels"],
* returnType: "Uint8ClampedArray"
* });
* console.log(result.output);
*
* @since 0.0.0
*/
async function callWasm({ funcName, args = {}, bufferKeys = [], returnType = "void" }) {
if (!initialized) throw new Error("WASM worker not initialized. Call initWasmWorker() first.");
const id = idCounter++;
return new Promise((resolve, reject) => {
callbacks.set(id, {
resolve,
reject
});
worker.postMessage({
id,
funcName,
args,
bufferKeys,
returnType
});
});
}
//#endregion
//#region src/safeWasmWrappers.js
/**
* @packageDocumentation
* High-level image operations exposed via WASM.
*
* The exports defined here abstract away the manual memory management required
* when importing raw WASM functions, making them more JavaScript-friendly.
*
* @file Safely wraps unsafe WASM (C++) function calls.
*
* @module image-wasm
* @license MIT
* @copyright Ryan Millard 2026
* @author Ryan Millard
* @since 0.0.0
* @description This module provides high-level image processing functions using WASM.
* Each function handles memory management and exposes a JavaScript-friendly API.
*/
await initWasmWorker();
/**
* @summary Apply a Gaussian blur to an image using FFT in WASM.
*
* @description
* Takes a Uint8ClampedArray and its dimensions and applies a Gaussian blur on the Uint8ClampedArray image.
* The `sigma_pixels` parameter determines the blur radius and has a dynamic default value equal to 5% of the image's width.
* Useful for denoising images by applying a low-pass filter. Sped up by a 2-D FFT.
*
* @async
* @function gaussianBlur
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - The image pixel data (flat RGBA array).
* @param {number} options.width - The width of the image.
* @param {number} options.height - The height of the image.
* @param {number} [options.sigma_pixels=width*0.005] - Standard deviation of the Gaussian blur (default=width*0.005; 5% of width).
* @returns {Promise<Uint8ClampedArray>} The blurred image pixels.
* @throws {Error} If the WASM function fails or memory allocation fails.
* @example
* const blurred = await gaussianBlur({ pixels, width, height });
* @todo Fix FFT zero-padding bug around edges of the image.
* @variation Standard Gaussian blur using FFT
* @since 0.0.0
*/
var gaussianBlur = async ({ pixels, width, height, sigma_pixels = width * .005 }) => {
return (await callWasm({
funcName: "gaussian_blur_fft",
args: {
pixels,
width,
height,
sigma_pixels
},
bufferKeys: [{
key: "pixels",
type: "Uint8ClampedArray"
}]
})).output.pixels;
};
/**
* @summary Apply a bilateral filter to an image using WASM.
*
* @description
* Takes a Uint8ClampedArray and its dimensions and applies a bilateral filter on the Uint8ClampedArray image.
* The `sigma_spatial` and `sigma_range` set weights to the respective Gaussian kernels applied to spatial (x, y) and range (color) data -
* they both have recommended default values applied.
* The default `color_space` is 0, which is CIE LAB, but sRGB can be chosen by setting `color_space` = 1. CIE LAB is more
* accurate, but sRGB is slightly faster.
*
* @async
* @function bilateralFilter
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - The image pixel data (flat RGBA array).
* @param {number} options.width - The width of the image.
* @param {number} options.height - The height of the image.
* @param {number} [options.sigma_spatial=3] - Spatial standard deviation.
* @param {number} [options.sigma_range=50] - Range (color) standard deviation.
* @param {number} [options.color_space=0] - Color space mode (0: CIE LAB; 1: sRGB).
* @returns {Promise<Uint8ClampedArray>} The filtered image pixels.
* @throws {Error} If the WASM function fails.
* @example
* const filtered = await bilateralFilter({ pixels, width, height });
* @variation Standard bilateral filter with default parameters
* @since 0.0.0
*/
var bilateralFilter = async ({ pixels, width, height, sigma_spatial = 3, sigma_range = 50, color_space = 0 }) => {
return (await callWasm({
funcName: "bilateral_filter",
args: {
pixels,
width,
height,
sigma_spatial,
sigma_range,
color_space
},
bufferKeys: [{
key: "pixels",
type: "Uint8ClampedArray"
}]
})).output.pixels;
};
/**
* @summary Apply a black-biased threshold filter to reduce colors in an image.
*
* @description
* Apply a simple sRGB bin-based threshold on the Uint8ClampedArray image.
* The bins in this function are determined by the `num_colors` parameter.
*
* @async
* @function blackThreshold
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - The image pixel data (flat RGBA array).
* @param {number} options.width - The width of the image.
* @param {number} options.height - The height of the image.
* @param {number} options.num_colors - Number of colors to reduce the image to.
* @returns {Promise<Uint8ClampedArray>} The thresholded image pixels.
* @throws {Error} If the WASM function fails.
* @example
* const thresholded = await blackThreshold({ pixels, width, height, num_colors: 16 });
* @see {@link https://en.wikipedia.org/wiki/Color_quantization|Color Quantization Wiki}
* @todo Support different bias levels for black/white thresholds.
* @variation Black-biased threshold with customizable number of colors
* @since 0.0.0
*/
var blackThreshold = async ({ pixels, width, height, num_colors }) => {
return (await callWasm({
funcName: "black_threshold_image",
args: {
pixels,
width,
height,
num_colors
},
bufferKeys: [{
key: "pixels",
type: "Uint8ClampedArray"
}]
})).output.pixels;
};
/**
* @summary Cluster pixels using the K-Means algorithm in WASM.
*
* @description
* Apply a standard K-Means clustering algorithm to the input image in the specified `color_space`
* (default is 0: CIE LAB, but 1: sRGB can be use) using pre-specified maximum color and iteration counts.
* You can provide the `out_pixels` and `out_labels` arrays,
* however this is atypical in JavaScript (since it is modified in-place and you will need to allocate a sufficiently large array),
* so it is recommended to use the default arguments and returns.
*
* @async
* @function kmeans
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - Original image pixels.
* @param {Uint8ClampedArray} [options.out_pixels=new Uint8ClampedArray(pixels.length)] - Output pixels array.
* @param {Int32Array} [options.out_labels=new Int32Array(pixels.length/4)] - Output labels array.
* @param {number} options.width - Image width.
* @param {number} options.height - Image height.
* @param {number} options.num_colors - Number of color clusters.
* @param {number} [options.max_iter=100] - Maximum number of iterations.
* @param {number} [options.color_space=0] - Color space mode.
* @returns {Promise<{pixels: Uint8ClampedArray, labels: Int32Array}>} Clustered pixels and labels.
* @throws {Error} If the WASM function fails or iterations do not converge.
* @example
* const { pixels: clusteredPixels, labels } = await kmeans({ pixels, width, height, num_colors: 8 });
* @variation K-means clustering with default color space
* @since 0.0.0
*/
var kmeans = async ({ pixels, out_pixels = new Uint8ClampedArray(pixels.length), out_labels = new Int32Array(pixels.length / 4), width, height, num_colors, max_iter = 100, color_space = 0 }) => {
const result = await callWasm({
funcName: "kmeans",
args: {
pixels,
out_pixels,
out_labels,
width,
height,
num_colors,
max_iter,
color_space
},
bufferKeys: [
{
key: "pixels",
type: "Uint8ClampedArray"
},
{
key: "out_pixels",
type: "Uint8ClampedArray"
},
{
key: "out_labels",
type: "Int32Array"
}
]
});
return {
pixels: result.output.out_pixels,
labels: result.output.out_labels
};
};
/**
* @summary Convert labeled regions to SVG contours.
*
* @description
* Convert an input image and its labeled regions into an SVG.
*
* @async
* @function findContours
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - Original image pixels.
* @param {Int32Array} options.labels - Label array from clustering (e.g., K-Means) or segmentation.
* @param {number} options.width - Image width.
* @param {number} options.height - Image height.
* @param {number} [options.min_area=100] - Minimum area of a region to be considered a contour.
* @param {number} [options.min_thickness=10] - Minimum thickness of a region to be considered a contour.
* @returns {Promise<{svg: string}>} Generated SVG.
* @throws {Error} If the WASM function fails or input labels are invalid.
* @example
* const { svg } = await findContours({ pixels, labels, width, height });
* @variation Converts labeled (from a clustering algorithm, e.g. K-Means) image into an SVG.
* @since 0.0.0
*/
var findContours = async ({ pixels, labels, width, height, min_area = 100, min_thickness = 10 }) => {
return { svg: (await callWasm({
funcName: "labels_to_svg",
args: {
pixels,
labels,
width,
height,
min_area,
min_thickness
},
bufferKeys: [{
key: "pixels",
type: "Uint8ClampedArray"
}, {
key: "labels",
type: "Int32Array"
}],
returnType: "string"
})).returnValue };
};
/**
* @summary Convert raster images (e.g., JPEG, PNG) to SVGs.
*
* @description
* Convert an input raster image into an SVG. A unification of `bilateralFilter`, `kmeans`, and `findContours`.
*
* @async
* @function imageToSvg
* @param {Object} options - The input options.
* @param {Uint8ClampedArray} options.pixels - Original image pixels.
* @param {number} options.width - Image width.
* @param {number} options.height - Image height.
* @param {number} [options.sigma_spatial=3] - Spatial standard deviation.
* @param {number} [options.sigma_range=50] - Range (color) standard deviation.
* @param {number} [options.num_colors=16] - Number of color clusters.
* @param {number} [options.max_iter=100] - Maximum number of iterations.
* @param {number} [options.min_area=100] - Minimum area of a region to be considered a contour.
* @param {number} [options.min_thickness=10] - Minimum thickness of a region to be considered a contour.
* @param {number} [options.color_space=0] - Color space mode.
* @returns {Promise<{svg: string}>} Generated SVG.
* @throws {Error} If the WASM function fails or input labels are invalid.
* @example
* const { svg } = await findContours({ pixels, labels, width, height });
* @variation Convert a raster image (e.g., PNG, JPG) into an SVG.
* @since 0.0.0
*/
var imageToSvg = async ({ pixels, width, height, sigma_spatial = 3, sigma_range = 50, num_colors = 16, max_iter = 100, min_area = 100, min_thickness = 10, color_space = 0 }) => {
return { svg: (await callWasm({
funcName: "image_to_svg",
args: {
pixels,
width,
height,
sigma_spatial,
sigma_range,
num_colors,
max_iter,
min_area,
min_thickness,
color_space
},
bufferKeys: [{
key: "pixels",
type: "Uint8ClampedArray"
}],
returnType: "string"
})).returnValue };
};
//#endregion
export { bilateralFilter, blackThreshold, findContours, gaussianBlur, imageToSvg, imageToUint8ClampedArray, kmeans };