🎥

Flutter WebでTensorFlow.jsを使う

2021/07/03に公開

背景

Flutterにおけるデスクトップデバイス

Flutterがアツいですね。先日のGoogle I/Oではさらなるアップデートが発表され、その進化は止まる所を知りません。

とはいえ、さすがのFlutterも、デスクトップデバイスのサポートはまだまだ発展途上にあります。自分のFlutter Appでデスクトップをサポートしたい場合は(ネイティブアプリ || Web)という2つの選択肢があるものの、Flutterではそのどちらもが後回しにされているというのが現状です。モバイルファーストなこの時代においては仕方のないことですが、この現状のおかげで苦しむことになります。

実現したいこと

今回Flutterアプリ for デスクトップでサポートしたい内容は、機械学習です。より具体的には、デスクトップカメラからユーザの姿を取得し、骨格推定を行います。

https://learnopencv.com/deep-learning-based-human-pose-estimation-using-opencv-cpp-python/

断念したアプローチ

ML Kit

Flutterは機械学習を利用するためのSDK「ML Kit」をサポートしています。最初のうちは、「ML Kitでいけるやろ!」と安易に考えていました。ところが、いざ実装する段になってサポート対象を見てみると

ML Kit is a mobile SDK that brings Google's on-device machine learning expertise to Android and iOS apps.

https://developers.google.com/ml-kit/guides
iOS/Android限定、だと…?「無理矢理使ってみた」的な記事を探しましたが見つからず、このアプローチは断念しました。

TensorFlow

次に検討したのはTensorFlowです。こちらもGoogleが提供する機械学習ライブラリです。ダメ元でDartのサポートを探しましたが、見つかりません。そりゃそうだよね、聞いたことないもんね…

ということで、この時期の僕は絶望感に押しつぶされそうでした。
https://www.tensorflow.org/

今回採用したアプローチ

そんな中、救世主として現れたのがTensorFlow.jsです。というのも、Flutter WebってDartをdart2jsでJavaScriptに変換して動くんですよね。ということは、TensorFlow.jsも使えるのでは?という考えで模索することにしました。

ここまで絶望続きでしたが、今回は希望がありました。Dart関数からJavaScript関数を呼ぶこと自体は公式にサポートされていたのです。公式ドキュメントはFlutter Packages > js > Readmeでご覧いただけますが、説明が簡潔すぎて自分は混乱したので、よく分からなかった方はこの後もお付き合いください。逆に、このReadmeでチョットデキルになった人はここでさようならです。
https://pub.dev/packages/js

ということで、以下のような流れで実装します。

  1. Dartファイルとは別に、JavaScriptファイルを用意する
  2. JavaScriptファイル内でTensorFlow.jsを呼ぶ
  3. Dart関数からJavaScript関数(TensorFlow.jsを呼んでるやつ)を呼ぶ
    JavaScriptに仲介してもらうイメージですね。より詳しいことは、この後コードを交えて説明します。

本題

利用するモデル

利用するモデルはPoseNetです。これは名前の通り姿勢推定を行うモデルです。公式ドキュメントは一つ目のリンク内README.mdで、動画を含む利用イメージはTensorFlow Blogでご覧いただけます。
https://github.com/tensorflow/tfjs-models/tree/master/posenet
https://blog.tensorflow.org/2018/05/real-time-human-pose-estimation-in.html

実装

Flutter Appを作成する

まずはFlutter Appを作成します。

$ flutter create flutter_tfjs_demo && cd flutter_tfjs_demo && flutter run -d chrome

カウンターを表示する例のアレが表示されたらOKです。

例のアレ

画面をつくる

画面を作るといっても、ボタンを一つ置くだけの超絶シンプルなものです。
lib/main.dart を以下の内容に書き換えてください。

ワーキングディレクトリについて

これ以降、ワーキングディレクトリは常に flutter_tfjs_demo/ としてお話しします。したがって、たとえば lib/main.dart という場合は flutter_tfjs_demo/lib/main.dart を指します。

lib/main.dart
import 'package:flutter/material.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter TensorFlow.js Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // TODO: get videoElement
            // TODO: call poseNet with videoElement
            print('button pressed');
          },
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Icon(
              Icons.play_circle,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

カメラにアクセスする

以下の記事を参考に、カメラへアクセスするコードを書きます。
https://qiita.com/tfandkusu/items/afd3fedcfda6e7c9f271

  1. 新規Dartファイルを作成する(video_brain.dart とか)
  2. 新しいクラスを宣言する(VideoBrain とか)
  3. videoElementを返す非同期関数を用意する(getVideoElement() とか)
lib/video_brain.dart
import 'dart:html';
import 'dart:ui' as ui;

class VideoBrain {
  final VideoElement _videoElement = VideoElement();

  Future<VideoElement> getVideoElement() async {
    ui.platformViewRegistry.registerViewFactory(
      'videoElement',
      (int viewId) => _videoElement,
    );
    window.navigator
        .getUserMedia(
      video: true,
    )
        .then((MediaStream mediaStream) {
      _videoElement.srcObject = mediaStream;
    });
    _videoElement.play();

    return _videoElement;
  }
}

カメラにアクセスできているか、確認してみましょう。lib/main.dart を以下の通り変更してください。

lib/main.dart
import 'package:flutter/material.dart';

// more lines

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp());
}

