-
Notifications
You must be signed in to change notification settings - Fork 6
Image processing (Node)
JavaScript | PHP
In this example we're going to build a server-side app that apply a filter on an image. It'll be a real server this time, one that accepts requests from the browser and sends different images based on parameters in the URL.
First, we initialize the project and add in the necessary modules:
mkdir image
cd image
npm init -y
npm install fastify sharp node-zigar
mkdir src zigWe'll be using Fastify, a modern alternative to Express.js, and Sharp, a popular image processing library.
After creating the basic skeleton, create scale.zig in the sub-directory zig:
const std = @import("std");
const zigar = @import("zigar");
pub fn scale(image_in: zigar.image.Any(.ro), image_out: zigar.image.Any(.rw)) void {
const Pixel = @Vector(4, f32);
inline for (zigar.image.formats) |tag| {
if (image_in == tag and image_out == tag) {
const in = image_in.getField(tag);
const out = image_out.getField(tag);
const x_adv: f32 = in.getWidthAsFloat() / out.getWidthAsFloat();
const y_adv: f32 = in.getHeightAsFloat() / out.getHeightAsFloat();
var coord: @Vector(2, f32) = undefined;
coord[1] = 0.5;
for (0..out.getHeight()) |y| {
coord[0] = 0.5;
for (0..out.getWidth()) |x| {
const pixel = in.sampleLinear(Pixel, coord);
out.setPixel(Pixel, x, y, pixel);
coord[0] += x_adv;
}
coord[1] += y_adv;
}
}
}
}The code above is a function that enlarges or shrinks an image. zigar.image.Any is a parametric union type that can accommodate either a PHP GD image or a JavaScript ImageData object. An inline loop is used here to generate different binaries for different formats from the same source code.
Now on to the JavaScript side. In src create index.js:
import Fastify from 'fastify';
import Sharp from 'sharp';
import { fileURLToPath } from 'url';
import { scale } from '../zig/scale.zig';
const fastify = Fastify();
fastify.get('/scale/:width/:height', async (req, reply) => {
reply.type('image/jpeg');
const url = new URL(`./sample.png`, import.meta.url);
const path = fileURLToPath(url);
// open image and get raw data
const buffer = await Sharp(path).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const inputImage = {
data: buffer.data,
width: buffer.info.width,
height: buffer.info.height,
};
// allocate memory for output image
const width = parseInt(req.params.width);
const height = parseInt(req.params.height);
const size = width * height * 4;
const data = new Uint8Array(size);
const outputImage = { data, width, height };
// pass input and output image to Zig function
scale(inputImage, outputImage);
const info = { ...buffer.info, width, height, size };
// compress output as JPEG and return buffer to Fastify
return await Sharp(data, { raw: info }).jpeg().toBuffer();
});
const address = await fastify.listen({ hostname: 'localhost', port: 8080 });
console.log(`Listening at ${address}`);To get our app to run, add the following to package.json:
"type": "module",
"scripts": {
"start": "node --loader=node-zigar --no-warnings src/index.js"
},Finally, download the following image into src (or choose an image of your own):

We are ready to start the server:
npm run start
Open a web browser tab and navigate to http://localhost:8080/scale/400/300
It'll take a moment for the module to compile. You should see a notification in the terminal while the compile runs. Once that's done, you should see a scaled version of the image in the browser.
Change the URL to http://localhost:8080/scale/800/600 and you'll see an enlarged image. Change it to http://localhost:8080/scale/800/100 and you'll see a squashed version.
In this section, we're going create a function that apply a sepia effect on an image. In the
sub-directory zig, create sepia.zig:
const std = @import("std");
const zigar = @import("zigar");
pub fn apply(image_in: zigar.image.Any(.ro), image_out: zigar.image.Any(.rw), intensity: f32) void {
const Pixel = @Vector(4, f32);
inline for (zigar.image.formats) |tag| {
if (image_in == tag and image_out == tag) {
const in = image_in.getField(tag);
const out = image_out.getField(tag);
var coord: @Vector(2, f32) = undefined;
coord[1] = 0.5;
for (0..out.getHeight()) |y| {
coord[0] = 0.5;
for (0..out.getWidth()) |x| {
const yiq_matrix: [4]@Vector(4, f32) = .{
.{ 0.299, 0.596, 0.212, 0.0 },
.{ 0.587, -0.275, -0.523, 0.0 },
.{ 0.114, -0.321, 0.311, 0.0 },
.{ 0.0, 0.0, 0.0, 1.0 },
};
const inverse_yiq: [4]@Vector(4, f32) = .{
.{ 1.0, 1.0, 1.0, 0.0 },
.{ 0.956, -0.272, -1.1, 0.0 },
.{ 0.621, -0.647, 1.7, 0.0 },
.{ 0.0, 0.0, 0.0, 1.0 },
};
const rgba_color = in.sampleNearest(@Vector(4, f32), coord);
var yiqa_color = @"M * V"(yiq_matrix, rgba_color);
yiqa_color[1] = intensity;
yiqa_color[2] = 0.0;
const pixel = @"M * V"(inverse_yiq, yiqa_color);
out.setPixel(Pixel, x, y, pixel);
coord[0] += 1;
}
coord[1] += 1;
}
}
}
}
fn @"M * V"(m1: anytype, v2: anytype) @TypeOf(v2) {
const ar = @typeInfo(@TypeOf(m1)).array;
var t1: @TypeOf(m1) = undefined;
inline for (m1, 0..) |column, c| {
inline for (0..ar.len) |r| {
t1[r][c] = column[r];
}
}
var result: @TypeOf(v2) = undefined;
inline for (t1, 0..) |column, c| {
result[c] = @reduce(.Add, column * v2);
}
return result;
}The working principle of the filter is quite simple. We transform each pixel of the image from RGB color space into YIQ, the color space used for NTSC broadcast. Acting like an old television set, we toss away the chromatic components (I and Q). Then we add a yellowish tint by assigning a specific value to I.

