Flutter WebでTensorFlow.jsを使う
背景
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.
iOS/Android限定、だと…?「無理矢理使ってみた」的な記事を探しましたが見つからず、このアプローチは断念しました。
TensorFlow
次に検討したのはTensorFlowです。こちらもGoogleが提供する機械学習ライブラリです。ダメ元でDartのサポートを探しましたが、見つかりません。そりゃそうだよね、聞いたことないもんね…
ということで、この時期の僕は絶望感に押しつぶされそうでした。
今回採用したアプローチ
そんな中、救世主として現れたのがTensorFlow.jsです。というのも、Flutter WebってDartをdart2jsでJavaScriptに変換して動くんですよね。ということは、TensorFlow.jsも使えるのでは?という考えで模索することにしました。
ここまで絶望続きでしたが、今回は希望がありました。Dart関数からJavaScript関数を呼ぶこと自体は公式にサポートされていたのです。公式ドキュメントはFlutter Packages > js > Readmeでご覧いただけますが、説明が簡潔すぎて自分は混乱したので、よく分からなかった方はこの後もお付き合いください。逆に、このReadmeでチョットデキルになった人はここでさようならです。
ということで、以下のような流れで実装します。
- Dartファイルとは別に、JavaScriptファイルを用意する
- JavaScriptファイル内でTensorFlow.jsを呼ぶ
- Dart関数からJavaScript関数(TensorFlow.jsを呼んでるやつ)を呼ぶ
JavaScriptに仲介してもらうイメージですね。より詳しいことは、この後コードを交えて説明します。
本題
利用するモデル
利用するモデルはPoseNetです。これは名前の通り姿勢推定を行うモデルです。公式ドキュメントは一つ目のリンク内README.mdで、動画を含む利用イメージはTensorFlow Blogでご覧いただけます。
実装
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
を指します。
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,
),
),
),
),
);
}
}
カメラにアクセスする
以下の記事を参考に、カメラへアクセスするコードを書きます。
- 新規Dartファイルを作成する(
video_brain.dart
とか) - 新しいクラスを宣言する(
VideoBrain
とか) - videoElementを返す非同期関数を用意する(
getVideoElement()
とか)
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
を以下の通り変更してください。
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
を開き、以下の内容に書き換えてください。
<!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
を作成し、内容を以下に変更してください。
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
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
を作成し、以下の内容に変更します。
()
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
を以下の通り変更してください。
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,
),
),
),
),
);
}
}
-
flutter run -d chrome
で再度実行し、中央のボタンをクリックする -
fn
+F12
でChrome DevTools >Console
タブを開く - カメラアクセスの要求を許可する
Console
でPoseNetからの出力を確認できましたか?これで完成です!
PoseNetからの出力はこんな感じになる
とはいえ、このままじゃ使えませんね。この値を、どう料理するかはあなた次第です。こちらで簡単な使用例が紹介されているので、ご参考までに。
最後に
稚拙な文章にお付き合いいただきありがとうございました。誤っている点があれば、コメントでご指摘ください。また、不明な点があればTwitterまでお気軽にご連絡下さい。平日夜 or 土日であれば、喜んでお手伝いします。今回のコード全体はGitHubでご覧いただけます。
Discussion