2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

FlutterとTFLiteでほぼリアルタイムPoseEstimation

FluterとTFliteシリーズ第3弾!(正確には2.5弾)
今回はカメラストリーミングからほぼリアルタイムPoseEstimationを行います。
↓前回までの記事はこちら↓
FlutterとTFLiteで”Hotdog or Not hotdog”
FlutterとTFLiteでPoseEstimation

前回はカメラやギャラリーから写真を取ってきて、それを入力としてPoseEstimationを行いました。
今回はカメラストリーミングから常に動画を垂れ流しにして、そこに対して姿勢推定を行い、CustomPainterで関節位置に対して●を描画していきます。

使ったライブラリ

モデルは前回と同じくこちらから!
FlutterとTFLiteでPoseEstimationと同じアプリ内に実装しているので、気をつけて下さい。

コード全体

camera_feed.dart
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へ変更する必要があります。

画像を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にして推論が終了したことを知らせます。

推論結果の描画

CustomPaintによる描画
@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を描画することができます。

関節位置に●を描画するCustomPainter
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;

となっています。

推論デモ

PoseEstimation_realtime2.gif
良い感じです!
手持ちのZenFone4だとさすがにリアルタイムとはいきませんでした。
ただ、徐々に姿勢を見極めて行くのが見れて、逆に面白いですね😆
こんな感じで一時期は一世を風靡した姿勢推定も今では誰もが使えるものになってきましたね!
色んなアプリが出てくるのが楽しみです🙌

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
2
Help us understand the problem. What are the problem?