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
のcore
crateで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