🕵️‍♂️

wasi-sdk,wit-bindgenとjcoでWebPに対応した画像diff wasm componentを作成する

2023/11/29に公開

Visual Regression Test をサポートする reg-actions をリリースしたという記事のTODOとして挙げたのだが、reg-viz/reg-cliwasm化とwebp対応を進めたいと思っていた。

今回はその第一歩として画像diffライブラリのwebp/wasm化対応と、その際に躓いた内容などを記載する。

TL;DR

  • webpをサポートした画像diffライブラリをRustで書き、wit-bindgenjconode.js,denoから使用できるようにした
  • webpのサポートにはlibwebpを使用した
  • 当初はwasm32-unknown-emscriptenで進めていたがComponent化で問題が発生した
  • wasi-sdkを利用し、wasm32-wasitargetを変えることでimportwasi_snapshot_preview1に揃えることで上記を回避した

成果物

https://github.com/bokuweb/image-diff-rs

以下のように使用できる

import { readFile } from "node:fs/promises";
import { diff } from "@bokuweb/image-diff-wasm";

const imga = await readFile(PATH_TO_IMAGE_A);
const imgb = await readFile(PATH_TO_IMAGE_B);

const result = diff(imga, imgb, { enableAntiAlias: true, threshold: 0.01 });

前提条件

  • rustc: 1.74.0
  • wasi-sdk: v20.0
  • wasm-tools: v1.0.53
  • jco: v0.13.3
  • nodejs: v20.10.0

witwit-bindgen

componentを作るならまずはwitを書く必要がある。
このあたりはwit-bindgen と jco で WebAssembly Component Model に入門するで調査・記録してはいたのだが今回触ってみると結構様変わりしていた。とりあえず以下を参照するのが良い。

https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md

大きな差分としてはpackageの定義が必須になったりdefault worldが無くなったりしたと認識しており、結果2つの画像のbufferを受け取り差分を算出する関数のwitは以下のようになった。

個人的にはresult,option,variantが備わっているのが大変うれしい。

wasm/wit/iamge-diff.wit
package bokuweb:image-diff;

interface types {
  record output {
    // The number of pixels that differ between the two images.
    diff-count: u32,
    // The buffer of the difference image in `WebP` format.
    diff-image: list<u8>,
    // The width of the difference image.
    width: u32,
    // The height of the difference image.
    height: u32,
  }

  record opts {
    // Matching threshold, ranges from 0 to 1. Smaller values make the comparison more sensitive. 0.1 by default.
    threshold: option<float32>,
    // The flag of antialias. If skipped false.
    include-anti-alias: option<bool>,
  }

  variant error {
    decode(string),
    encode(string),
  }
}

world image-diff {

  use types.{opts, output, error};

  // The diff function is designed to compare two images and identify their differences.
  export diff: func(imga: list<u8>, imgb: list<u8>, opts: opts) -> result<output, error>;
}

合わせてwit-bindgenも少し使い方が変わっており、以下のように記述した。
wit_bindgen::generate!で使用するworldとそれにより生成されるGuestimpl対象を指定する形のようだ。

image_diff_rs::diffrepositorycorecrateでexposeしている関数だ。

wasm/src/lib.rs
use image_diff_rs::{DiffOption, DiffOutput, ImageDiffError};

wit_bindgen::generate!({
 world: "image-diff",
 exports: {
     world: ImageDiff,
 }
});

struct ImageDiff;

impl Guest for ImageDiff {
    fn diff(
        imga: Vec<u8>,
        imgb: Vec<u8>,
        opts: bokuweb::image_diff::types::Opts,
    ) -> Result<bokuweb::image_diff::types::Output, bokuweb::image_diff::types::Error> {
        Ok(image_diff_rs::diff(
            imga,
            imgb,
            &DiffOption {
                threshold: opts.threshold,
                include_anti_alias: opts.include_anti_alias,
            },
        )?
        .into())
    }
}

ここまで出来たらあとはbuildwasm-toolsComponentを作成すれば良い。これは後述する。

WebPのサポート

今回画像のdecode/encodeにはimage-rs/imageを使用しつつ、libwebpも合わせて使用する。image-rs/imageでもWebPが一部サポートされたが、内部的にlibwebpを使用している。
であれば、自分でbuildしてやったほうがversionbuildなどコントローラブルであると考えimage-rs/imagewebp用のfeaturedisableにしたまま使用している。

