valentin13.mail@gmail.com

Creando librerias para node con Rust - 16/02/2024

En este post vamos a ver como crear una libreria para node con Rust

Creando librerias para node con Rust

En este artículo exploraremos cómo crear librerías para Node.js utilizando Rust y Neon Bindings una biblioteca que nos permite crear bindings para Node.js con Rust de manera sencilla y eficiente.

¿Por qué Rust?

Rust es un lenguaje de programación de sistemas que se enfoca en la seguridad, la concurrencia y el rendimiento. Es un lenguaje moderno que nos permite escribir código eficiente y seguro. Además, Rust tiene una gran comunidad y un ecosistema de herramientas muy activo.

Definiendo nuestra librería

Después de considerar diferentes enfoques, he decidido crear una librería que nos permita extraer los colores de una imagen. Esta idea está inspirada en ColorThief, una herramienta popular para extraer colores de imágenes.

A diferencia de la librería image de Rust, vamos a interactuar directamente con las librerías de decodificación existentes en Rust. Antes de comenzar, es importante definir los formatos de imágenes que vamos a soportar.

El diagrama de la librería se vería así:

Loading graph...

Soporte de formatos de imagen

Para nuestro proyecto, vamos a soportar los siguientes formatos de imagen: JPG, PNG, GIF, BMP y WEBP. Para ello, vamos a utilizar las siguientes librerías de Rust: palette_extract, zune-jpeg, png, gif, zune-bmp y libwebp.

Creando el proyecto

Comenzando el proyecto para esta parte simplemente seguiremos la documentacion de Neon Bindings para crear un proyecto de Rust con soporte para Node.js.

Nos indica que hay que ejecutar el comando npm init neon PixDecodeRust esto nos dejara una estructura de archivos como la siguiente:

PixDecodeRust/
├── Cargo.toml
├── README.md
├── package.json
└── src
    └── lib.rs

si entramos a a la carpeta con cd PixDecodeRust y ejecutamos npm install nos instalara las dependencias necesarias para el proyecto.

Implementando la extracción de colores

Ahora que tenemos nuestro proyecto listo, vamos a implementar la extracción de colores. Para ello, vamos a crear una función en Rust que reciba una imagen y nos devuelva los colores dominantes.

NOTA: Para maximizar la eficiencia, se recomienda pasar el buffer de la imagen desde JavaScript en lugar de la ruta de la imagen. De esta manera, el programa no necesita gastar tiempo en leer el archivo desde una ruta. Además, también se solicitará desde JavaScript el formato de la imagen, pero eso lo veremos mas tarde.

La idea del programa hecho en rust sera usar las librerias que mencionamos anteriormente para decodificar la imagen para obtener los pixeles de la imagen y luego usamos la libreria palette_extract que es una biblioteca que se basa en el algoritmo de Leptonica para extraer los colores dominantes de una imagen, entonces nuestra estructura quedaria asi:

struct Image {
    pixels: Vec<u8>,
    quality: u8,
    max_colors: u8,
}

Implementaremos un método en la estructura que nos permita obtener los colores dominantes de la imagen:


impl Image {
    fn to_object<'a, C: Context<'a>>(cx: &mut C, img: Image) -> Handle<'a, JsObject> {
        let obj = JsObject::new(cx);

        let r: Vec<Color> = get_palette_with_options(
            &img.pixels,
            PixelEncoding::Rgb,
            Quality::new(img.quality),
            MaxColors::new(img.max_colors),
            PixelFilter::White,
        );
        let palette = JsArray::new(cx, r.len() as u32);
        r.iter().enumerate().for_each(|(_i, color)| {
            let obj = JsObject::new(cx);
            let r = cx.number(color.r as f64);
            let g = cx.number(color.g as f64);
            let b = cx.number(color.b as f64);
            obj.set(cx, "r", r).unwrap();
            obj.set(cx, "g", g).unwrap();
            obj.set(cx, "b", b).unwrap();
            palette.set(cx, _i as u32, obj).unwrap();
        });

        obj.set(cx, "palette", palette).unwrap();

        obj
    }
}

