ONNX Runtime Webを使ってBlazor WebAssemblyで手書き数字認識

今年はBlazor WebAssemblyで画像処理をするアプリを開発していました。そのアプリで手書き数字認識の機能を搭載したいと思っていたところ、ONNX Runtime Webを利用して比較的簡単に実現することができました。そのときの手順を参考にしてBlazor WebAssemblyのアプリに指定した画像の数字を認識する簡単なプログラムを作ってみます。

# ONNX Runtime Web (ORT Web) とは

ONNX (Open Neural Network Exchange) は機械学習モデルのフォーマットで、PyTorchなどの機械学習フレームワークからONNX形式でモデルをエクスポートすることができます。これまでONNX形式のモデルをWebブラウザで動作させるONNX.jsがありましたが、2021年9月2日にONNX Runtime Web (ORT Web) が公開されました。

https://cloudblogs.microsoft.com/opensource/2021/09/02/onnx-runtime-web-running-your-machine-learning-model-in-browser/

![ORT Webの概要](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/916b2ebd-9703-3111-b1ff-ac50ab2ddb10.png)

ONNX Runtime WebではWebAssemblyとWebGLバックエンドを別々に使用して、CPUとGPUの両方でWebブラウザでのモデル推論を高速化します。

# セットアップ

## Blazor WebAssembly

Visual Studio 2022でBlazor WebAssemblyのプロジェクトを作成して、NuGetで`SixLabors.ImageSharp`をインストールします。

`Pages/Index.razor`を以下のように編集します。

```csharp:Pages/Index.razor
@page "/"

@inject IJSRuntime JS

@using SixLabors.ImageSharp;
@using SixLabors.ImageSharp.PixelFormats;
@using SixLabors.ImageSharp.Processing;

<PageTitle>Index</PageTitle>

<p>
    <InputFile OnChange="@LoadImages" accept=".png,.jpg,.jpeg,.bmp,.gif" />
</p>

@if (@isLoading)
{
    <p>Loading...</p>
}
else if (@resizedImage != null)
{
    MemoryStream stream = new();
    resizedImage.SaveAsPng(stream);
    <p>
        <img src="data:image/png;base64,@Convert.ToBase64String(stream.ToArray())" style="width: 300px;" />
    </p>
    <p>@result</p>
}


@code {
    private long maxFileSize = 1024 * 1024 * 24;
    private int maxAllowedFiles = 1;
    private bool isLoading = false;
    private Image<Rgba32>? resizedImage;
    private int result;

    private async Task LoadImages(InputFileChangeEventArgs e)
    {
        isLoading = true;
        StateHasChanged();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            MemoryStream stream = new();
            await file.OpenReadStream(maxFileSize).CopyToAsync(stream);
            var image = Image.Load(stream.ToArray());
            // 指定された画像ファイルを28x28にリサイズ
            resizedImage = image.Clone(img => img.Resize(28, 28));

            // 画像のデータをONNXのために変換
            float[] data = new float[1 * 1 * 28 * 28];
            for (int y = 0; y < 28; y++)
            {
                for (int x = 0; x < 28; x++)
                {
                    data[28 * y + x] = (float)(255.0 - resizedImage[x, y].R) / 255.0f;
                }
            }
            result = await JS.InvokeAsync<int>("runOnnxRuntime", data);
        }

        isLoading = false;
    }
}
```

`wwwroot/index.html`に`dist/bundle.min.js`を読み込むタグを追加します。


```html:wwwroot/index.html
    <script src="_framework/blazor.webassembly.js"></script>
    <script src="dist/bundle.min.js"></script>
</body>

</html>
```

## ONNX Runtime Web (ORT Web)

さきほどとは別のフォルダを作成して、以下の3つのファイルを作成します。

```javascript:main.js
export const ort = require('onnxruntime-web');

let session;
window.runOnnxRuntime = async (methodParameter) => {
  if (session == null) {
    session = await ort.InferenceSession.create('/mnist-8.onnx');
  }
  const dataIn = Float32Array.from(methodParameter);
  const tensorIn = new ort.Tensor('float32', dataIn, [1, 1, 28, 28]);

  const results = await session.run({ Input3: tensorIn });
  const resultData = Array.from(results['Plus214_Output_0'].data);
  var currentMax = resultData[0];
  var resultValue = 0;
  resultData.forEach((v, i) => {
    if (v > currentMax) {
      currentMax = v;
      resultValue = i;
    }
  });
  return resultValue;
};
```

```json:package.json
{
  "name": "web-bundler",
  "private": true,
  "version": "1.0.0",
  "description": "(based on https://github.com/microsoft/onnxruntime-inference-examples/blob/main/js/quick-start_onnxruntime-web-bundler/package.json)",
  "dependencies": {
    "onnxruntime-web": "^1.8.0"
  },
  "devDependencies": {
    "copy-webpack-plugin": "^8.1.1",
    "webpack": "^5.36.2",
    "webpack-cli": "^4.6.0"
  }
}
```


```javascript:webpack.config.js
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = () => {
  return {
    target: ['web'],
    entry: path.resolve(__dirname, 'main.js'),
    output: {
      path: path.resolve(__dirname, 'wwwroot', 'dist'),
      filename: 'bundle.min.js',
      library: {
        type: 'umd'
      }
    },
    plugins: [new CopyPlugin({
      patterns: [{ from: 'node_modules/onnxruntime-web/dist/*.wasm', to: '[name][ext]' }]
    })],
    mode: 'production'
  }
};
```

`npm`で依存関係のあるパッケージをインストールします。

```sh
$ npm install
```

以下のコマンドを実行すると、`wwwroot/dist`以下にファイルが作成されます。

```sh
$ npx webpack
```

作成された`wwwroot/dist`を、Blazor WebAssemblyの`wwwroot/dist`にコピーします。

## 手書き数字認識のモデルをダウンロード

ONNXのリポジトリから手書き数字認識のモデルの`mnist-8.onnx`をダウンロードして、Blazor WebAssemblyのアプリの`wwwroot`以下にコピーします。

https://github.com/onnx/models/tree/master/vision/classification/mnist/model

# 実行

画像を指定してしばらくするとリサイズされた画像が拡大して表示され、その下に認識結果が表示されます。

![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238166/cedc6cde-baf5-ae9d-6939-925dd1a1f481.png)

# まとめ

ONNX Runtime Webを使ってBlazor WebAssemblyで手書き数字認識をしました。本当はせっかくBlazor WebAssemblyを使っているのだからJavascriptを書かずにC#のみで書きたかったのですが、Blazor WebAssemblyからML.NETを使ってONNXのモデルをロードしようとしたらうまくいかなかったので、まずはONNX Runtime Webを利用することにしました。今回のようにJavascriptの資産と連携できることもBlazor WebAssemblyのよいところかと思います。