// more lines

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
-            // TODO: get videoElement
-            // TODO: call poseNet with videoElement
-            print('button pressed');
+            VideoBrain _videoBrain = VideoBrain();
+            VideoElement _videoElement = await _videoBrain.getVideoElement();
            print('button pressed');
          },
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Icon(
              Icons.play_circle,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

画面中央のボタンを押して、カメラアクセス許可の要求を確認してください。

カメラへのアクセス許可を要求される

TensorFlow.js > PoseNetを扱う

次に、PoseNetを使う準備をしましょう。以下の内容はPoseNetのREADME.mdを原則として踏襲しています。

PoseNetを読み込む

まず下準備として、PoseNetを読み込まなければいけません。web/index.html を開き、以下の内容に書き換えてください。

web/index.html
<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8" />
		<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
		<meta name="description" content="A new Flutter project." />

		<meta name="apple-mobile-web-app-capable" content="yes" />
		<meta name="apple-mobile-web-app-status-bar-style" content="black" />
		<meta name="apple-mobile-web-app-title" content="stat_flutter" />
		<link rel="apple-touch-icon" href="icons/Icon-192.png" />

		<link rel="icon" type="image/png" href="favicon.jpg" />

		<title>flutter_tfjs_demo</title>
		<link rel="manifest" href="manifest.json" />
	</head>
	<body>
		<!-- Tensorflow.js -->
		<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
		<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/posenet"></script>

		<script src="posenet.js" defer></script>
		<script src="main.dart.js" type="application/javascript"></script>
	</body>
</html>

JavaScriptファイルからPoseNetを呼び出す

ついに本題です。PoseNetを呼びましょう。といっても、あまりに簡単なので拍子抜けするかもしれません。より詳しい使い方は、例の如くREADME.mdをご覧ください。

web/ ディレクトリに posenet.js を作成し、内容を以下に変更してください。

posenet.js
async function estimatePoseOnVideo(videoElement, email) {
	const poseNet = await posenet.load();
	const pose = await poseNet.estimateSinglePose(videoElement, {
	    flipHorizontal: false
	});

	return pose;
}

Dart ↔︎ JavaScriptを繋げる

本題です。さっき本題と言いましたが、実はこっちの方が本題です。すでに作成した posenet.js の関数 estimatePoseOnVideo() を、Dartから呼ばなければなりません。

ここで登場するのが、js パッケージです。
さっそく、このパッケージをプロジェクト flutter_tfjs_demo に追加しましょう。

$ flutter pub add js && flutter pub get

https://pub.dev/packages/js

dart:js との違い

js パッケージのReadmeに記載されている通り、Dartに組み込まれている dart:js とは異なるパッケージです。というよりも、dart:js を代替する(superseed)するものです。文法が根本的に異なるので、こちらをimportしないようご注意ください。

Important: This library supersedes dart:js, so don't import dart:js. Instead, import package:js/js.dart.

次に、JavaScriptファイルとDartファイルの架け橋を作りましょう。lib/bridge_to_js.dart を作成し、以下の内容に変更します。

lib/bridge_to_js.dart
()
library posenet;

import 'dart:html';
import 'package:js/js.dart';

void callPoseNet(VideoElement videoElement) {
  estimatePoseOnVideo(videoElement);
}

()
external void estimatePoseOnVideo(VideoElement videoElement);

これで、Dart関数からJavaScript関数 estimatePoseOnVideo() を呼べるようになりました。

PoseNetにVideoElementを渡す

上で見た通り、callPoseNet()VideoElement 型の引数を受け取ります。最後にすべきことは、この引数を callPoseNet() (Dart関数)経由で estimatePoseOnVideo() (JavaScript関数)に渡すことです。VideoElement を受け取る関数は、VideoBrain.dart に用意したので、これを使いましょう。

lib/main.dart を以下の通り変更してください。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_tfjs_demo/bridge_to_js.dart';
import 'package:flutter_tfjs_demo/video_brain.dart';
+ import 'dart:html';

void main() => runApp(MyApp());

// more lines

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: ElevatedButton(
-          onPressed: () {
+          onPressed: () async {
-            // TODO: get videoElement
-            // TODO: call poseNet with videoElement
-            print('button pressed');
+            while (true) {
+              VideoBrain _videoBrain = VideoBrain();
+              VideoElement _videoElement = await _videoBrain.getVideoElement();
+              await Future.delayed(const Duration(milliseconds: 50));
+              callPoseNet(_videoElement);
+              // exec every 5 secs and will never stop
+              await Future.delayed(const Duration(seconds: 5));
            }
          },
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Icon(
              Icons.play_circle,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}
  1. flutter run -d chrome で再度実行し、中央のボタンをクリックする
  2. fn + F12Chrome DevTools > Console タブを開く
  3. カメラアクセスの要求を許可する
    Console でPoseNetからの出力を確認できましたか?これで完成です!


PoseNetからの出力はこんな感じになる
とはいえ、このままじゃ使えませんね。この値を、どう料理するかはあなた次第です。こちらで簡単な使用例が紹介されているので、ご参考までに。
https://medium.com/tensorflow/real-time-human-pose-estimation-in-the-browser-with-tensorflow-js-7dd0bc881cd5

最後に

稚拙な文章にお付き合いいただきありがとうございました。誤っている点があれば、コメントでご指摘ください。また、不明な点があればTwitterまでお気軽にご連絡下さい。平日夜 or 土日であれば、喜んでお手伝いします。今回のコード全体はGitHubでご覧いただけます。
https://github.com/yhakamay/flutter_tfjs_demo
https://twitter.com/yhakamay

Discussion