アダコテック技術ブログ

株式会社アダコテックの技術ブログです。

Rust+WebAssemblyを使ったWebアプリでの高速画像処理入門

こんにちは!美味しいタコスを食べることを専門としているプロダクト開発部エンジニアの井上です。

要約

  • WebAssembly(WASM)、Rustは近年注目されている技術であり、これを利用することでWebアプリケーション上で高速な画像処理を実現できるよ。
  • 我々も画像前処理を行っていたWindowsデスクトップアプリケーションを、WebAssembly+Rustを使用してWebアプリケーションに置き換えたよ。
  • Rust言語で実装した画像処理をWASMにコンパイル、そしてWebアプリケーション(Vite+React+TypeScript)上で爆速実行するまでの手順を紹介するよ。

背景

弊社では、異常検知向けの機械学習モデル作成サービスをWebアプリケーションで提供しています。
学習に利用する画像データに対して前処理(例えば画像中の対象オブジェクトの位置補正)を適用するケースがあり、この機能についてはWindows上で動作するデスクトップアプリケーションとして提供してきました。しかし、このアプローチには以下のような課題が存在していました。

  • ユーザはWebアプリケーションとWindowsデスクトップアプリケーションの両方を利用することがあり、認知コストが高くなってしまう。
  • ユーザが一度デスクトップアプリケーションをインストールをすると、新しいバージョンがリリースされても古いバージョンのまま使用を続けていることが多く、最新の機能を提供することが困難となる。
    • そのため、提供側では多くのバージョンのインストーラを管理することになる。
  • ユーザの使用状況のログが収集できず、アプリケーションUI/UXやパフォーマンス改善のための洞察ができない。

これらの課題に対応するため、デスクトップアプリケーションからWebアプリケーションへの移行を決定しました。この変更により、ユーザーはインストールなしで常に最新バージョンのアプリケーションを使用できるようになります。
しかし、画像処理をWebブラウザで実行する際の課題として、複雑な画像処理アルゴリズムや大量データの解析に対する処理速度があります。JavaScriptだけでは、この要求に十分応えることが難しいため、より高速な処理を実現するためにWebAssemblyとRust言語による開発を採用しました。

WebAssembly (WASM) とは?

  • Webブラウザ上で実行可能なバイナリフォーマット
  • Rust, JavaScript, C++などの言語で実装したプログラムをWASMバイナリにコンパイルすることで、Webブラウザ上でもネイティブに近い速度で実行できる

Rust言語とは?

  • C/C++の代替となるかもしれないと近年注目されているプログラミング言語
  • 高速な処理を実現でき、所有権システムによりメモリの安全性が保証されている(所有権システムについては一言で語れないので、また別の機会で…。)
  • 高いパフォーマンスと信頼性(メモリリーク、スレッド競合を防ぐ)をもつことからWASMの開発言語として人気がある

サンプルアプリ概要

ここからは、WebAssemblyとRustを使用してWebアプリケーションを作成する方法について紹介します。
サンプルとして作成するWebアプリケーションは、ユーザがアップロードした画像データに対してリアルタイムで画像処理を適用し、結果を表示する機能をもちます。

適用する画像処理の内容は以下の通りです。

  • 入力画像データの各画素でLocal Binary Pattern(LBP)特徴量を計算
  • 各画素の値をLBP特徴量値に置き換える

※LBP特徴量については↓こちらの記事がわかりやすかったです。

以下のように画像処理をRustで実装し、それをWASMにコンパイルします。
WebフロントエンドでWASMバイナリを利用することで、Webブラウザ上でネイティブに近い速度で画像処理を実行することができます。

LBP特徴量計算をRustで実装

まずは、LBP特徴量の計算と、各画素値をLBP特徴量に置き換える処理をRustで実装してみましょう。
画像データの扱いにはimageクレートを利用します。

use image::{ImageBuffer, Luma};

fn transform_lbp_image(
    gray_image: &ImageBuffer<Luma<u8>, Vec<u8>>,
    width: u32,
    height: u32,
) -> ImageBuffer<Luma<u8>, Vec<u8>> {
    let mut lbp_img = ImageBuffer::new(width, height);
    let directions = [
        (-1, -1),
        (0, -1),
        (1, -1),
        (1, 0),
        (1, 1),
        (0, 1),
        (-1, 1),
        (-1, 0),
    ];

    for y in 1..height - 1 {
        for x in 1..width - 1 {
            let center_pixel = gray_image.get_pixel(x, y).0[0];
            // 左上から時計回りで注目画素(中央)の値との比較を行う
            let pattern = directions
                .iter()
                .enumerate()
                .fold(0u8, |acc, (idx, &(dx, dy))| {
                    let nx = (x as i32 + dx) as u32;
                    let ny = (y as i32 + dy) as u32;
                    let neighbor_pixel = gray_image.get_pixel(nx, ny).0[0];
                    acc | ((neighbor_pixel >= center_pixel) as u8) << (7 - idx)
                });
            lbp_img.put_pixel(x, y, Luma([pattern]));
        }
    }

    lbp_img
}

