LoginSignup
25
9

More than 1 year has passed since last update.

WebAssembly (Rust) によるブラウザ上の画像処理

Last updated at Posted at 2021-09-16

概要

  • ブラウザ上で動画をリアルタイムに画像処理する。
  • Rustによって作成したWebAssemblyと、JavaScriptとの処理速度の比較を行う。
  • 結果は、Firefox(JS) > Chrome(Rust) > Chrome(JS) > Firefox(Rust) [速い順] となった。
  • 画素数1920x1080でのフレームレートは、最速のFirefox(JS)で約10~12fpsとなる。
    • この記事で示す処理以外のものが入ったときのフレームレートである。ただここでの処理が他のに比べて大きくボトルネックにはなっている。

WebAssemblyとは

  • WebAssembly1とは、ブラウザ上で動くバイナリコードである。JavaScriptに比べて処理速度が速いという特徴がある。
  • JavaScriptからWebAssemblyの関数が呼び出されたり、WebAssemblyからJavaScriptの関数が呼び出されたりする。
  • WebAssemblyからHTMLの要素を操作することはできない。
  • C/C++, Rust, C#, Goなどの言語によって開発することができる。
  • UnityのWebGLビルドのスタンダードになっている2

この記事では、Rustによって開発されたWebAssemblyについて記述する。

Rustとは

  • Rust(ラスト)3とは、マルチパラダイム(手続き型、オブジェクト指向、関数型プログラミングなど)の実装手法をサポートしているC言語に似たプログラミング言語である。
  • C/C++に代わるコンピュータのハードウェアの操作、制御のために使用されるプログラミング言語を目指している4

この記事で扱う画像処理について

ここで扱う画像処理は、フルHD(1920x1080ピクセル)の画像を10x10のセルに分割し、そのそれぞれのセルで平均を取るものとする。平均値は履歴を持っていて、新しい値がすぐにセルを代表する平均値になるわけではない。この処理によって動きのある部分は薄く、変化の少ない背景はハッキリと表示されるようになる。上の図では参考用に処理結果を右の画像で表示している。ただ処理速度の計測時には、画像処理の結果を表示せずに各セルの平均値を計算するところまでの時間を計測するとする。

結果:処理速度

1画面のセル化、平均化処理にかかる時間を、Chrome、Firefoxブラウザにおいてそれぞれ、JavaScriptはJSと表記、WebAssemblyはRustとして図に示す。測定条件については次のようになっている。

  • Dell Inspiron 7501 Windows 10 Home x64 (10.0.19042 ビルド 19042)
  • Core i7-10750H 2.60GHz, RAM 16.0GB
  • Chrome 93.0.4577.82 (64ビット)
  • Firefox 92.0 (64ビッド)
  • 計測は100回実施した平均値を、それぞれのブラウザ、JavaScript/WebAssemblyの項目を変えながら2回以上求めたおおよその値

開発、実行環境の概要

  • WebAssemblyはRustで開発し、node.jsのwebpackによってパッケージ化する。
    • index.html: ブラウザで表示するファイル。
    • index.js: index.htmlから読み込まれ、動画の読み込み、画像データの取得、WebAssemblyの読み込み、画像処理、結果の表示を行う。
    • TimeChecker.js: 処理時間を計測するためのJavaScript。
    • lib.rs: WebAssemblyになるRustのコード。
  • WebAssemblyは、wasm-packにより開発環境の構築を構築した。

以下にまず、それぞれの開発したソースコードを示し、最後に開発環境構築について記述する。

JavaScriptとRustとの共有メモリの構造

上記のように、Rust側で作成したpixels0, pixels2をJavaScriptのUnit8Array, Uint8ClampedArrayなどによって作成されたwasm_pixels0, wasm_pixels2によって共有する。また、wasm_pixels2はそのメモリを使ったImageDataを作成し、Cavansに描画する。

HTML(index.html)

HTMLは、wasm-packで作成されたプロジェクトのstatic/index.htmlを編集する。
以下は、HTMLの全文。

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>WebAssembly(Rust) vs. JavaScript</title>
  <!-- 速度計測のためのJavaScript、内容についてはこの記事で後述する。-->
  <script src="TimeChecker.js"></script>
</head>

<body>
  <video id="video"></video>
  <canvas id="canvas"></canvas>
  <script src="index.js"></script>
</body>

</html>

JavaScriptのコード(index.js)