image-rs/imageを使えばpngなどは、簡単にdecodeができる。なので、libwebpに関してのみ記載する。

今回はdecodeencode_losslessが必要なため以下のようなwebp.cを用意する。

core/src/webp.c
#ifdef __EMSCRIPTEN__
  #include "emscripten.h"
#else
  #define EMSCRIPTEN_KEEPALIVE
#endif

#include "../../libwebp/src/webp/encode.h"
#include "../../libwebp/src/webp/decode.h"

EMSCRIPTEN_KEEPALIVE
uint8_t* decode(const uint8_t* data, size_t data_size, int* width, int* height) {
  return WebPDecodeRGBA(data, data_size, width, height);
}

EMSCRIPTEN_KEEPALIVE
size_t encode_lossless(const uint8_t* rgba, int width, int height, int stride, uint8_t** output) {
  return WebPEncodeLosslessRGBA(rgba, width, height, stride, output);
}

Rustからは以下のように使用できる。

core/src/webp.rs
extern "C" {
    fn encode_lossless(
        rgba: *const c_uchar,
        width: c_int,
        height: c_int,
        stride: c_int,
        output: &mut *mut c_uchar,
    ) -> usize;
}

pub fn decode_webp_buf(data: &[u8]) -> Result<DecodeOutput, WebPError> {
    // ...skipped
    let result = unsafe { decode(data.as_ptr(), data.len(), &mut w, &mut h) };
    // ...skipped
}

fn encode_buf(rgba: &[u8], width: u32, height: u32) -> Result<EncodeOutput, WebPError> {
    // ...skipped
    let result = unsafe {
        encode_lossless(rgba.as_ptr(), width as i32, height as i32, (width * 4) as i32, &mut ptr)
    };
    // ...skipped
}

合わせてbuild.rsの対応も必要だ。今回は以下のようになっている。
一部省略するが、必要なコードをリストアップしている。

core/build.rs
extern crate cc;

fn main() {
    cc::Build::new()
        .file("../libwebp/src/dec/alpha_dec.c")
        .file("../libwebp/src/dec/buffer_dec.c")
        .file("../libwebp/src/dec/frame_dec.c")
        // ... skipped
        .file("../libwebp/sharpyuv/sharpyuv_cpu.c")
        .include("../libwebp")
        .compile("webp");
}

これでWebPdecode/encodeはOKだ。

画像のdiff算出

前述まででWebPも含めた画像のdecode/encodeはできるようになった。
decode後のデータから差分を検出し、diff画像をencodeしてやる。
というのがこのライブラリの大まかな流れになる。

diffは以前書いてあったbokuweb/pixelmatch-rsを使用した。

https://github.com/bokuweb/pixelmatch-rs

decode結果を以下のように渡せば、比較できる。
返り値として差分画像が手に入るのでそれをencodeすればよい。

core/src/compare.rs
pub fn compare_buf(
    img1: &[u8],
    img2: &[u8],
    dimensions: (u32, u32),
    opt: CompareOption,
) -> Result<DiffOutput, ImageDiffError> {
    let result = pixelmatch(
        img1,
        img2,
        dimensions,
        Some(PixelmatchOption {
            threshold: opt.threshold,
            include_anti_alias: true,
            ..PixelmatchOption::default()
        }),
    )
    // ...skipped
}

これらをつなぎ合わせると前述した以下のようなdiff関数が完成する。

core/src/lib.rs
pub fn diff(
    actual: impl AsRef<[u8]>,
    expected: impl AsRef<[u8]>,
    option: &DiffOption,
) -> Result<DiffOutput, ImageDiffError> {
    let img1 = decode_buf(actual.as_ref())?;
    let img2 = decode_buf(expected.as_ref())?;

    let w = std::cmp::max(img1.dimensions.0, img2.dimensions.0);
    let h = std::cmp::max(img1.dimensions.1, img2.dimensions.1);

    // expand ig size is not match.
    let expanded1 = expander::expand(img1.buf, img1.dimensions, w, h);
    let expanded2 = expander::expand(img2.buf, img2.dimensions, w, h);

    let result = compare_buf(
        &expanded1,
        &expanded2,
        (w, h),
        CompareOption {
            threshold: option.threshold.unwrap_or_default(),
            enable_anti_alias: option.include_anti_alias.unwrap_or_default(),
        },
    )?;

    Ok(DiffOutput {
        diff_count: result.diff_count,
        diff_image: encode(&result.diff_image, result.width, result.height)?,
        width: result.width,
        height: result.height,
    })
}