コードの見た感じはC++に近いですね!
もともとC++を書いていた人であれば、Rustへの入門はスムーズであるという話はよく聞きます。Rustにはメモリ安全性を保証する仕組みがあることも考慮すると、C++よりもRustを選びたくなる気持ちが高まりそうです。

画像処理界隈でよく見るヒヒ画像(baboon.jpg)を実装した関数に入力してみると以下のようになりました。

元画像とLBP画像

ひえぇ…こわい 変換できてそうですね!
特定の点で確認してみましょう。

特定の座標を拡大

x=172, y=55(ヒヒの目)周辺の輝度値で確認してみましょう。この領域(3x3)の輝度値は以下のようになっていました。

中心画素値45と周辺画素をLBP特徴量の手法で比較(今回は左上から時計回り)すると11100001となります。これを10進数に変換すると225となります。
実際に変換後画像のx=172, y=55の輝度値を見てみると225となっているので正しく計算できているようです!

次は、この関数をWebブラウザ上で実行できるようにフロントエンドおよびバックエンドの環境構築をしていきましょう。

フロントエンド環境構築

前提
Node.jsは既にインストールされているものとします。

Webアプリケーションのプロジェクト作成

npm create vite@latest wasm-sample-app --template react-ts

Viteを使って環境構築をします。

必要なライブラリをインストール

npm install @mui/material @emotion/react @emotion/styled
npm install image-js flatbuffers