A continuación, crearemos una función para un tipo de imagen específico y repetiremos el proceso para cada tipo de imagen que soportemos:


fn png(mut cx: FunctionContext) -> JsResult<JsObject> {
    let buffer = cx.argument::<JsBuffer>(0)?;
    let quality_fs64 = cx.argument::<JsNumber>(1)?.value(&mut cx);
    let quality = format!("{:.0}", quality_fs64).parse::<u8>().unwrap();

    let max_colors_fs64 = cx.argument::<JsNumber>(2)?.value(&mut cx);
    let max_colors = format!("{:.0}", max_colors_fs64).parse::<u8>().unwrap();
    let data = buffer.as_slice(&mut cx);

    let decoder = png::Decoder::new(data);
    let mut reader = decoder.read_info().unwrap();
    let mut pixels = vec![0; reader.output_buffer_size()];
    reader.next_frame(&mut pixels).unwrap();
    let obj = Image::to_object(
        &mut cx,
        Image {
            pixels,
            quality,
            max_colors,
        },
    );

    Ok(obj)
}

Luego, exportamos la función png para que sea accesible desde JavaScript:

#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
    cx.export_function("png", png)?;
    Ok(())
}

Ahora solo tenemos que compilar el proyecto con npm install y ya tendremos nuestro archivo index.node que podremos usar en nuestro proyecto de node.js.

Usando la librería en Node.js

En node simplemente iniciaremos un proyecto con npm init -y y en mi caso configurare el proyecto para que funcione con typescript con npx tsc --init. Por ultimo asi quedaria mi archivo tsconfig.json:

{
    "compilerOptions": {
        "lib": [
            "es5",
            "es6",
            "es7",
            "ESNext",
            "DOM"
        ],
        "target": "ES2018",
        "removeComments": false,
        "esModuleInterop": true,
        "moduleResolution": "Node",
        "resolveJsonModule": true,
        "strict": true,
        "skipLibCheck": true,
        "strictPropertyInitialization": false,
        "noUnusedLocals": true,
        "noUnusedParameters": false,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,
        "downlevelIteration": true,
        "isolatedModules": true,
        "noImplicitAny": false,
        "declaration": true
    },
    "include": [
        "../src/**/*"
    ],
    "exclude": [
        "node_modules",
        "dist"
    ]
}

A continuación, crearemos la estructura del proyecto:

Pix/
├── src
   ├── index.ts
   ├── lib
   ├── addon.js
   ├── index.node
├── package.json
└── tsconfig.json

En la estructura del proyecto, podemos observar un archivo .js y un archivo .node. El archivo .js se utiliza para exportar las funciones que serán utilizadas en Node.js, mientras que el archivo .node es el resultado de la compilación del proyecto en Rust.

A continuación, en el archivo addon.js, agregaremos el siguiente código:

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { png } = require(".");

export { png };

Estamos usando node:module para poder importar el modulo require y asi poder importar el archivo index.node que nos genero el proyecto de rust, en este caso solo estamos exportando la funcion png que es la que usamos en el proyecto de rust.

Ahora en el archivo index.ts implementaremos los Formatos de imagen que soportaremos de esta manera sera mas facil de usar en el futuro, ademas agrega algunos tipos de datos que nos ayudaran a trabajar con la libreria:


import { png } from "./lib/addon.js"


export enum Format {
    JPG,
}
type PaletteColor = [number, number, number];
type TPalette = PaletteColor[];
type Output = {
    palette: TPalette;
};

Ahora implementaremos la clase Color que nos ayudará a trabajar con los colores. También agregaremos una función llamada toHex() que devolverá el color en formato hexadecimal:

class Color {
    r: number
    g: number
    b: number

    toHex(): string {
        return `#${this.r.toString(16).padStart(2, '0')}${this.g.toString(16).padStart(2, '0')}${this.b.toString(16).padStart(2, '0')}`
    }
}

Ahora implementaremos la clase Palette y Dominant que nos ayudara a tener mas control sobre los colores que extraigamos de las imagenes:

class Palette {
    colors: Color[];

    constructor(colors: Color[]) {
        this.colors = colors;
    }

