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

FluterとTFliteシリーズ第3弾！（正確には2.5弾）
今回はカメラストリーミングからほぼリアルタイムPoseEstimationを行います。
↓前回までの記事はこちら↓
①[FlutterとTFLiteで”Hotdog or Not hotdog”](https://qiita.com/bigface00/items/9312dd4a4fde1f4bcffe)
②[FlutterとTFLiteでPoseEstimation](https://qiita.com/bigface00/items/ad2bf48d406681fc35fb)

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

# 使ったライブラリ
- [camera](https://pub.dev/packages/camera)
- [tflite](https://pub.dev/packages/tflite)

モデルは前回と同じく[こちら](https://github.com/shaqian/flutter_tflite/blob/master/example/assets/posenet_mv1_075_float_from_checkpoints.tflite)から！
[FlutterとTFLiteでPoseEstimation](https://qiita.com/bigface00/items/ad2bf48d406681fc35fb)と同じアプリ内に実装しているので、気をつけて下さい。

# コード全体
```dart: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ライブラリを使って作成しています。

```dart:カメラの取得
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から選ぶことができます。

## 推論
```dart:カメラ映像を姿勢推定へ垂れ流す
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()```が呼び出されてしまうことによってエラーが発生してしまいます。

```dart:エラー
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の中で呼ぶ時の注意点](https://blog.mrym.tv/2019/12/traps-on-calling-setstate-inside-initstate/)

さて推論部分ですが、```controller.startImageStream```によってカメラ画像を```Tflite.runPoseNetOnFrame```へ垂れ流していきます。
推論が完了したかどうかを確認するために```isDetecting```で確認を行っています。
今回はカメラフレームに対して推論を行う```Tflite.runPoseNetOnFrame()```であるため、カメラ画像を```byteList```へ変更する必要があります。

```dart:画像をbyteListへ変換
img.planes.map((plane) {return plane.bytes;}).toList(),
```
また、画像の高さ、幅も渡します。
```numResults```は推論結果の出力数です。今回は一人分のみの出力とします。

```dart:推論結果の格納
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にして推論が終了したことを知らせます。

## 推論結果の描画
```dart: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](https://stackoverflow.com/questions/50473495/custompaint-over-camera-preview)
つまり、```foregroundPainter```を使うことによって、最後にCustomPaintを描画することができます。

```dart:関節位置に●を描画する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;
}
```
こちらに関しては前回とほとんど同じです。
正規化された関節位置座標に対して、画像サイズをかけることで元のサイズに戻しています。
ただ、カメラフレーム毎で関節の●の描画を行う必要があるため、

```dart
bool shouldRepaint(covariant CirclePainter oldDelegate) => true;
```
となっています。

# 推論デモ
![PoseEstimation_realtime2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271414/81f6b071-f5a6-3284-d4c3-7bd503072e49.gif)
良い感じです！
手持ちのZenFone4だとさすがにリアルタイムとはいきませんでした。
ただ、徐々に姿勢を見極めて行くのが見れて、逆に面白いですね😆
こんな感じで一時期は一世を風靡した姿勢推定も今では誰もが使えるものになってきましたね！
色んなアプリが出てくるのが楽しみです🙌