あとはComponent化だ。

wasm32-unknown-emscriptenでの頓挫

このタスクをやり始めたときはwasm32-unknown-emscriptenでなんとかなるかと思っていたが、結果から言うと頓挫した。

確かにwasm32-unknown-emscriptenbuildは成功するし、nodejsで動作させることもできるがComponent化する際に以下のエラーが発生する。

wasm-tools component new target/wasm32-unknown-emscripten/release/image_diff_wasm.wasm -o wasm/component.wasm

error: failed to encode a component from module

Caused by:
    0: module requires an import interface named `env`

これはemscriptenが差し込んだ(import "env" "invoke_vii" (func (;1;) (type 4)))などのimportの定義がwitに無いためだ。

リストアップしてみると以下のようなものがimportされている。

(import "env" "__cxa_find_matching_catch_2" (func (;0;) (type 22)))
(import "env" "invoke_vii" (func (;1;) (type 4)))
(import "env" "invoke_viii" (func (;2;) (type 2)))
(import "env" "invoke_vi" (func (;3;) (type 1)))
(import "env" "__resumeException" (func (;4;) (type 3)))
(import "env" "invoke_viiii" (func (;5;) (type 6)))
(import "env" "invoke_v" (func (;6;) (type 3)))
(import "env" "invoke_viiiii" (func (;7;) (type 8)))
(import "env" "invoke_ii" (func (;8;) (type 0)))
(import "env" "invoke_iii" (func (;9;) (type 5)))
(import "env" "invoke_viiiiiiii" (func (;10;) (type 14)))
(import "wasi_snapshot_preview1" "fd_write" (func (;11;) (type 13)))
(import "env" "invoke_viiiiiii" (func (;12;) (type 18)))
(import "env" "invoke_iiii" (func (;13;) (type 13)))
(import "env" "__cxa_find_matching_catch_3" (func (;14;) (type 7)))
(import "env" "invoke_viiiiii" (func (;15;) (type 15)))
(import "env" "invoke_iiiiiiiii" (func (;16;) (type 24)))
(import "env" "invoke_iiiiiiiiii" (func (;17;) (type 47)))
(import "env" "invoke_iiiiiii" (func (;18;) (type 17)))
(import "env" "invoke_iiiiii" (func (;19;) (type 9)))
(import "env" "invoke_viiiiiiifi" (func (;20;) (type 56)))
(import "env" "invoke_viiijj" (func (;21;) (type 18)))
(import "env" "invoke_viiijiii" (func (;22;) (type 14)))
(import "env" "invoke_iij" (func (;23;) (type 13)))
(import "env" "invoke_viiji" (func (;24;) (type 8)))
(import "env" "emscripten_notify_memory_growth" (func (;25;) (type 3)))
(import "wasi_snapshot_preview1" "fd_seek" (func (;26;) (type 57)))
(import "env" "__syscall_getcwd" (func (;27;) (type 0)))
(import "env" "emscripten_memcpy_js" (func (;28;) (type 4)))
(import "wasi_snapshot_preview1" "fd_close" (func (;29;) (type 7)))
(import "wasi_snapshot_preview1" "proc_exit" (func (;30;) (type 3)))
(import "wasi_snapshot_preview1" "environ_get" (func (;31;) (type 0)))
(import "wasi_snapshot_preview1" "environ_sizes_get" (func (;32;) (type 0)))
(import "env" "__cxa_throw" (func (;33;) (type 4)))
(import "env" "__cxa_begin_catch" (func (;34;) (type 7)))
(import "env" "invoke_fiiiiiii" (func (;35;) (type 58)))
(import "env" "invoke_viiiiiiiiii" (func (;36;) (type 59)))
(import "env" "invoke_i" (func (;37;) (type 7)))

これをwitに定義すれば理屈上はいけるはずだが、いくつか問題がある。

まず__cxa_find_matching_catch_3などの関数名がwitparseで失敗する(witの命名規則に違反しているため)し、仮にrenameなどでそれを解消してもinvoke_iijなど何に使うのか、どのような実装かもわからない関数を要求するComponentが出来上がってしまう。これはComponent化する意味を失ってしまっているので今回は廃案とした。

wasi-sdkwasm32-wasiによるComponent

