FluterとTFliteシリーズ第3弾!(正確には2.5弾)
今回はカメラストリーミングからほぼリアルタイムPoseEstimationを行います。
↓前回までの記事はこちら↓
①FlutterとTFLiteで”Hotdog or Not hotdog”
②FlutterとTFLiteでPoseEstimation
前回はカメラやギャラリーから写真を取ってきて、それを入力としてPoseEstimationを行いました。
今回はカメラストリーミングから常に動画を垂れ流しにして、そこに対して姿勢推定を行い、CustomPainterで関節位置に対して●を描画していきます。
使ったライブラリ
モデルは前回と同じくこちらから!
FlutterとTFLiteでPoseEstimationと同じアプリ内に実装しているので、気をつけて下さい。
コード全体
import 'dart:ui' as ui;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:tflite/tflite.dart';
// typedef void Callback(List<dynamic> list, int h, int w);
List<CameraDescription> cameras;
class CameraFeed extends StatefulWidget {
static const routeName = '/camera-feed';
@override
_CameraFeedState createState() => _CameraFeedState();
}
class _CameraFeedState extends State<CameraFeed> {
CameraController controller;
bool isDetecting = false;
Map<int, dynamic> keyPoints = {};
ui.Image image;
@override
void initState() {
super.initState();
availableCameras().then(
(cameras) {
CameraDescription rearCamera = cameras.firstWhere(
(description) =>
description.lensDirection == CameraLensDirection.back,
orElse: () => null);
if (rearCamera == null) {
return;
}
controller = new CameraController(rearCamera, ResolutionPreset.high);
controller.initialize().then(
(_) {
if (!mounted) {
return;
}
setState(() {});
controller.startImageStream(
(CameraImage img) async {
if (!isDetecting) {
isDetecting = true;
List recognition = await Tflite.runPoseNetOnFrame(
bytesList: img.planes.map((plane) {
return plane.bytes;
}).toList(),
imageHeight: img.height,
imageWidth: img.width,
numResults: 1,
);
print(recognition.length);
if (recognition.length > 0) {
// Should check mounted because setState is called after disposed
if (!mounted) {
return;
}
setState(() {
keyPoints = new Map<int, dynamic>.from(
recognition[0]['keypoints']);
});
print(keyPoints);
} else {
keyPoints = {};
}
if (!mounted) {
return;
}
if (!mounted) {
return;
}
setState(() {
isDetecting = false;
});
}
},
);
},
);
},
);
}
@override
void dispose() {
super.dispose();
controller?.dispose();
}
@override
Widget build(BuildContext context) {
if (controller == null || !controller.value.isInitialized) {
return Container();
}
var tmp = MediaQuery.of(context).size;
var screenH = math.max(tmp.height, tmp.width);
var screenW = math.min(tmp.height, tmp.width);
tmp = controller.value.previewSize;
var previewH = math.max(tmp.height, tmp.width);
var previewW = math.min(tmp.height, tmp.width);
var screenRatio = screenH / screenW;
var previewRatio = previewH / previewW;
return Scaffold(
appBar: AppBar(
title: Text('Camera'),
),
body: OverflowBox(
maxHeight: screenRatio > previewRatio
? screenH
: screenW / previewW * previewH,
maxWidth: screenRatio > previewRatio
? screenH / previewH * previewW
: screenW,
child: CustomPaint(
foregroundPainter: CirclePainter(keyPoints),
child: CameraPreview(controller),
),
),
);
}
}
class CirclePainter extends CustomPainter {
final Map params;
CirclePainter(this.params);
@override
void paint(ui.Canvas canvas, Size size) {
final paint = Paint();
paint.color = Colors.red;
if (params != null) {
params.forEach((index, param) {
canvas.drawCircle(
Offset(size.width * param['x'], size.height * param['y']),
5,
paint);
});
print("Done!");
}
}
@override
bool shouldRepaint(covariant CirclePainter oldDelegate) => true;
}
カメラ画像の垂れ流し
https://ryuta46.com/1076
こちらのページを参考にさせて頂き、cameraライブラリを使って作成しています。
availableCameras().then(
(cameras) {
CameraDescription rearCamera = cameras.firstWhere(
(description) =>
description.lensDirection == CameraLensDirection.back,
orElse: () => null);
if (rearCamera == null) {
return;
}
controller = new CameraController(rearCamera, ResolutionPreset.high);
まず、cameras.firstWhere
を使ってカメラの位置を取得します。
大体どのスマホにもフロントとリアカメラが付いているので、今回はリアカメラを利用しました。
CameraLensDirection.back
の部分をfront
に変えれば、インカメを取ってこれます。
CameraController
にはカメラの位置と、解像度を渡すことができます。解像度はlow, medium, high, veryHigh, ultraHigh, max, valueから選ぶことができます。
推論
controller.initialize().then(
(_) {
if (!mounted) {
return;
}
setState(() {});
controller.startImageStream(
(CameraImage img) async {
if (!isDetecting) {
isDetecting = true;
List recognition = await Tflite.runPoseNetOnFrame(
bytesList: img.planes.map((plane) {
return plane.bytes;
}).toList(),
imageHeight: img.height,
imageWidth: img.width,
numResults: 1,
);
推論部分の説明に入る前に、以降のコードでも所々if(!mounted)
が出てきます。これは今回の様なinitState
の中で非同期処理を流すような状況において、
画面遷移等によって「姿勢推定画面のWidgetTree」が消えた後に推論等の非同期処理が完了し、消えた「姿勢推定画面のWidgetTree」に対してsetState()
が呼び出されてしまうことによってエラーが発生してしまいます。
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree
これを回避するために、Stateオブジェクトの"mounted"プロパティをチェックすることによって呼び出そうとするWidgetがWidget Treeに存在してるかを確認することができます。
詳しくはこちらのページが参考になると思います。
【Flutter】setStateをinitStateの中で呼ぶ時の注意点
さて推論部分ですが、controller.startImageStream
によってカメラ画像をTflite.runPoseNetOnFrame
へ垂れ流していきます。
推論が完了したかどうかを確認するためにisDetecting
で確認を行っています。
今回はカメラフレームに対して推論を行うTflite.runPoseNetOnFrame()
であるため、カメラ画像をbyteList
へ変更する必要があります。
img.planes.map((plane) {return plane.bytes;}).toList(),
また、画像の高さ、幅も渡します。
numResults
は推論結果の出力数です。今回は一人分のみの出力とします。
if (recognition.length > 0) {
// Should check mounted because setState is called after disposed
if (!mounted) {
return;
}
setState(() {
keyPoints = new Map<int, dynamic>.from(
recognition[0]['keypoints']);
});
print(keyPoints);
} else {
keyPoints = {};
}
if (!mounted) {
return;
}
setState(() {
isDetecting = false;
});
推論が行われた場合、keyPoints
に関節の位置情報を格納します。ちなみに出力結果は0~1の正規化された値になっています。
推論が終了したら、isDetecting
をfalseにして推論が終了したことを知らせます。
推論結果の描画
@override
Widget build(BuildContext context) {
if (controller == null || !controller.value.isInitialized) {
return Container();
}
return Scaffold(
appBar: AppBar(
title: Text('Camera'),
),
body: Column(
children: [
Expanded(
child: CustomPaint(
foregroundPainter: CirclePainter(keyPoints),
child: CameraPreview(controller),
),
),
],
),
);
}
}
前回との違いはCustomPaint
内でpainter
ではなく、foregroundPainter
を使います。
というのも、前回はギャラリーから画像を取ってきてCustomPaint
に画像を渡して、画像を描画後に●の描画を行っていました。
しかし、今回は別で描画されるカメラ映像の上に重畳する形で関節位置に●を描画する必要があります。
親切にもStackOverflowにちょうど良い質問がありました。
CustomPaint Over Camera Preview
つまり、foregroundPainter
を使うことによって、最後にCustomPaintを描画することができます。
class CirclePainter extends CustomPainter {
final Map params;
CirclePainter(this.params);
@override
void paint(ui.Canvas canvas, Size size) {
final paint = Paint();
paint.color = Colors.red;
if (params.isNotEmpty) {
params.forEach((index, param) {
canvas.drawCircle(
Offset(size.width * param['x'], size.height * param['y']),
5,
paint);
});
print("Done!");
}
}
@override
bool shouldRepaint(covariant CirclePainter oldDelegate) => true;
}
こちらに関しては前回とほとんど同じです。
正規化された関節位置座標に対して、画像サイズをかけることで元のサイズに戻しています。
ただ、カメラフレーム毎で関節の●の描画を行う必要があるため、
bool shouldRepaint(covariant CirclePainter oldDelegate) => true;
となっています。
推論デモ
良い感じです!
手持ちのZenFone4だとさすがにリアルタイムとはいきませんでした。
ただ、徐々に姿勢を見極めて行くのが見れて、逆に面白いですね😆
こんな感じで一時期は一世を風靡した姿勢推定も今では誰もが使えるものになってきましたね!
色んなアプリが出てくるのが楽しみです🙌