wasi-sdk,wit-bindgenとjcoでWebPに対応した画像diff wasm componentを作成する
Visual Regression Test をサポートする reg-actions をリリースしたという記事のTODOとして挙げたのだが、reg-viz/reg-cliのwasm化とwebp対応を進めたいと思っていた。
今回はその第一歩として画像diffライブラリのwebp/wasm化対応と、その際に躓いた内容などを記載する。
TL;DR
-
webpをサポートした画像diffライブラリをRustで書き、wit-bindgenとjcoでnode.js,denoから使用できるようにした -
webpのサポートにはlibwebpを使用した - 当初は
wasm32-unknown-emscriptenで進めていたがComponent化で問題が発生した -
wasi-sdkを利用し、wasm32-wasiにtargetを変えることでimportをwasi_snapshot_preview1に揃えることで上記を回避した
成果物
以下のように使用できる
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
witとwit-bindgen
componentを作るならまずはwitを書く必要がある。
このあたりはwit-bindgen と jco で WebAssembly Component Model に入門するで調査・記録してはいたのだが今回触ってみると結構様変わりしていた。とりあえず以下を参照するのが良い。
大きな差分としてはpackageの定義が必須になったりdefault worldが無くなったりしたと認識しており、結果2つの画像のbufferを受け取り差分を算出する関数のwitは以下のようになった。
個人的にはresult,option,variantが備わっているのが大変うれしい。
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とそれにより生成されるGuestのimpl対象を指定する形のようだ。
image_diff_rs::diffはrepositoryのcorecrateでexposeしている関数だ。
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())
}
}
ここまで出来たらあとはbuildしwasm-toolsでComponentを作成すれば良い。これは後述する。
WebPのサポート
今回画像のdecode/encodeにはimage-rs/imageを使用しつつ、libwebpも合わせて使用する。image-rs/imageでもWebPが一部サポートされたが、内部的にlibwebpを使用している。
であれば、自分でbuildしてやったほうがversionやbuildなどコントローラブルであると考えimage-rs/imageのwebp用のfeatureはdisableにしたまま使用している。
image-rs/imageを使えばpngなどは、簡単にdecodeができる。なので、libwebpに関してのみ記載する。
今回はdecodeとencode_losslessが必要なため以下のような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からは以下のように使用できる。
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の対応も必要だ。今回は以下のようになっている。
一部省略するが、必要なコードをリストアップしている。
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");
}
これでWebPのdecode/encodeはOKだ。
画像のdiff算出
前述まででWebPも含めた画像のdecode/encodeはできるようになった。
decode後のデータから差分を検出し、diff画像をencodeしてやる。
というのがこのライブラリの大まかな流れになる。
diffは以前書いてあったbokuweb/pixelmatch-rsを使用した。
decode結果を以下のように渡せば、比較できる。
返り値として差分画像が手に入るのでそれをencodeすればよい。
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関数が完成する。
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-emscriptenでbuildは成功するし、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などの関数名がwitのparseで失敗する(witの命名規則に違反しているため)し、仮にrenameなどでそれを解消してもinvoke_iijなど何に使うのか、どのような実装かもわからない関数を要求するComponentが出来上がってしまう。これはComponent化する意味を失ってしまっているので今回は廃案とした。
wasi-sdkとwasm32-wasiによるComponent化
前述まででwasm32-unknown-emscriptenでの問題が判明した。調べたところwasi-sdkを使うのが良さそうという結論に至った。これによりimportすべき関数はwasi_snapshot_preview1に集約されるのでemscripten使用時の問題は発生しない。
wasi-sdkやcargo-wasiなどinstall後以下でbuildできる
CFLAGS='--sysroot ./wasi-sdk/share/wasi-sysroot' cargo wasi build
build後importを確認すると以下のようになっているので意図通りの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で定義したErrorはtagged unionとして定義されている。
ErrorはComponentErrorにwrapされthrowされるようだ。
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を用いてnpmにpublishするまでを実施してみた。
wasi-sdkの使い方や最新のwitの書き方、RustとCが混在したケースの対応などいろいろ学べた。
次のStepとしてはreg-viz/reg-cliをwasm化する際にこのComponentをcomposeすることを目標としたい。
以上。
Discussion