Hanatare's PaPa

Make life a little richer.

Virtual Space of Hanatare's PaPa

人生をほんの少しだけ充実させる

【WASM】はじめてのWebAssembly:RustとWASMでブラウザーベースの画像フィルターアプリを作る②

前回のブログ記事ではWASMの概要とRustで書いた画像処理コードをWebAssembly(WASM)モジュールとしてコンパイルしました。今回は前回作成したWASMのモジュールをブラウザから操作できるようにHTMLとJavaScriptでフロントエンドを構築していきます。

www.hanatare-papa.jp

記事のポイント
  • WASMを呼び出すフロントエンドの構築
  • Rustでセピアフィルターの追加

HTMLとJavaScriptでフロントエンドを構築する

ユーザーが画像をアップロードしたり、フィルターを選択する画面を作成します。今回はシンプルなものを構築しようと思いますので、HTMLと素のJavaScriptだけで構築していきたいと思います。

HTMLファイルの作成(index.html)

プロジェクトのルートディレクトリ(pkgディレクトリと同じ階層)にindex.htmlという名前のファイルを作成し、以下の内容を記述してください。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Rust & WASM 画像フィルター</title>
    <style>
        body { font-family: sans-serif; text-align: center; }
        canvas { border: 1px solid #ccc; margin-top: 20px; max-width: 100%; }
        div { margin-top: 10px; }
        button { margin: 0 5px; padding: 8px 16px; cursor: pointer; }
    </style>
</head>
<body>
    <h1>Rust & WASM 画像フィルター</h1>
    <p>フィルターを適用したい画像ファイル(PNGまたはJPEG)を選択してください。</p>
    <input type="file" id="upload" accept="image/png, image/jpeg">
    <div>
        <button id="btn-grayscale">グレースケール</button>
        <button id="btn-sepia">セピア</button>
        </div>
    <hr>
    <canvas id="canvas"></canvas>

    <script type="module" src="index.js"></script>
</body>
</html>

input type="file" id="upload"

ユーザーが画像ファイルを選択するための要素です 。

button

各フィルターを適用するためのボタンです。

canvas id="canvas"

加工前と加工後の画像を表示するための描画領域です。

script type="module" src="index.js"

JavaScriptファイルを読み込みます。type="module"と指定することで、ESモジュール構文(importなど)が使えるようになります。

JavaScriptの連携ロジック (index.js)

次に、HTML要素を操作し、WASMモジュールと連携させるためのindex.jsファイルを作成します。これもindex.htmlと同じ階層に作成してください。ここが、フロントエンドとWASMバックエンドを繋ぐ、最も重要な部分です。

// 1. WASMモジュールから必要な関数をインポート
import init, { grayscale } from './pkg/wasm_image_filters.js';

async function run() {
    // 2. WASMモジュールを非同期で初期化
    await init();

    const upload = document.getElementById('upload');
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');

    // アップロードされた元の画像データを保持するための変数
    let originalFile = null;

    // 3. ファイルがアップロードされたときの処理
    upload.addEventListener('change', (e) => {
        if (!e.target.files || e.target.files.length === 0) return;

        originalFile = e.target.files[0];

        const reader = new FileReader();
        reader.onload = (event) => {
            const img = new Image();
            img.onload = () => {
                // Canvasのサイズを画像に合わせる
                canvas.width = img.width;
                canvas.height = img.height;
                // Canvasのサイズを画像に合わせる
                ctx.drawImage(img, 0, 0);
            };
            img.src = event.target.result;
        };
        // 画像をデータURLとして読み込み、表示用に使用
        reader.readAsDataURL(originalFile);
    });

    // 4. グレースケールボタンがクリックされたときの処理
    document.getElementById('btn-grayscale').addEventListener('click', () => {
        if (!originalFile) {
            alert('先に画像をアップロードしてください!');
            return;
        }
        
        const fileReader = new FileReader();
        fileReader.onloadend = (e) => {
            // 画像データを生のバイト配列(Uint8Array)として取得
            const imageDataBytes = new Uint8Array(e.target.result);

            // ★★★ Rustのgrayscale関数を呼び出す! ★★★
            const resultBytes = grayscale(imageDataBytes);

            // 結果をCanvasに描画
            displayResult(resultBytes);
        };
        // Rustに渡すために、ファイルをArrayBufferとして読み込む
        fileReader.readAsArrayBuffer(originalFile);
    });
    
    // セピアボタンの処理は後ほどここに追加します

    // 5. Rustから返されたバイト配列をCanvasに表示するヘルパー関数
    function displayResult(resultBytes) {
        // バイト配列をBlobオブジェクトに変換
        const blob = new Blob([resultBytes], { type: 'image/png' });
       // Blobから一時的なURLを生成
        const url = URL.createObjectURL(blob);
        
        const img = new Image();
        img.onload = () => {
            // Canvasに加工後の画像を描画
            ctx.drawImage(img, 0, 0);
           // メモリリークを防ぐためにURLを解放
            URL.revokeObjectURL(url);
        };
        img.src = url;
    }
}

//  run()の呼び出し
run();

JavaScriptコードは以下の流れになっています。

インポート

wasm-packが生成したJSファイルから、初期化用のinit関数と、私たちがRustで定義したgrayscale関数をインポートします 。

初期化

await init()の処理が、裏側で.wasmファイルを読み込んでコンパイルし、使える状態に準備してくれます。この処理が終わるまでWASM関数は呼び出せません。

ファイルハンドリング

FileReader APIを使って、ユーザーが選択したファイルを読み込みます。readAsDataURLは画像を直接表示するために、readAsArrayBufferは生のバイトデータをRustに渡すために使います。

Rust関数の呼び出し

const resultBytes = grayscale(imageDataBytes);という一行で、JavaScriptからRustへデータが渡され、高速に処理され、結果が再びJavaScript側に戻ってきます。

結果の表示

Rustから返ってきたのは、加工後の生データ(バイト配列)です。これをブラウザに表示させるために、BlobオブジェクトとURL.createObjectURLを使い、バイトデータを一時的な画像URLに変換し、に描画します。

実行結果確認

この時点で、ローカルサーバーで立ててindex.htmlにアクセスします。ローカルサーバーを立てる簡単な方法はPythonをインストールし、プロジェクトのルートディレクトリで以下のコマンドを実行することでローカルサーバーを起動することができます。

python -m http.server

サーバーの起動結果は以下のようになります。

その後ブラウザを起動し、http://localhost:8000にアクセスすると以下のような画面になります。

ファイル選択ボタンから画像ファイルを選択するとCanvasエリアに画像が表示されます。

グレースケールボタンをクリックすると以下のように読み込んだ画像にグレーフィルターがかけられます。

セピアフィルターを追加する

次は「セピア」フィルターを追加していこうと思います。

セピアカラーの計算式

セピア変換には確立された計算式が存在しています。各ピクセルのRGB値(赤、緑、青)に対して以下の計算を適用します。

  • newRed=(R×0.393)+(G×0.769)+(B×0.189)
  • newGreen=(R×0.349)+(G×0.686)+(B×0.168)
  • newBlue=(R×0.272)+(G×0.534)+(B×0.131)

計算結果が色の最大値である255を超えてしまった場合は、255に丸める(「クランプする」と言います)必要があります。

Rustでセピアフィルターを実装する

src/lib.rsファイルを開き、以下のsepia関数を追記していきます。

#[wasm_bindgen]
pub fn sepia(image_data: &[u8]) -> Vec<u8> {
    log("Rust: sepia関数が呼ばれました!");
    let img = image::load_from_memory(image_data)
        .expect("画像のデコードに失敗しました");
    
    // DynamicImageを、ピクセル操作が可能なRgbaImageに変換
    let mut rgba_img = img.to_rgba8();
    
    // 画像の各ピクセルを可変な状態でループ処理
    for pixel in rgba_img.pixels_mut() {
        //  各チャンネル(R, G, B)を個別に取り出す
        let r = pixel[0] as f32; // R値
        let g = pixel[1] as f32; // G値
        let b = pixel[2] as f32; // B値
        // pixel[3] はアルファ値(透明度)

        // セピア変換の計算式を適用
        let new_r = (r * 0.393) + (g * 0.769) + (b * 0.189);
        let new_g = (r * 0.349) + (g * 0.686) + (b * 0.168);
        let new_b = (r * 0.272) + (g * 0.534) + (b * 0.131);

        // 各チャンネルに計算結果を書き込む
        // 計算結果が255を超えないように値を丸める(クランプ)
        pixel[0] = new_r.min(255.0) as u8; // R値の更新
        pixel[1] = new_g.min(255.0) as u8; // G値の更新
        pixel[2] = new_b.min(255.0) as u8; // B値の更新
        // アルファ値 (pixel[3]) は変更しない
    }

    let mut buf = Vec::new();
    // 変更が加えられたrgba_imgをエンコードする
    rgba_img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
        .expect("画像のエンコードに失敗しました");
    
    log("Rust: セピア処理が完了しました。");
    buf
}

pixel_mut()メソッドを使って、画像のすべてのピクセルに対して変更可能な参照値を取得し、ループ内で色を書き換えていくことができます。 このコードでは、赤、緑、青に対応させています。

コードを追記したら再度ビルドをしてWASMモジュールを最新化します。

wasm-pack build --target web

index.jsにセピア処理の呼び出しを追加する

index.jsにセピアボタンの処理を追加していきます。 先ほどの記載したinde.jsの「セピアボタンの処理は後ほどここに追加します」の直下に以下の内容を記載します。

    document.getElementById('btn-sepia').addEventListener('click', () => {
        if (!originalFile) {
            alert('先に画像をアップロードしてください!');
            return;
        }
        
        const fileReader = new FileReader();
        fileReader.onloadend = (e) => {
            const imageDataBytes = new Uint8Array(e.target.result);
            
            // ★★★ Rustのsepia関数を呼び出す! ★★★
            const resultBytes = sepia(imageDataBytes);
            
            displayResult(resultBytes);
        };
        fileReader.readAsArrayBuffer(originalFile);
    });

また、1行目を以下に変更します。

//変更前
import init, { grayscale } from './pkg/wasm_image_filters.js';

//変更後
import init, { grayscale,sepia } from './pkg/wasm_image_filters.js';

実行結果の確認

ローカルサーバーを再起動すると、先ほど画面でセピアボタンをクリックすると以下のような画面に切り替わります。

ここまでが、今回目的とした画像フィルターのアプリになります。

まとめ

今回はWASMをRustを使い実践的に学ぶこと意識して記事を書いてきました。Rustを使うことでWASMを手軽に試せることを感じていただけていたら嬉しいです。 今回の実装では、WASMを試すという部分に重点を置いたのでパフォーマンスの観点ではもう少し掘り下げることができると感じています。今回の実装では、画像データを処理するたびに、JavaScriptのメモリ空間からWASMのメモリ空間へ、そして処理後にWASMからJavaScriptへと、2回の大きなデータコピーが発生しています。5MBの画像なら、合計10MBのデータがコピーされている計算です。今回のアプリでは問題になりませんが、これがリアルタイムのビデオストリーム(毎秒30〜60フレーム)だった場合、このコピーのオーバーヘッドは致命的になります。

より高度なテクニックとして、「共有メモリ」を利用する方法があります。これは、JavaScriptとWASMが同じメモリ領域を直接参照し、データのコピーを一切行わずに処理を進める方法です。コピーを省略することでよりWASMのパフォーマンスをより効果的に活かすことができる方法なので、今後どこかで試せればと思っています。

今回の記事がWASMを学びたい方の参考になっていれば幸いです。