Skip to content

Image processing (Node)

Chung Leong edited this page Jul 23, 2026 · 4 revisions

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.

Creating the sample app

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 zig

We'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):

Sample image

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.

Applying an image filter

In this section, we're going create a function that performs 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.

Color transform

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:

Screen shot

Configuring the app for deployment

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 build

If 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

Standalone module loader

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 build

Afterward, 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.

Source code

You can find the complete source code for this example here.

Conclusion

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.


Additional examples

Clone this wiki locally