WebAssemblyのRustコードは、後述するwebpackで作成されているものとする。
JavaScriptのコードは、wasm-packで作成されたプロジェクトのjs/index.jsに記述する。

以下は、プログラムの全文。

// WebAssembly(Rust)の読み込みをawaitを使用して同期的に行うため、処理をasync関数として実装する。
(async () => {
    // WebAssemblyの読み込み
    let wasm = await import("../pkg/index.js").catch(console.error);
    // WebAssemblyのmemory変数を使用するために読み込む
    let wasm_bg = await import("../pkg/index_bg.wasm").catch(console.error);

    // htmlのvideoエレメントで動画を再生する。
    const video_opt = {
        width: { ideal: 1920, min: 640 },
        height: { ideal: 1080, min: 480 },
    };
    const stream = await navigator.mediaDevices.getUserMedia({audio:false,video:video_opt});
    const settings = stream.getTracks().filter(e => e.kind === "video")[0].getSettings();
    console.log(settings);
    const video = document.getElementById("video");
    video.srcObject = stream;
    video.play();

    // 動画はcanvasエレメントに画像をコピーして使用する。
    const canvas = document.getElementById("canvas");
    canvas.width = settings.width;
    canvas.height = settings.height;
    const ctx = canvas.getContext("2d");

    // Universeという名前で公開されたRustのクラスのようなものを生成する。
    const universe = wasm.Universe.new(settings.width, settings.height,10,10);
    const memory = wasm_bg.memory;

    // Rust上に作成された画像データをJavascript側で共有するためのUint8Arrayを作成する。
    // これは、JavaScriptからRust側へ送る画像データとして使用する。
    const wasm_pixels0 = new Uint8Array(memory.buffer, universe.pixels0(),
        settings.width * settings.height * 4);
    // Rustで画像処理した結果が保存されているメモリをUint8ClampedArrayで共有する。
    const wasm_pixels2 = new Uint8ClampedArray(memory.buffer, universe.pixels2(),
        settings.width * settings.height * 4);
    // Rustのpixels2のメモリを共有したUint8ClampedArrayによるImageDataを生成する。
    // ImageDataはCanvasエレメントに描画される。
    const img2 = new ImageData(wasm_pixels2, settings.width);

    // HTMLの描画タイミングで呼ばれるrenderLoopメソッドを作成する。
    const renderLoop = () => {
        // videoエレメントの動画をキャプチャしてcanvasに描画する。
        ctx.drawImage(video, 0, 0, settings.width, settings.height);

        // ここから処理速度の計測を始める。TimeChecker.jsについては後述する。
        TimeChecker.check(0);

        // canvasから各画素のデータを取得する。
        const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
        // 各画素のデータを、Rustのメモリにコピーする。
        wasm_pixels0.set(img.data);
        // Rustでの画像処理を起動する。
        universe.cells_update(0.01);
        // Rustの処理結果はpixels2に保存され、それを共有しているimg2を使ってcanvasを上書き描画する。
        ctx.putImageData(img2, 0, 0);

        // ここまでの処理を計測する。
        TimeChecker.check(1);

        // 次のHTML再描画のタイミングでrenderLoopが呼ばれるように登録する。
        requestAnimationFrame(renderLoop);
    };
    // renderLoop関数をHTMLの再描画時に呼び出すように登録する。
    // これ以後は、renderLoop内で再度、自分自身のrenderLoop関数を再描画時の
    // ハンドラとして登録し続けることによって繰り返し処理を実現する。
    requestAnimationFrame(renderLoop);
})();

処理時間を計測するためのJavaScript(TimeChecker.js)

処理速度計測用のjsファイルの全文、このファイルは、wasm-packで作成されたプロジェクトの「static」フォルダにTimeChecker.jsファイルとして新規作成する。

// 処理時間を計測する。
// TimeChecker.num回数だけ実施し、その平均をコンソールに表示する。
const TimeChecker = {
    buffer: undefined,
    num: 100,
    check: function (st) {
        if (this.buffer === undefined) {
            this.buffer = Array(this.num).fill(0);
            this.buffer_index = 0;
        }
        if (this.buffer_index === this.num) {
            console.log(this.buffer.reduce((a, e) => a + e) / this.num);
            this.buffer_index = 0;
        }
        if (st === 0) {
            this.buffer[this.buffer_index] = Date.now();
        } else {
            this.buffer[this.buffer_index] = Date.now() - this.buffer[this.buffer_index];
            this.buffer_index += 1;
        }
    },
    sample_function: ()=>{
        for(let i=0;i<200;i++){
            TimeChecker.check(0);
            [...Array(10000).keys()].forEach(e=>e*e);
            TimeChecker.check(1);
        }
    },
};

