Flutter Webで画像分類を行う(AutoML Vision, TensorFlow.js)

# はじめに

スプラトゥーン2のプレイヤー向けに、シーン抽出機能付きの動画プレイヤーアプリを[Flutter Web](https://flutter.dev/multi-platform/web)で作成しました。プレイの録画ファイルをブラウザにドラッグアンドドロップすると、試合の開始と終了、試合中のたおしたシーンとやられたシーンを頭出しします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/5261192a-1252-90c9-c8da-ff4851cf3fb1.png" width="640">

使ってみたい方はこちら。

https://ikut.app/

## 画像の引用

この記事では任天堂株式会社のゲーム、スプラトゥーン2のスクリーンショットを引用しています。

## 頭出しの方法

各シーンの頭出しは、動画からフレーム画像を抽出して、[Google AutoML Vision](https://cloud.google.com/vision/automl/docs)で作成した学習モデルを使って予測することで行っています。学習モデルはフレーム画像を以下の5クラスに分類するものです。

|　試合の開始 | 試合の終了 |
| --- | --- |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/0c2e845f-59fb-6184-f2ab-146c20d9afca.jpeg" width="320"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/b4a198ec-4d95-6cfe-ba07-dc8c47a50d5a.jpeg" width="320"> |


| たおした　 | やられた | その他 |
| --- | --- | --- |
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/bf6e4148-1960-6738-c86a-7d1ee0f5fbbb.png" width="320"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/d60d1219-6fe7-ed54-ae8f-11050fa64b59.png" width="320"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/bc27ca27-0839-7403-0e0c-6fd8e93f4165.jpeg" width="320"> |

## 学習モデルの作成

学習モデルは以下の枚数のラベル付けしたフレーム画像をAutoML Visionにインポートして作成しています。

| シーン | 枚数 |
| --- | --- |
| 試合の開始 | 5537 |
| 試合の終了 | 23494 |
| たおした | 1810 |
| やられた | 1532 |
| その他 | 19950 |
| 全体 | 52323|

AutoML Visionの画面はこのようになります。

![スクリーンショット 2021-11-28 2.05.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/1f2b636a-1b6e-2ec3-34c0-0c04f2884fab.png)

### ラベル付け作業について

約５万枚の画像を手作業で分類するのは大変ですが、[Google Cloud Vision APIのテキスト検出](https://cloud.google.com/vision/docs/ocr?hl=ja)を使用して一部自動化しています。その様子は、やられたシーンだけですが、こちらの記事で紹介しています。

[スプラトゥーン2のプレイ動画から、やられたシーンだけをディープラーニングで自動抽出する](https://qiita.com/tfandkusu/items/acbc0906046bb7b2b1db)

また、手作業をやりやすくするために、Flutter Desktopで簡易的なアノテーションツールも作成していて、こちらの記事で紹介しています。

[Flutter Desktopで簡易アノテーションツールを作る](https://qiita.com/tfandkusu/items/435650c5b4667ea97169)

## 学習モデルのエクスポート

今回はブラウザ内で予測したかったので、TensorFlow.js形式でモデルを出力しました。

![スクリーンショット 2021-11-28 2.32.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/7c5440c9-807c-b41d-ec07-5f5542dbb27c.png)

# ブラウザのJavaScriptからAutoML Visionで作成したモデルを使って、画像分類を行う

ブラウザのJavaScriptからAutoML Visionで作成したモデルを使って、画像分類を行う方法は、公式で紹介されています。

[Edge TensorFlow.js チュートリアル](https://cloud.google.com/vision/automl/docs/tensorflow-js-tutorial)

しかし今回はFlutter Webで作成したアプリなので、[こちらで紹介されているJavaScriptのコード](https://github.com/tensorflow/tfjs/blob/HEAD/tfjs-automl/code_snippets/img_classification.html)をFlutterから呼び出す方法を探す必要があります。

# pub.devからパッケージを探す

2021年3月4日に行われた[Flutter Engage](https://events.flutter.dev/)の[モバイルアプリからウェブアプリへ](https://www.youtube.com/watch?v=HAstl_NkXl0)セッションでは、[既存のJavaScriptライブラリにはプラグインからアクセスできて、pub.devでは対応プラットフォームがラベルとして表示される](https://www.youtube.com/watch?v=HAstl_NkXl0&t=343s)とありました。しかし探してみたところ、Webラベルが付いているTensorFlowのプラグインは見つけられませんでした。

![スクリーンショット 2021-11-28 3.23.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f85d99d6-4cf5-b59c-d587-1a2ce050fa97.png)

# FlutterからJavaScriptメソッドを直接呼ぶ

プラグインはないので[公式で紹介されているTensorFlow.jsを使うJavaScript関数](https://github.com/tensorflow/tfjs/blob/HEAD/tfjs-automl/code_snippets/img_classification.html)を直接呼ぶことにします。


## 学習モデルとjsファイルの設置

プロジェクト直下のwebディレクトリに学習モデルと、TensorFlow.jsを使うjsファイルであるpredict.jsを設置します。学習モデルはmodelディレクトリにまとめました。

![スクリーンショット 2021-11-28 3.30.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/1a18db47-95a4-8127-0d03-6b345f42a5c3.png)

## jsファイルの取り込み

web/index.htmlファイルの所定の位置にjsファイルを取り込むためのscriptタグを挿入します。

```index.html
<!DOCTYPE html>
<html>

<head>
  <!-- 略 -->
</head>

<body>
  <!-- 以下3行を挿入する -->
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.8.4/dist/tf.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-automl@1.0.0/dist/tf-automl.min.js"></script>
  <script src="predict.js"></script>
  <!-- This script installs service_worker.js to provide PWA functionality to
       application. For more information, see:
       https://developers.google.com/web/fundamentals/primers/service-workers -->
  <script>
    if ('serviceWorker' in navigator) {
      window.addEventListener('flutter-first-frame', function () {
        navigator.serviceWorker.register('flutter_service_worker.js');
      });
    }
  </script>
  <script src="main.dart.js" type="application/javascript"></script>
  <!-- 略 -->
</body>

</html>
```


## jsファイルの中身

モデルの読み込みは1回やればよく、画像の分類は動画の0.5秒おき等たくさん行うので、別の関数にしました。

```predict.js
// 分類モデル
var iKutModel = null;
// モデル読込済みフラグ
var iKutLoaded = false;

// モデルを読み込む
async function loadImageClassification() {
    if (!iKutLoaded) {
        // 試合の開始モデル読込
        iKutModel = await tf.automl.loadImageClassification('model/model.json');
        // 読込完了フラグ
        iKutLoaded = true;
    }
    return 0;
}


// 分類する
async function classify(image) {
    const result = await iKutModel.classify(image);
    return JSON.stringify(result, null, 2);
}
```

## jsパッケージを使い、JavaScript関数を直接呼び出す

Dartはjsパッケージを使うことで、JavaScript関数を呼び出すことができます。

**参考** [JavaScript interoperability](https://dart.dev/web/js-interop)

[API Reference](https://pub.dev/documentation/js/latest/)を参考に、dartファイルで関数とアノテーションを設定しました。
asyncキーワードで宣言された[非同期関数](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function)はPromiseを返却しますが、ここでは戻り値をObjectにします。その理由は追って説明します。
ImageElementはHTMLのimgタグになります。HTMLタグは[dart:htmlライブラリ](https://api.dart.dev/stable/2.14.4/dart-html/dart-html-library.html)に定義されています。


```predict.dart
@JS()
library predict;

import 'dart:html';

import 'package:js/js.dart';

@JS('loadImageClassification')
external Object loadImageClassification();

@JS('classify')
external Object classify(ImageElement img);
```

定義した関数はそのまま呼ぶことが可能です。JavaScriptのPromiseは[js_utilライブラリ](https://pub.dev/documentation/js/latest/js_util/js_util-library.html)の[promiseToFuture関数](https://api.dart.dev/stable/2.14.4/dart-js_util/promiseToFuture.html)で[Future](https://api.dart.dev/stable/2.14.4/dart-async/Future-class.html)に変換できます。戻り値をObjectにした理由は、promiseToFuture関数の引数がObjectだからです。


```dart
await promiseToFuture(loadImageClassification());
```

```dart
String result = await promiseToFuture(classify(imageElement));
```

## JavaScript側のnullに注意

Dartには[null safety](https://dart.dev/null-safety)の仕組みがありますが、Dart側でnon-nullableになっている変数にJavaScriptから返却されたnullを代入すると、実行時にUncaught Errorになってしまいます。ご注意ください。

# JavaScript関数呼び出しの前後について

## 動画のフレーム画像をimg要素にする

画像分類の入力はHTMLのimg要素なので、動画のフレームをimg要素にします。

まずvideo要素とcanvas要素を作成します。canvas要素のサイズは[学習モデルの入力層の形](https://qiita.com/tfandkusu/items/acbc0906046bb7b2b1db#%E5%85%A5%E5%8A%9B%E5%B1%A4%E5%87%BA%E5%8A%9B%E5%B1%A4%E3%81%AE%E5%BD%A2%E3%82%92%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B)に合わせました。

```dart
final VideoElement _videoElement = VideoElement();
final CanvasElement _canvasElement = CanvasElement(width: 224, height: 224);
```


このアプリではドラッグアンドドロップで動画ファイルを読み込みます。ドラッグアンドドロップは[flutter_dropzone](https://pub.dev/packages/flutter_dropzone)プラグインのDropzoneViewで使うことができます。

```dart
DropzoneView(
  operation: DragOperation.copy,
  cursor: CursorType.Default,
  onLoaded: () {},
  onError: (ev) {},
  onLeave: () {},
  onHover: () {},
  onDrop: (ev) {
    // ファイルがドラッグアンドドロップされた
    // ファイルのオブジェクトURLを取得できる
    final file = ev as File;
    final url = Url.createObjectUrl(file);
  }
});
```

取得したオブジェクトURLをvideo要素に読み込ませます。


```dart
_videoElement.src = url;
```

動画の長さが確定すると[loadedmetadataイベント](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadedmetadata_event)が呼ばれます。そうすると次に再生時間を更新できるようになるので、欲しいフレームの再生時間に更新します。

```dart
_videoElement.addEventListener("loadedmetadata", (event) {
  // 再生時間を更新
  _videoElement.currentTime = 0.0;
});
```

次に[timeupdateイベント](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/timeupdate_event)が呼ばれます。そこでvideo要素をcanvas要素のデータURLを経由してimg要素に変換します。

```dart
_videoElement.addEventListener("timeupdate", (event) {
  // video要素をcanvas要素に描き込む
  final context = _canvasElement.context2D;
  context.drawImageScaled(_videoElement, 0, 0, 224, 224);
  // canvas要素をデータURLに変換する
  final dataUrl = _canvasElement.toDataUrl();
  // img要素を作成する
  final imageElement = new ImageElement();
  // 画像の読み込み完了イベント
  imageElement.addEventListener("load", (event) async {
    // フレーム画像を分類する
    String result = await promiseToFuture(classify(imageElement));
    // 次節に続く
  });
  // データURLをimg要素に渡す。
  imageElement.src = dataUrl;
});
```

## JSON文字列から分類結果を取得する

分類結果のJSON文字列はこのようになっています。各ラベルの確率をprobフィールドから得ることができます。probが一番大きいラベルを取得すれば良いです。

```json
[
  {
    "label": "start",
    "prob": 0.012603357434272766
  },
  {
    "label": "other",
    "prob": 0.009589558467268944
  },
  {
    "label": "end",
    "prob": 0.010704034939408302
  },
  {
    "label": "death",
    "prob": 0.9580927491188049
  },
  {
    "label": "kill",
    "prob": 0.009010315872728825
  }
]
```

ラベル取得の処理はこちらになります。

```dart
String _getLabel(String jsonString) {
  // JSON文字列をデコードする
  List<dynamic> dynamicClasses = jsonDecode(jsonString);
  // label - prob のMapに変換する
  Map<String, double> classes = Map();
  dynamicClasses.forEach((e) {
    classes[e['label']] = e['prob'];
  });
  // Mapから値が一番大きいキーを得る
  String maxLabel = "";
  double maxProb = -1.0;
  classes.forEach((label, prob) {
    if (prob > maxProb) {
      maxLabel = label;
      maxProb = prob;
    }
  });
  return maxLabel;
}
```

# まとめ

- プラグインのFlutter Webへの対応状況は[pub.dev](https://pub.dev/)のWebラベルの有無で確認できます。
- [jsパッケージ](https://pub.dev/packages/js)を使うことによって、TensorFlow.jsのようなプラグインが提供されていないJavaScriptの処理も、Flutterから呼び出すことが可能です。
- JavaScript側でPromiseになっているオブジェクトは[js_utilライブラリ](https://pub.dev/documentation/js/latest/js_util/js_util-library.html)の[promiseToFuture](https://api.dart.dev/stable/2.14.4/dart-js_util/promiseToFuture.html)関数を使うことで、[Future](https://api.dart.dev/stable/2.14.4/dart-async/Future-class.html)のオブジェクトに変換することができます。