    toHex(): string[] {
        return this.colors.map(color => color.toHex());
    }
}
class Dominant {
    color: Color


    constructor(color: Color) {
        this.color = color;
    }

    toHex(): string {
        return this.color.toHex();
    }
}

Por ultimo solo faltaria implementar la clase Pix que sera el nucleo de nuestra libreria, a esta clase se la pasara por parametro el buffer de la imagen y el formato de la imagen que se quiere extraer los colores, pero ademas dentro del constructor aprovecharemos para extraer el color dominante y la paleta de colores de la imagen, ademas de que tambien se encargara de convertir los colores a la clase Color que creamos anteriormente, asi quedaria la clase Pix:

class Pix {
    static Format = Format
    public palette: Palette
    dominant: Dominant

    /**
     * Creates a new Pix instance.
     * @param buffer - The image buffer.
     * @param filetype - The file format of the image.
     * @throws Error if the buffer is empty or the filetype is unsupported.
     */
    constructor(buffer: Buffer, filetype: Format.JPG | Format.PNG | Format.GIF | Format.BMP | Format.WEBP) {

        if (!buffer || buffer.length === 0) {
            throw new Error("Buffer is empty")
        }
        if (!filetype) {
            throw new Error("Filetype is empty")
        }

        let output: Output;
        switch (filetype) {
            // @ts-ignore
            case Format.JPG:
                output = jpg(buffer);
                break;
            case Format.PNG:
                output = png(buffer);
                break;
            case Format.GIF:
                output = gif(buffer);
                break;
            case Format.BMP:
                output = bmp(buffer);
                break;
            case Format.WEBP:
                output = webp(buffer);
                break;
            default:
                throw new Error("Unsupported file format")
        }

        if (!output.palette) {
            throw new Error("Palette not found")
        }

        const dominantColor = new Color();
        dominantColor.r = output.palette[0][0];
        dominantColor.g = output.palette[0][1];
        dominantColor.b = output.palette[0][2];

        const paletteColors = output.palette.slice(1).map(colorArray => {
            const color = new Color();
            color.r = colorArray[0];
            color.g = colorArray[1];
            color.b = colorArray[2];
            return color;
        });
        this.palette = new Palette(paletteColors);
        this.dominant = new Dominant(dominantColor);
    }

}

Solo queda exportar la clase Pix para que sea accesible desde Node.js:

export { Pix }

Una vez hecho esto, debemos compilar el proyecto con tsc. Con eso, nuestra librería estará lista para ser utilizada en Node.js. A continuación, se muestra un ejemplo de cómo se usaría:

import Pix from '../dist/pix';
import * as fs from 'fs';

const imageBuffer = fs.promises.readFile('path_to_your_image.jpg');
const image = new Pix(imageBuffer, Pix.Format.JPG);
console.log('Dominant Color:', image.dominant.toHex());
console.log('Palette:', image.palette.toHex());

En este ejemplo, importamos la clase Pix desde nuestra librería, leemos una imagen del sistema de archivos y creamos una nueva instancia de Pix con el buffer de la imagen y el formato de la imagen. Luego, imprimimos el color dominante y la paleta de colores de la imagen en formato hexadecimal.

Rendimiento y eficiencia

Una vez hecho esto podemos testear la libreria y ver que tan eficiente es, en mi caso hice una prueba con una imagen de 3120x3920 y la libreria tardo 0.2 segundos en extraer los colores de la imagen, a diferencia de la libreria que use como referencia que tardo 2.3 segundos, asi que podemos decir que la libreria es bastante eficiente, dejo una tabla con los resultados con diferentes formatos de imagen:

File TypePix (ms)ColorThief (ms)
PNG2742325
JPEG3093781
GIF262237
BMP122No Supported
WEBP726No Supported

Conclusión

En este artículo hemos explorado cómo crear una librería para Node.js utilizando Rust y Neon Bindings. Hemos creado una librería que nos permite extraer los colores de una imagen de manera eficiente y segura. Además, hemos visto cómo usar la librería en un proyecto de Node.js y hemos comprobado su eficiencia y rendimiento.