WebAssemblyのコード(lib.rs)

Rustのコードは、wasm-packで作成されたプロジェクトのsrc/lib.rsを編集する。
以下は、プログラムの全文。

use wasm_bindgen::prelude::*;
use web_sys::console;
// 比較関数の使用のために定義する。
use std::cmp;

// 英語で書かれたコメントは、rust-webpackテンプレートから受け継がれたままのコード

// When the `wee_alloc` feature is enabled, this uses `wee_alloc` as the global
// allocator.
//
// If you don't want to use `wee_alloc`, you can safely delete this.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

// This is like the `main` function, except for JavaScript.
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
    // This provides better error messages in debug mode.
    // It's disabled in release mode so it doesn't bloat up the file size.
    #[cfg(debug_assertions)]
    console_error_panic_hook::set_once();

    // Your code goes here!
    // 以下のように記述するとブラウザのコンソールに文字が表示される。
    // 例えば数値を出力したい場合は、from_f32を使用する。
    console::log_1(&JsValue::from_str("started wasm"));
    Ok(())
}

// 画像を分割する各セルの構造体
#[derive(Default)]
pub struct Cell {
    x: u32,
    y: u32,
    w: u32,
    h: u32,
    size: u32,
    ave: [f32;3],
    prev_ave: [f32;3],
}

// JavaScript側に公開されるクラスのようなもの
#[wasm_bindgen]
pub struct Universe {
    width: u32,
    height: u32,
    pixels0: Vec<u8>,
    pixels2: Vec<u8>,
    cells: Vec<Cell>,
}

// クラスのようなものの実態
#[wasm_bindgen]
impl Universe {
    // クラスを生成する。
    pub fn new(w:u32,h:u32,cell_w:u32,cell_h:u32) -> Universe {
        let width = w;
        let height = h;
        // pixels0は、javaScriptから画像データを受け取るために使用する。
        let pixels0: Vec<u8> = (0..(w*h*4))
            .map(|_| {0})
            .collect();
        // pixels2は、処理結果を保存するために使用する。
        let mut pixels2 = pixels0.clone();
        for y in 0..h {
            for x in 0..w {
                let idx:usize = ((y*w+x)*4) as usize;
                pixels2[idx+3] = 255;
            }
        }
        let cells = Universe::create_cells(w,h,cell_w,cell_h);

        // 作成されたインスタンスを戻す。
        // Rustはセミコロンは文を区切るために使用し、セミコロンが無いとCのreturnのようになる。
        Universe {
            width,
            height,
            pixels0,
            pixels2,
            cells,
        }
    }

    // Rustのメモリのポインタを返す。
    pub fn pixels0(&self) -> *const u8 {
        self.pixels0.as_ptr()
    }
    pub fn pixels2(&self) -> *const u8 {
        self.pixels2.as_ptr()
    }