UIコンポーネントとしてMUI、フロントエンドでも画像データの扱うためにimage-jsをインストールします。
また、今回はWebフロントエンド(TypeScript)とWASM間で受け渡しするデータのシリアライズ/デシリアライズのためにFlatBuffersライブラリを使用します。
flatcというFlatBuffers用コンパイラによって、FlatBuffersのスキーマ定義ファイル(データ構造体を記述)をもとに様々な言語(TypeScript, Rust, C#などなど)用のシリアライズ/デシリアライズ用コードファイルを生成することができます。
なおflatcは、以下のようにソースからビルドするか、FlatBuffersのGItHubリリースページからバイナリをダウンロードし実行可能状態としてください。

cmake -G "Unix Makefiles"
makej

バックエンド環境構築

前提
rustupは既にインストールされているものとします。

wasm-packインストール
wasm-packのページにあるコマンドを使ってインストールします。 wasm-packで、WASMへのコンパイルとパッケージングを行うことができます。

プロジェクト作成

mkdir rust_wasm

Rust開発用のプロジェクトフォルダを作成します。
今回は、フロントエンド開発用プロジェクトフォルダ内にrust_wasmという名前で作成しています。

ライブラリクレートを作成

cd rust_wasm
cargo new wasm --lib
cargo new core_lib --lib

Webフロントエンドとデータの受け渡し(FlatBuffersのシリアライズ/デシリアライズ)をするwasmクレートと、画像処理を実装するcore_libクレートの2つを用意します。
wasm-pack new <name>でも作成することが可能です。

wasmクレートにライブラリ追加

cd wasm
cargo add wasm-bindgen flatbuffers

WASMとTypeScript間のデータのやり取りを用意にするためのライブラリとしてwasm-bindgenを追加します。
また、フロントエンド開発環境構築の際にFlatBuffersライブラリをインポートしたので、バックエンドでも同様にFlatBuffersを利用します。

wasmクレートのCargo.tomlを編集

[package]
name = "wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.92"
flatbuffers = "24.3.25"
core_lib = { path = "../core_lib" }

1か所目はcrate-type = ["cdylib"]です。これを追記してあげることで、Webブラウザで実行可能なWASMバイナリを生成することができます。
2か所目は画像処理を実装したcore_libクレートを依存関係に追加するため、core_lib = { path = "../core_lib" }を追記しています。

core_libクレートにライブラリ追加

cd core_lib
cargo add image

LBP画像変換の関数時に利用したimageクレートも追加します。

Workspaceを設定

[workspace]
members = ["core_lib", "wasm"]

上記内容のCargo.tomlファイルをrust_wasmフォルダ直下に作成してください。 これにより2つのライブラリクレートの管理が可能となります。

スキーマ定義

今回のアプリケーションでは、WebフロントエンドとWASM間で画像データの受け渡ししかしないのですが、複数の画像データやパラメータを渡すのであればFlatBuffers等のシリアライザでシリアライズすると良いです。 FlatBuffers用のフォルダを作成

mkdir flatbuffers

フロントエンド開発用プロジェクトフォルダ内にflatbuffersという名前で作成しています。

スキーマ定義ファイルを作成

namespace image_processing;

table ImageProcessing {
    width: uint;
    height: uint;
    buf: [ubyte] (required);
}

root_type ImageProcessing;

flatbuffersフォルダ内にimage_processing.fbsというファイルを作成しました。
ファイル名と、内部のnamespace, table名は自由に決めてください。画像データのwidth, height, bufというフィールドを定義しました。

flatcでコンパイル

cd flatbuffers
flatc --ts image_processing.fbs -o ../src/fbs
flatc --rust image_processing.fbs -o ../rust_wasm/wasm/src

TypeScript用ファイルはsrc/fbsフォルダ、Rust用ファイルはrust_wasm/wasm/srcフォルダ内にシリアライズ/デシリアライズ用コードファイルを出力するようにしました。

Rust実装

Webアプリケーション機能を実現するためのサンプルコードを示していきます。   

LBP画像に変換 (core_lib/src/lib.rs)

use image::{DynamicImage, GenericImageView, GrayImage, ImageBuffer, Luma, Pixel};

fn transform_lbp_image(
    gray_image: &ImageBuffer<Luma<u8>, Vec<u8>>,
    width: u32,
    height: u32,
) -> ImageBuffer<Luma<u8>, Vec<u8>> {
    // 省略
}

/// GrayScaleに変換する関数
fn to_grayscale_rgba(img: &DynamicImage) -> GrayImage {
    let (width, height) = img.dimensions();
    let mut gray_img = GrayImage::new(width, height);

    for y in 0..height {
        for x in 0..width {
            let pixel = img.get_pixel(x, y).to_luma();
            gray_img.put_pixel(x, y, pixel);
        }
    }

    gray_img
}

/// 画像からLocal Binary Pattern特徴量を計算し、輝度値を変換する関数
pub fn local_binary_pattern(pixel_data: Vec<u8>, width: u32, height: u32) -> Vec<u8> {
    // DynamicImageに変換
    let buffer = ImageBuffer::from_raw(width, height, pixel_data).expect("Failed");
    let img = DynamicImage::ImageRgba8(buffer);

    // GrayScaleに変換
    let gray_image = to_grayscale_rgba(&img);

    // LBP画像に変換
    let lbp_img = transform_lbp_image(&gray_image, width, height);

    lbp_img.to_vec()
}

上記では省略していますが、冒頭で実装したtransform_lbp_imageメソッドを利用しています。

FlatBuffersシリアライズ/デシリアライズ (wasm/src/lib.rs)

use core_lib::local_binary_pattern;
use wasm_bindgen::prelude::*;

mod image_processing_generated;
use flatbuffers::FlatBufferBuilder;
use image_processing_generated::image_processing::{ImageProcessing, ImageProcessingArgs};

core_libクレートに実装したlocal_binary_patternメソッドをインポートしています。また、flatcで生成したコードファイルもインポートしています。

#[wasm_bindgen]
pub fn process_image(data: &[u8]) -> Vec<u8> {
    let img_proc = flatbuffers::root::<ImageProcessing>(data).expect("Failed");

    // img_proc.buf() からバイナリデータを取得
    let pixel_data = img_proc.buf().bytes().to_vec();
    let width = img_proc.width() as u32;
    let height = img_proc.height() as u32;

    // Local Binary Pattern特徴量計算
    let lbp_data = local_binary_pattern(pixel_data, width, height);

    // FlatBuffersのバイナリデータを作成
    let mut builder = FlatBufferBuilder::new();
    let lbp_vector = builder.create_vector(&lbp_data);
    let image_data = ImageProcessing::create(
        &mut builder,
        &ImageProcessingArgs {
            width: width,
            height: height,
            buf: Some(lbp_vector),
        },
    );

    // ベクタとして返す
    builder.finish(image_data, None);
    builder.finished_data().to_vec()
}

process_imageメソッドに#[wasm_bindgen]という属性をつけており、これによりWebフロントエンド(TypeScript)から呼び出すことが可能となります。
Webフロントエンドからは、FlatBuffersでシリアライズされたバイナリデータが渡されるので、デシリアライズを行い、画像のピクセルデータやサイズ情報を抽出します。
local_binary_patternメソッド実行後、ベクタ型データが返ってくるので、それをFlatBuffersでシリアライズします。

WASMバイナリの生成

cd rust_wasm/wasm
wasm-pack build --target web --out-dir ../pkg

一通り実装が完了したら、wasm-pack buildでWASMバイナリを生成させます。
--target webをつけることで、Webアプリケーション上で使用可能なWASMバイナリとして生成されます。

TypeScript実装

UI部分の実装については割愛していますm(__)m

メイン画面 (src/App.tsx)

import { Image } from 'image-js'
import * as flatbuffers from 'flatbuffers'
import { ImageProcessing } from './fbs/image-processing'
import init, { process_image } from '../rust_wasm/pkg'

// WASMモジュールの初期化
init()

flatcで生成されたコードファイルと、wasm-packで生成したWASMバイナリのインポートをしています。

  const [imageUrl, setImageUrl] = useState<string | ArrayBuffer | null>(null)
  const [fbBuffer, setFbBuffer] = useState<Uint8Array | null>(null)

  const handleFileChange = async (
    event: React.ChangeEvent<HTMLInputElement>,
  ) => {
    const file = event.target.files?.[0]

    try {
      const image = await Image.load(await file.arrayBuffer())
      setImageUrl(image.toDataURL())

      // FlatBuffersのビルダーを準備
      const builder = new flatbuffers.Builder(1024)

      // 画像データ(ピクセル値)をUInt8Arrayとして取得
      const imageData = new Uint8Array(image.getRGBAData())

      // FlatBuffersのバッファに画像データを格納
      const imageDataOffset = ImageProcessing.createBufVector(
        builder,
        imageData,
      )

      ImageProcessing.startImageProcessing(builder)
      ImageProcessing.addWidth(builder, image.width)
      ImageProcessing.addHeight(builder, image.height)
      ImageProcessing.addBuf(builder, imageDataOffset)
      const imageProc = ImageProcessing.endImageProcessing(builder)
      builder.finish(imageProc)

      // Uint8ArrayとしてエンコードされたFlatBuffersバイナリを取得
      const fbBuffer = builder.asUint8Array()
      setFbBuffer(fbBuffer)
    } catch (error) {
      setImageUrl(null)
      setFbBuffer(null)
    }
  }

画像読み込み時のメソッドです。
画像データからRGBA形式のピクセルデータを取得し、FlatBuffersで画像データのベクタを作成しています。
その後はスキーマ定義ファイルに従って、必要情報を指定したうえでシリアライズしています。
Uint8Array型にしてWASMバイナリに与えるようにします。

  const handleProcessImage = async () => {
    if (!fbBuffer) {
      return
    }
    try {
      // WASM画像処理実行
      const resultBuffer = process_image(fbBuffer)

      // ImageProcessingスキーマをもとに各データを抽出
      const buf = new flatbuffers.ByteBuffer(resultBuffer)
      const imageProc = ImageProcessing.getRootAsImageProcessing(buf)
      const width = imageProc.width()
      const height = imageProc.height()
      const pixels = imageProc.bufArray() as Uint8Array

      // image-jsのImageオブジェクト作成 (PNGフォーマットのグレースケールデータ)
      const img = new Image(width, height, pixels, {
        components: 1,
        alpha: 0,
        bitDepth: 8,
      })
      const imgFile = img.toBuffer({ format: 'png' })
      const blob = new Blob([imgFile.buffer], { type: 'image/png' })

      // 結果画像描画のためにURL生成
      const imgUrl = URL.createObjectURL(blob)
      setImageUrl(imgUrl)
    } catch (error) {
      console.error(`Error creating image from buffer: `, error)
    }
  }

画像処理実行時のメソッドです。
WASMバイナリから返ってきたデータをでシリアライズします。
その後は、UI上に画像描画するためにURL生成をしています。

実行結果

対象画像を読み込み
画像処理を実行

LBP画像データが表示されました!
(画像は、先日エンジニアチームで合宿をした際に食べたカツ丼ですw)

時間計測
WebAssembly+Rustで実行したときの処理時間と、TypeScriptでLBP画像変換を実装したときの処理時間は以下の通りです。
画像の解像度は約1200万画素です。

実装 処理時間(msec)
WebAssembly+Rust 282
TypeScript 7,076

なんと約25倍も速く実行することができていました!

まとめ

WebAssembly+Rustを活用することで、高速な画像処理をWebブラウザ上で実行できることが確認できました。
非常に簡単な処理であればWASM活用する必要はないと思いますが、複雑なアルゴリズムの画像処理や画像解析を行う際は是非WASMを活用してみてください!

メンバー募集しています!

Webアプリケーションで画像処理/機械学習をするプロダクトに興味を持った方、アダコテック事業内容に興味ある方、ぜひ私たちのチームに参加してください!

herp.careers

おまけ

先日食したタコスの写真でも変換しましたw
処理時間については上記と同等でした。味は最高でした。

Kitade Tacos & Sakeさんのカルニタス