前述まででwasm32-unknown-emscriptenでの問題が判明した。調べたところwasi-sdkを使うのが良さそうという結論に至った。これによりimportすべき関数はwasi_snapshot_preview1に集約されるのでemscripten使用時の問題は発生しない。

wasi-sdkcargo-wasiなどinstall後以下でbuildできる

CFLAGS='--sysroot ./wasi-sdk/share/wasi-sysroot' cargo wasi build

buildimportを確認すると以下のようになっているので意図通りのwasmが生成されている。

(import "wasi_snapshot_preview1" "fd_write" (func $wasi::lib_generated::wasi_snapshot_preview1::fd_write::h753e999f4c99ffdd (type 11)))
(import "wasi_snapshot_preview1" "random_get" (func $wasi::lib_generated::wasi_snapshot_preview1::random_get::h83e1ee0faf81626c (type 6)))
(import "wasi_snapshot_preview1" "environ_get" (func $__imported_wasi_snapshot_preview1_environ_get (type 6)))
(import "wasi_snapshot_preview1" "environ_sizes_get" (func $__imported_wasi_snapshot_preview1_environ_sizes_get (type 6)))
(import "wasi_snapshot_preview1" "fd_close" (func $__imported_wasi_snapshot_preview1_fd_close (type 3)))
(import "wasi_snapshot_preview1" "fd_prestat_get" (func $__imported_wasi_snapshot_preview1_fd_prestat_get (type 6)))
(import "wasi_snapshot_preview1" "fd_prestat_dir_name" (func $__imported_wasi_snapshot_preview1_fd_prestat_dir_name (type 9)))
(import "wasi_snapshot_preview1" "fd_seek" (func $__imported_wasi_snapshot_preview1_fd_seek (type 39)))
(import "wasi_snapshot_preview1" "proc_exit" (func $__imported_wasi_snapshot_preview1_proc_exit (type 2)))

あとはwasi_snapshot_preview1.reactor.wasmを落としてきてadaptorに設定することでComponent化が可能となる。

これにより(import "wasi_snapshot_preview1"は自動的に解決される。

wasm-tools component new target/wasm32-wasi/release/image_diff_wasm.wasm -o component.wasm --adapt ./wasi_snapshot_preview1.reactor.wasm

component.wasmが生成されたら成功だ。
まだ試していないが、この状態でWebAssembly Registry (Warg)publishできるんじゃないかと思う。

publishしたものをどのようにinstallするのかまだ記載が無いように見えるが、このRegistry経由で気軽にinstallし言語を跨いで相互運用できるのであればそれは魅力的に思う。

jcoによるJavaScriptコードの生成

以下のように先程生成したwasmを指定することでJavaScriptが自動生成される。

jco transpile component.wasm -o js

以下のような定義のコードが生成される。

variantで定義したErrortagged unionとして定義されている。
ErrorComponentErrorwrapされthrowされるようだ。

src/js/index.d.ts
export interface Opts {
  threshold?: number;
  includeAntiAlias?: boolean;
}
export interface Output {
  diffCount: number;
  diffImage: Uint8Array;
  width: number;
  height: number;
}
export type Error = ErrorDecode | ErrorEncode;
export interface ErrorDecode {
  tag: "decode";
  val: string;
}
export interface ErrorEncode {
  tag: "encode";
  val: string;
}

export function diff(imga: Uint8Array, imgb: Uint8Array, opts: Opts): Output;

生成したコードは@bytecodealliance/preview2-shimに依存しているので、nodejsの場合はpackage.jsonに追加が必要となる。

@bytecodealliance/preview2-shimさえインストールされれば冒頭で紹介した、以下のようなコードで実行できる。

import { readFile } from "node:fs/promises";
import { diff } from "./js/image-diff-wasm";

const imga = await readFile(PATH_TO_IMAGE_A);
const imgb = await readFile(PATH_TO_IMAGE_B);

const result = diff(imga, imgb, { enableAntiAlias: true, threshold: 0.01 });

まとめ

wasi-sdk,wit-bindgenを用いてnpmpublishするまでを実施してみた。
wasi-sdkの使い方や最新のwitの書き方、RustCが混在したケースの対応などいろいろ学べた。

次のStepとしてはreg-viz/reg-cliwasm化する際にこのComponentcomposeすることを目標としたい。

以上。

FRAIMテックブログ

Discussion