As you can see in the picture above depicting the YIQ color space at Y = 0.5, the I channel represents the reddish orange color:
The @"M * V" function might look odd to you. Zig allows identifiers to contain whitespaces and
special characters using the @"..." escape sequence. We're taking advantage of that here to
clearly indicate what the function does, namely multipling a matrix with a vector.
Loops are unrolled using the inline keyword to enhance performance.
In index.js, import the new function:
import { sepia } from '../zig/sepia.zig';And add another handler:
fastify.get('/sepia/:intensity', async (req, reply) => {
reply.type('image/jpeg');
const url = new URL(`./sample.png`, import.meta.url);
const path = fileURLToPath(url);
const buffer = await Sharp(path).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const inputImage = {
data: buffer.data,
width: buffer.info.width,
height: buffer.info.height,
};
const { width, height, size } = buffer.info;
const data = new Uint8Array(size);
const outputImage = { data, width, height };
const intensity = parseFloat(req.params.intensity);
apply(inputImage, outputImage, intensity);
const info = { ...buffer.info, width, height, size };
return await Sharp(data, { raw: info }).jpeg().toBuffer();
});Restart the demo application and then navigate to http://localhost:8080/src/sepia/0.3. After a brief pause, the following should appear:

Follow the same steps as described in the the hello world example. First change the import statements:
import { scale } from '../lib/scale.zigar';
import { apply } from '../lib/sepia.zigar';Then create node-zigar.config.json:
{
"optimize": "ReleaseSmall",
"modules": {
"lib/scale.zigar": {
"source": "zig/scale.zig"
},
"lib/sepia.zigar": {
"source": "zig/sepia.zig"
}
},
"targets": [
{ "platform": "linux", "arch": "x64" },
{ "platform": "linux", "arch": "arm64" },
{ "platform": "linux-musl", "arch": "x64" },
{ "platform": "linux-musl", "arch": "arm64" }
]
}And build the libraries:
npx zigar buildIf you have Docker installed, run the following command to test the server in a cloud environment:
docker run --rm -v ./:/test -w /test -p 8080 node:alpine npm run start
Zigar 0.14.1 introduced a way of generating a standalone module loader. This frees an app from dependency on node-zigar, allowing it to run on other JavaScript runtimes such as Deno and Bun.
In node-zigar.config.json, add the "loader" field to lib/scale.zigar and lib/sepia.zigar:
{
"optimize": "ReleaseSmall",
"modules": {
"lib/scale.zigar": {
"source": "zig/scale.zig",
"loader": "src/scale.js"
},
"lib/sepia.zigar": {
"source": "zig/sepia.zig",
"loader": "src/sepia.js"
}
},
...
}Rebuild the libraries:
npx zigar buildAfterward, scale.js and sepia.js will appear in src.
In index.js, change the import statements to:
import { scale } from './scale.js';
import { apply } from './sepia.js';In package.json, remove the --loader=node-zigar --no-warnings flags from the start command:
"scripts": {
"start": "node src/index.js",And move node-zigar from dependencies to devDependencies:
"devDependencies": {
"node-zigar": "^0.15.3"
},
The standalone loader does not rebuild the module automatically upon changes to the code. You have to do it manually.
You can find the complete source code for this example here.
Finally, we have an actual server-side app. And it does something cool! A major advantage of using Zig for a task like image processing is that the same code can be deployed on the browser too. Consult the Vite or Webpack version of this example to learn how to do it.
The image filter employed for this example is very rudimentary. Check out pb2zig's project page to see more advanced code.
That's it for now. I hope this tutorial is enough to get you started with using Zigar.