    // セル構造体を作成する内部関数
    fn create_cells(canvas_w:u32,canvas_h:u32,cell_w:u32,cell_h:u32) -> Vec<Cell>{
        let p0:[u32;2] = [
            (((canvas_w % cell_w) /2) - cell_w) % cell_w,
            (((canvas_h % cell_h) /2) - cell_h) % cell_h,
        ];
        let mut cells = Vec::new();
        for y in (p0[1]..canvas_h).step_by(cell_h as usize){
            for x in (p0[0]..canvas_w).step_by(cell_w as usize){
                let mut cell = Cell{
                    x : cmp::max(0,x),
                    y : cmp::max(0,y),
                    ..Default::default()
                };
                cell.w = cmp::min(x+cell_w,canvas_w)-cell.x;
                cell.h = cmp::min(y+cell_h,canvas_h)-cell.y;
                cell.size = cell.w * cell.h;
                cells.push(cell);
            }
        }
        cells
    }
    // 画像処理を実行する関数。
    // 引数r0は、差分を更新する割合(大きいほど画素の平均に最新画素の情報が大きく反映される)
    pub fn cells_update(&mut self,r0:f32){
        // 処理効率のために、予めr0の排反事象の確率r1 = (1 - r0)を求めておく。
        let r1 = 1_f32-r0;

        // 各セルでループする。
        for i in 0..self.cells.len(){
            // 前回実行時の平均値をバックアップし、最新の平均値を初期化する。
            for j in 0..3 {
                self.cells[i].prev_ave[j] = self.cells[i].ave[j];
                self.cells[i].ave[j] = 0_f32;
            }

            // セル内のすべての画素値を足し合わせる。
            for x in self.cells[i].x..(self.cells[i].x+self.cells[i].w){
                for y in self.cells[i].y..(self.cells[i].y+self.cells[i].h){
                    let idx:usize = ((y*self.width+x)*4) as usize;
                    for j in 0..3 {
                        self.cells[i].ave[j] += self.pixels0[idx+j] as f32;
                    }
                }
            }
            // 合計になっている画素値を画素数で割って、新しい平均値を前の平均値からの指定された割合で
            // 混合して新しい平均値を求める。
            for j in 0..3 {
                self.cells[i].ave[j] = r1*self.cells[i].prev_ave[j] + r0*self.cells[i].ave[j] 
                    / (self.cells[i].size as f32);
            }

            // 平均化した画素値でセル内のすべての画素を上書きする。
            // ただしこの処理の部分は、JavaScriptを処理速度を比較するために、
            // 処理速度の計測時は、この部分はコメントアウトして実行した。
            for x in self.cells[i].x..(self.cells[i].x+self.cells[i].w){
                for y in self.cells[i].y..(self.cells[i].y+self.cells[i].h){
                    let idx:usize = ((y*self.width+x)*4) as usize;
                    for j in 0..3 {
                        self.pixels2[idx+j] = self.cells[i].ave[j] as u8;
                    }
                }
            }
        }
    }
}

WebAssemblyの開発環境構築

Rustのチュートリアルの「4.1.Setup」, 「4.2. Hello, World!」に従い、開発環境を構築する。ここでは、エラーが発生した場合の対処方法を記述する。

  • cargo install cargo-generateでlink.exeの1181エラー等
    • Build Tools for Visual Studio 2019をインストールする。Build Toolsは、2021.09.16現在で、公式サイトの画面下にある「Visual Studio 2019のツール」「Build Tools for Visual Studio 2019」からダウンロードできる。
    • スタートメニューから「Developer Command Prompt for VS 2019」を起動してコマンドを実行し直す。
  • npm init wasm-app wwwでerrno: -4058, code ENOENT, syscall spawn git, path git

「4.4. Implementing Life」まで実施すると、WebAssemblyによるLife Gameがブラウザ上で実行される。

プロジェクトのwebpack化

Rustチュートリアルで作成したWebAssemblyは、node.jsをサーバとしたnode moduleとして作成されている。ここでは、Apacheなどの他のWebサーバで実行できるようにwebpack化する方法について示す。

webpack5とは、HTML,CSS,JavaScriptなどの複数のファイルを1つにまとめてくれるもので、node.jsのパッケージ(機能)である。

この記事内では、wasm-packを使用してwebpack化されたプロジェクトを作成する。

  • wasm-packをインストールしていない場合、wasm-pack-init.exe ここからダウンロードでwasm-packのインストールする。
  • 以下は、基本的にはwasm-webpackのチュートリアル5.1.2. Using your libraryに従って実行する。
  • npm init rust-webpack your-package-name によって、上記のチュートリアルとは別のプロジェクトを作成する。
    • コマンド実行したフォルダに、your-package-nameのフォルダが作成される。
    • 2021.09.16時点でプロジェクトフォルダ内に作成されるファイルの場所が、チュートリアルと以下のように違っている。
      • index.html -> static/index.html
      • crate/src/lib.rs -> src/lib.rs
  • static/index.html, src/lib.rs, js/index.jsファイルを、Rustチュートリアルの「4.4. Implementing Life」の状態に書き換える。
  • js/index.jsに以下のコードを追加する。
(async ()=>{
    let wasm = await import("../pkg/index.js").catch(console.error);
    let wasm_bg = await import("../pkg/index_bg.wasm").catch(console.error);

    // here is sample codes...
    // const ptr = wasm.published_get_pointer_function();
    // const pixels = new Uint8Array(wasm_bg.memory.buffer, ptr, 1920 * 1080 * 4);
})();
  • プロジェクトのフォルダでnpm run buildを実行する。
    • distフォルダが作成される。
  • distフォルダをWebとして公開する。

Apacheサーバでdistフォルダを公開してエラーになる場合、以下のMIMEタイプの設定を行う。

  • vi /etc/mime.types
  • 最後の行に、application/wasm wasmを追加する。
  • sudo service httpd restart 等でapacheを再起動する。
25
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
9