Flutter + Google MLKitで機械学習体験【動画のポーズ推定】

## はじめに

この記事ではFlutterでGoogleの[**ML Kitのポーズ推定**](https://pub.dev/packages/google_mlkit_pose_detection)を使ったアプリの[**サンプルコード**](https://github.com/kazmawedton/flutter_mlkit_pose/)の紹介と解説をしています。動作確認はiOSシミュレーターのみです。実機やAndroidでもコードの大部分はそのまま動かせると思いますが、WebとPCではML Kitが未対応なのでご注意ください。

ソースコードは[**コチラ**](https://github.com/kazmawedton/flutter_mlkit_pose/)でご覧ください。(ほぼ全てのコードはこの記事中でも見られます)
動画の処理には[FFmpeg](https://ffmpeg.org/)を利用します。
表題のML KitとFFmpegの利用箇所は[`5. mlkit_video_converter.dartについて`](#5-mlkit_video_converterdartについて)で解説しています。

**完成イメージ**
![Simulator Screen Shot - iPhone 14 - 2022-11-29 at 20.16.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/f8657aa6-79bd-62a8-860c-ff83cc3b617e.png)
**出力ファイルイメージGIF**
![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/8363d335-0ff9-a35b-710e-d0986da7cdeb.gif)

## 機能について

実装機能について、大枠は以下の通りです。
- iOSのカメラロールの動画を取得
- FFmpegで動画をフレーム(コマ送りの画像)に分割
- ML KItでポーズ推定しその結果をフレーム上に描画
- FFmpegでフレームを再合成して動画を作成
- 作成した動画をカメラロールに保存

## 環境と利用パッケージ

```
macOS : 13.0.1 
Flutter : 3.3.9
Xcode : 14.1
Simulator : iOS 16.1
```
- [google_mlkit_pose_detection: 0.5.0](https://pub.dev/packages/google_mlkit_pose_detection)：ML KitのFlutterパッケージ
- [ffmpeg_kit_flutter: 4.5.1-LTS](https://pub.dev/packages/ffmpeg_kit_flutter/versions/4.5.1-LTS)：FFmpeg (投稿時点でも最新版ではないので注意)
- [image_picker: 0.8.6](https://pub.dev/packages/image_picker)：カメラロールから画像を取得
- [path_provider: 2.0.11]()：端末のキャッシュ用ディレクトリを取得
- [flutter_video_info: 1.3.1](https://pub.dev/packages/flutter_video_info)：選択した動画のピクセルサイズやFPSを取得
- [image_gallery_saver: 1.7.1](https://pub.dev/packages/image_gallery_saver)：生成した動画をカメラロールに保存

## ML KitとFFmpegについて

### [ML Kit](https://developers.google.com/ml-kit)

Google製のモバイル用SDKで、[TensorFlow](https://www.tensorflow.org/?hl=ja)という機械学習のソフトウェアライブラリをより手軽に使えるようにしてくれるものです。
文字認識や顔認識などの用途別にパッケージが用意されており、それぞれに必要な学習済みモデルがラップされています。今回はそのなかでも[ポーズ推定用のFlutterパッケージ](https://pub.dev/packages/google_mlkit_pose_detection)を利用します。

### [FFmpeg](https://ffmpeg.org/)
動画と音声を記録・変換・再生するためのフリーのコマンドツールです。
FFmpegのコマンドをFlutterアプリから実行できるようにしてくれるパッケージが[**ffmpeg_kit_flutter**](https://pub.dev/packages/ffmpeg_kit_flutter)です。
記事投稿時の環境では最新版の`5.1.0`や`5.1.0-LTS`はアプリが起動すらしないという事象がおこり、原因がわからなかったため、今回は`4.5.1-LTS`を利用しています。

※ FFmpeg公式の[**Mini FAQ**](https://ffmpeg.org/legal.html)に特許周りの**大事なお話**が書かれているので、利用の際はご注意ください。

## フォルダ構成について

[**Flutterプロジェクト**](https://github.com/kazmawedton/flutter_mlkit_pose/)の`lib`以下のフォルダは以下の通りです。

```
- main.dart                      : 説明割愛
- view
    - main_screen.dart           : このアプリの唯一のスクリーン
    - video_convert_view.dart    : 名前はViewですがViewModel的に利用
- utility
    - utilities.dart             : ちょっとしたメソッドをいくつか
- model
    - landmark_painter.dart      : ポーズ推定結果を描画するCustomPainter
    - mlkit_video_converter.dart : 動画や画像の編集を行うクラス
```

- ファイル数やコードの行数を抑えることを優先して、`video_convert_view.dart`内のWidgetにロジックを含めてしまっています。
- 実際にアプリの一部として機能実装する場合は、[MVVM](https://ja.wikipedia.org/wiki/Model_View_ViewModel)などのパターンを採用する方がよいでしょう。その場合は[riverpod](https://pub.dev/packages/flutter_riverpod)などを利用するのがオススメです。
- メソッドや処理の部分を参考にご覧ください。


## 1. プロジェクトの準備

ここから実際に[**Flutterプロジェクト**]()の解説をしていきます。

### 1.1 プロジェクト作成
```zsh
flutter create --platform=ios your_project_name
```
Flutterコマンドラインで作成する場合は`--platform`オプションで対応プラットフォームを指定することで、不要なプラットフォームを削除する手間が省けます。

### 1.2 ARMv7の除外
ML KitはARMv7アーキテクチャのCPUに対応していないため、プロジェクトを作成したらまずは[こちら](https://github.com/bharat-biradar/Google-Ml-Kit-plugin#requirements)の解説に従い、ARMv7を明示的に除外します。方法は以下のaとbの２つがあります。
(全く同じ設定の内容ではありませんが、bのコードは[解説](https://github.com/bharat-biradar/Google-Ml-Kit-plugin#requirements)の引用です)

#### 1.2.a Xcodeで行う
`ios`ディレクトリをXcodeで開き`Project > Runner > Building Settings > Excluded Architectures`に`Any SDK > armv7`を追加する。
![Screenshot 2022-11-28 at 0.25.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/3ca540c2-c539-bdc0-6b81-6983724f85ad.png)

#### 1.2.b `Podfile`を編集する
`ios/Podfile`に下記コードを追記する。(Podfileがない場合は`ios`ディレクトリで`pod init`)
  [解説]((https://github.com/bharat-biradar/Google-Ml-Kit-plugin#requirements))に合わせて`iOSVersion = '10.0'`としていますが、他に利用するパッケージが新しいiOSバージョンにしか対応してない場合は、適宜変更する必要があると思います。
```ios/Podfile
# ...省略...

# add this line:
$iOSVersion = '10.0'

post_install do |installer|
  # add these lines:
  installer.pods_project.build_configurations.each do |config|
    config.build_settings["EXCLUDED_ARCHS[sdk=*]"] = "armv7"
    config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = $iOSVersion
  end
  
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
    
    # add these lines:
    target.build_configurations.each do |config|
      if Gem::Version.new($iOSVersion) > Gem::Version.new(config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'])
        config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = $iOSVersion
      end
    end
    
  end
end
```

### 1.3 カメラロール利用の許可設定

カメラロールを利用する旨をユーザーに伝えるための設定をします。
(これをしないと、カメラロールにアクセスしようとしただけでアプリが強制終了します)
こちらも方法は以下のaとbの２つがあります。

#### 1.3.a Xcodeで行う
`ios`ディレクトリをXcodeで開き`Runner > Runner > Info.plist`に`Privacy - Photo Library Usage Description`を追加する。
Valueにはカメラロールにアクセスする理由をユーザーにわかる様に記載してください。
![Screenshot 2022-11-28 at 0.43.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/1bcc14da-2612-f536-4641-a025e42797fb.png)

#### 1.3.b `Info.plist`を編集する
`/ios/Runner/Info.plist`に`NSPhotoLibraryUsageDescription`を追記する。
```Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>

  // ...中略...

  <key>NSPhotoLibraryUsageDescription</key>
  <string>ビデオファイルの取り込みを保存をします。</string>
</dict>
</plist>
```

### 1.4 パッケージの追加

先述の通り`ffmpeg_kit_flutter`だけは最新版ではないものを利用していますので、ご注意ください。

```pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  # 以下追記
  google_mlkit_pose_detection: ^0.5.0
  image_picker: ^0.8.6
  path_provider: ^2.0.11
  ffmpeg_kit_flutter: 4.5.1-LTS
  flutter_video_info: ^1.3.1
  image_gallery_saver: ^1.7.1
```
`pubspec.yaml`に追記したら`flutter pub get`を実行し、パッケージのダウンロードをしてください。

## 2. `main.dart`について

特筆することはありませんが、`debugShowCheckedModeBanner: false`と`theme: ThemeData(primarySwatch: Colors.pink)`でテーマカラーをお好みで変更すると、開発が少し楽しくなるのでオススメです。

```lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_mlkit_video/view/main_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false, // アプリ内のdebug表記をなくす
      title: 'Google MLKit Demo',
      theme: ThemeData(primarySwatch: Colors.pink), // お好みのカラーに変更
      home: const MainScreen(), // このアプリで唯一の画面
    );
  }
}
```

## 3. `main_screen.dart`について

![Simulator Screen Shot - iPhone 14 - 2022-11-29 at 20.16.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/f8657aa6-79bd-62a8-860c-ff83cc3b617e.png)

- ビデオファイルを選択する画面です。
- できるだけ少ない画面とファイルで機能実装をしているため、少し歪な実装になっています。
### 操作と処理の流れ
- `_videoPicked = null`の初期状態では`scaffoldBody`は「ファイル選択」ボタンを表示
- ファイルを選択すると`XFile`形式で`_videoPicked`に格納し`setState`で再描画
- `scaffoldBody`が、次の画面に当たる`VideoConvertView`に切り替わる

```lib/view/main_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_mlkit_video/view/video_convert_view.dart';
import 'package:image_picker/image_picker.dart';

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  XFile? _videoPicked; // image_pickerで選択したビデオファイル
  late Widget scaffoldBody; // 表示するWidget、_videoPickedに応じて変化させる

  // ファイル選択ボタン押下
  Future<void> _pickVideo() async {
    // カメラロールからビデオファイルを選択
    await ImagePicker().pickVideo(source: ImageSource.gallery).then((result) {
      if (result != null) {
        _videoPicked = result;
        setState(() {});
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    // ファイル選択状況に応じてビューを切り替え
    if (_videoPicked == null) {
      // ファイル未選択時は「ファイル選択」ボタンを表示
      scaffoldBody = Container(
        alignment: Alignment.center,
        child: ElevatedButton(
          child: const Text('ファイル選択'),
          onPressed: () async => _pickVideo(),
        ),
      );
    } else {
      // ファイル選択後はポーズ推定を行うビューを表示
      scaffoldBody = VideoConvertView(videoXFile: _videoPicked);
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter ML Kit')),
      body: SafeArea(child: scaffoldBody),
    );
  }
}
```

## 4. `video_convert_view.dart`について

![Simulator Screen Shot - iPhone 14 - 2022-12-02 at 14.59.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/bc534e19-29a3-7192-c1b5-d1c39ba8e63d.png)
![Simulator Screen Shot - iPhone 14 - 2022-12-02 at 15.20.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/a4f0a94d-117b-a0b5-0d92-ea1fac2ca6fc.png)

- ビデオの処理開始後に`Scaffold`の`body`に表示されるビューです。
- `view`という名前ですがファイル数を減らすためにロジックも含めています
(コードが胴長で、デザインとしては好ましくないと思います。ViewModelなどのロジックを担当するclassを作るのがよいでしょう。)

### 表示と処理の流れ
- 親ビューから`videoXFile`を受け取る
- `init()`でポーズ推定の処理を開始し、`Future<void>`型の戻り値を`_future`に渡す
- `_future`の処理状況に応じて`FutureBuilder`内`snapshot`の`ConnectionState`が変化する
- `snapshot`の`ConnectionState`に応じての表示が切り替わる
    - `ConnectionState.done`より前：プログレスサークル表示
    - `ConnectionState.done`後：「カメラロールに保存しました」表示
    - `ConnectionState`について詳しくは[こちら](https://api.flutter.dev/flutter/widgets/ConnectionState.html)

### `_convertVideo()`メソッドについて
- ポーズ推定からカメラロールへの保存までのメソッド群を非同期で実行するメソッドです。
- `(1)` `MlkitVideoConverter`はカスタムクラスで、`ML kit`や`FFmpeg`を使った実際の処理を行います。詳細は[後述](#5-mlkit_video_converterdartについて)します。
- `removeFFmpegFiles()`や`localFilePath()`などのメソッドは後述する`utilities.dart`に記述されています。詳細は[後述](#6-landmark_painter.dartについて)しますが機能については下記コメントの通りです。
- `(2)` 中頃の`for`ループでは、動画から取得した各フレームで順番にポーズ推定し、結果を画像の上に描画する処理を行なっています。
- `(3)` 進捗率を表す`_progress`は、実際には`(2)`の`forループ`で完了した処理の数を元にしています。

```lib/view/video_convert_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_mlkit_video/model/mlkit_video_converter.dart';
import 'package:flutter_mlkit_video/utility/utilities.dart';
import 'package:image_picker/image_picker.dart';

class VideoConvertView extends StatefulWidget {
  const VideoConvertView({super.key, this.videoXFile});
  final XFile? videoXFile; // 選択したビデオファイル

  @override
  State<VideoConvertView> createState() => _VideoConvertViewState();
}

class _VideoConvertViewState extends State<VideoConvertView> {
  late Future<void> _future; // FutureBuilderに動画処理完了を通知する

  var _busy = false; // 動画処理実行中ガード
  var _progress = 0.0; // (4) ポーズ推定の進捗率

  // ビデオの全フレームにランドマークを描画してカメラロールに保存
  Future<void> _convertVideo() async {
    if (!_busy) {
      // 開始
      setState(() => _busy = true);

      // 選択したファイルパス
      final videoFilePath = widget.videoXFile?.path;
      // ファイル未選択時ガード
      if (videoFilePath == null) return;

      // 作成したファイルの保存先パス
      final localPath = await localFilePath();

      // (1)
      // コンバーターの作成と初期化
      final mlkitVideoConverter = MlkitVideoConverter();
      await mlkitVideoConverter.initialize(
        localPath: localPath,
        videoFilePath: videoFilePath,
      );
      // フレーム抽出
      final frameImageFiles = await mlkitVideoConverter.convertVideoToFrames(context: context);
      // 全フレームにランドマークを描画
      if (frameImageFiles != null) {
        // (2)
        for (var index = 0; index < frameImageFiles.length; index++) {
          final file = frameImageFiles[index];
          await mlkitVideoConverter.paintLandmarks(context: context, frameFilePath: file.path);
          // (3)
          setState(() => _progress = index / frameImageFiles.length); // プログレス更新
        }
      }
      // フレームから動画生成
      final exportFilePath = await mlkitVideoConverter.createVideoFromFrames();
      // カメラロールに保存
      if (exportFilePath != null) {
        await ImageGallerySaver.saveFile(exportFilePath);
      }
      // キャッシュクリア
      await removeFFmpegFiles();

      // 完了ダイアログ表示
      showDialog(
        context: context,
        builder: (_) {
          return AlertDialog(
            content: const Text('カメラロールに保存しました'),
            actions: [
              TextButton(child: const Text('OK'), onPressed: () => Navigator.pop(context)),
            ],
          );
        },
      );

      //  終了
      setState(() => _busy = false);
    }
  }

  // 処理中プログレスバー
  Widget _progressView(double value) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text('書き出し中'),
        const SizedBox(height: 16),
        CircularProgressIndicator(
          value: value,
          backgroundColor: Colors.black12,
        ),
      ],
    );
  }

  @override
  void initState() {
    super.initState();
    // ポーズ推定開始
    _future = _convertVideo();
  }

  @override
  Widget build(BuildContext context) {
    return widget.videoXFile == null
        // ---------- ファイル選択前 ----------
        ? Container(
            alignment: Alignment.center,
            child: const Text('ファイルを選択してください'),
          )
        // ---------- ファイル選択後 ----------
        : FutureBuilder(
            future: _future,
            builder: (context, snapshot) {
              if (snapshot.connectionState != ConnectionState.done) {
                // ---------- 処理中 ----------
                return Container(
                  alignment: Alignment.center,
                  child: _progressView(_progress),
                );
              } else if (snapshot.hasError) {
                // ---------- エラー発生時 ----------
                return Container(
                  alignment: Alignment.center,
                  child: Text(snapshot.error.toString()),
                );
              } else {
                // ---------- 完了 ----------
                return Container(
                  alignment: Alignment.center,
                  child: const Text('カメラロールに保存しました'),
                );
              }
            },
          );
  }
}
```

## 5. `mlkit_video_converter.dart`について

- `MlkitVideoConverter`はビデオのフレーム抽出、ポーズ推定処理、フレームからのビデオの再生成などの処理を行うメソッド群を持つカスタムクラスです。
- タイトルのML KitやFFmpegはこの`MlkitVideoConverter`内で利用しています。

### 各メソッドについて
- `initialize()`
    - インスタンス宣言直後に実行して、必要なビデオのメタデータを取得しておきます。
    - `ffmpegCoomand`はFFmpegで実行する[画像処理コマンド](https://ffmpeg.org/ffmpeg.html#Options)で、オプションの詳細は以下の通りです。
        - `-i $videoFilePath`：入力ファイルのパスを指定
        - `-vcodec png`：出力ファイルの形式pngに指定
        - `$localPath/${CommonValue.filePrefix}%05d.png`
        出力ファイルのパスとファイル名を指定 (`%05d`で5桁の連番が自動で付与される)
- `convertVideoToFrames()`
    - ビデオを全フレームを画像として取得し、デバイスのキャッシュフォルダに保存します。
- `_getFFmpegFiles()`
    - `convertVideoToFrames()`で生成した画像の一覧を取得します。
- `paintLandmarks()`
    - ポーズ推定をして[ランドマーク](https://developers.google.com/ml-kit/vision/pose-detection) (関節や目口鼻の位置) の座標を取得し、画像に描画します。
- `createVideoFromFrames()`
    - ポーズ推定と描画が終わった画像を全て再合成して動画を出力します。
    - `ffmpegCoomand`はFFmpegで実行する[画像処理コマンド](https://ffmpeg.org/ffmpeg.html#Options)です。
        - `-i $localPath/${CommonValue.filePrefix}%05d.png`
        ：入力ファイルのパス (`$localPath/`下でファイル名が5桁の連番になっているpng画像全て)
        - `-framerate $videoFps`：元動画のfps
        - `-b 100M`：出力動画のbps (劣化させないように必要より大きな値を指定しています)
        - `-r $videoFps`：出力動画のfps
        - `$exportVideoFilePath`：出力動画のパス

```lib/model/mlkit_video_converter.dart
import 'dart:io';
import 'dart:ui' as ui;

import 'package:ffmpeg_kit_flutter/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter/return_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mlkit_video/model/landmark_painter.dart';
import 'package:flutter_mlkit_video/utility/utilities.dart';
import 'package:google_mlkit_pose_detection/google_mlkit_pose_detection.dart';

class MlkitVideoConverter {
  late final String localPath;
  late final String videoFilePath;
  late final int videoWidth;
  late final int videoHeight;
  late final double videoFps;

  // メタデータ取得などの初期化処理
  Future<void> initialize({
    required String localPath,
    required String videoFilePath,
  }) async {
    final Map<String, dynamic>? videoInfo = await getVideoMetadata(videoFilePath);
    this.localPath = localPath;
    this.videoFilePath = videoFilePath;
    videoWidth = videoInfo!['width'];
    videoHeight = videoInfo['height'];
    videoFps = videoInfo['fps'];
  }

  // ビデオをフレームに分割してPNG画像として保存
  Future<List<File>?> convertVideoToFrames() async {
    await removeFFmpegFiles();

    // フレーム抽出
    final ffmpegCoomand =
        '-i $videoFilePath -vcodec png $localPath/${CommonValue.filePrefix}%05d.png';
    await FFmpegKit.execute(ffmpegCoomand).then((session) async {
      final returnCode = await session.getReturnCode();

      // エラーまたは中断
      if (ReturnCode.isCancel(returnCode) || !ReturnCode.isSuccess(returnCode)) {
        return null;
      }
    }).onError((error, stackTrace) {
      return null;
    });

    return _getFFmpegFiles();
  }

  // convertVideoToFrames()で生成した画像のリストを取得
  List<File> _getFFmpegFiles() {
    List<File> files = [];
    final localDirectory = Directory(localPath);

    List<FileSystemEntity> fileEntities =
        localDirectory.listSync(recursive: true, followLinks: false);
    for (var entity in fileEntities) {
      final fileName = entity.path.split('/').last;
      if (fileName.startsWith(CommonValue.filePrefix)) {
        files.add(File(entity.path));
      }
    }
    return files;
  }

  // フレームにポーズ推定結果を描画してに上書き保存
  Future<bool> paintLandmarks({required String frameFileDirPath}) async {
    // ファイル
    final imageFile = File(frameFileDirPath);
    if (imageFile.existsSync()) {
      // ボーズ推定
      final inputImage = InputImage.fromFile(imageFile);
      final poseDetector = PoseDetector(options: PoseDetectorOptions());
      await poseDetector.processImage(inputImage).then((value) async {
        final pose = value.first;

        // 画像のデコード
        final imageByte = await imageFile.readAsBytes();
        final image = await decodeImageFromList(imageByte);

        // キャンバス上でランドマークの描画
        final recorder = ui.PictureRecorder();
        final canvas = Canvas(recorder);
        final painter = LankmarkPainter(image: image, pose: pose);
        painter.paint(canvas, Size(videoWidth.toDouble(), videoHeight.toDouble()));

        // ランドマーク付き画像ByteData生成
        final ui.Picture picture = recorder.endRecording();
        final ui.Image imageRecorded = await picture.toImage(videoWidth, videoHeight);
        final ByteData? byteData = await imageRecorded.toByteData(format: ui.ImageByteFormat.png);

        // 上書き保存
        await File(imageFile.path).writeAsBytes(byteData!.buffer.asInt8List());
      });
      return true;
    } else {
      return false;
    }
  }

  // フレームから動画を再生成
  Future<String?> createVideoFromFrames() async {
    final exportVideoFilePath = '$localPath/ffmpeg_video.mp4';
    final ffmpegCommand =
        '-framerate $videoFps -i $localPath/${CommonValue.filePrefix}%05d.png -b 100M -r $videoFps $exportVideoFilePath';

    var succeed = false;

    await FFmpegKit.execute(ffmpegCommand).then((session) async {
      final returnCode = await session.getReturnCode();
      succeed = ReturnCode.isSuccess(returnCode);
    });

    if (succeed) {
      return exportVideoFilePath;
    } else {
      return null;
    }
  }
}
```

## 6. `landmark_painter.dart`について

- ビデオから取得したフレーム画像にポーズ推定の結果 (ランドマークの位置) を描画する処理を行う`CustomPainter`です。
ランドマークについて詳細は[こちら](https://developers.google.com/ml-kit/vision/pose-detection)をご覧ください
- 左腕 > 右腕 > 左脚 > 右脚と順に同じ描画の処理をするので、ここでは省略して記載しています。
- `drawCircle()`でランドマーク上に点を描き、`drawLine()`でランドマークを結ぶ線を描画します。

### 簡単な解説
- `(1)` `for`ループで便利のため、最大32個あるランドマークを顔と左右の手脚に分類しておく。
- `(2)` canvasにポーズ推定した画像を描画 (この上にランドマークを描画していく)
- `(3)` 顔のランドマークを画像に描画
- `(4)` 左右の手脚の付け根4点をグレーのラインで結ぶ
- `(5)` 左腕のランドマークを順に描画し線で結ぶ (以下、右腕と脚を同様のため中略)
- `(6)` 便利のため32個の`PoseLandmarkType`に全て色を割り振っておく

```lib/model/landmark_painter.dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:google_mlkit_pose_detection/google_mlkit_pose_detection.dart';

class LankmarkPainter extends CustomPainter {
  LankmarkPainter({
    required this.image,
    required this.pose,
  });
  final ui.Image image; // MK kitへの入力画像
  final Pose pose; // ML kitの出力

  // (1) ランドマークを分類
  List<PoseLandmarkType> get faceLandmarks => [
        PoseLandmarkType.leftEyeInner,
        PoseLandmarkType.leftEye,
        PoseLandmarkType.leftEyeOuter,
        PoseLandmarkType.rightEyeInner,
        PoseLandmarkType.rightEye,
        PoseLandmarkType.rightEyeOuter,
        PoseLandmarkType.leftEar,
        PoseLandmarkType.rightEar,
        PoseLandmarkType.leftMouth,
        PoseLandmarkType.rightMouth,
      ];
  List<PoseLandmarkType> get rightArmLandmarks => [
        PoseLandmarkType.rightShoulder,
        PoseLandmarkType.rightElbow,
        PoseLandmarkType.rightWrist,
        PoseLandmarkType.rightThumb,
        PoseLandmarkType.rightIndex,
        PoseLandmarkType.rightPinky,
      ];
  // ...中略...

  @override
  void paint(canvas, size) async {
    const strokeWidth = 4.0;

    // (2)
    // 元画像の描画
    canvas.drawImage(image, Offset.zero, Paint());

    // ランドマークの描画

    // (3)
    // 顔
    for (var landmark in faceLandmarks) {
      final paint = Paint()..color = landmark.color;
      final position = Offset(pose.landmarks[landmark]!.x, pose.landmarks[landmark]!.y);
      canvas.drawCircle(position, strokeWidth, paint);
    }
    
    // (4)
    // 胴体
    final paint = Paint()
      ..color = Colors.grey
      ..strokeWidth = strokeWidth;
    // 左右の方と脚の付け根の座標
    final p1 = Offset(
        pose.landmarks[rightArmLandmarks.first]!.x, pose.landmarks[rightArmLandmarks.first]!.y);
    final p2 = Offset(
        pose.landmarks[leftArmLandmarks.first]!.x, pose.landmarks[leftArmLandmarks.first]!.y);
    final p3 = Offset(
        pose.landmarks[leftLegLandmarks.first]!.x, pose.landmarks[leftLegLandmarks.first]!.y);
    final p4 = Offset(pose.landmarks[rightLeg.first]!.x, pose.landmarks[rightLeg.first]!.y);
    // 4点をつなぐ直線を描画
    canvas.drawLine(p1, p2, paint);
    canvas.drawLine(p2, p3, paint);
    canvas.drawLine(p3, p4, paint);
    canvas.drawLine(p4, p1, paint);

    // (5)
    // 左腕の関節を順に描き線で結ぶ
    for (var index = 0; index < leftArmLandmarks.length - 1; index++) {
      final landmark1 = leftArmLandmarks[index];
      final landmark2 = leftArmLandmarks[index + 1];
      final paint = Paint()
        ..color = landmark1.color
        ..strokeWidth = strokeWidth;
      final p1 = Offset(pose.landmarks[landmark1]!.x, pose.landmarks[landmark1]!.y);
      final p2 = Offset(pose.landmarks[landmark2]!.x, pose.landmarks[landmark2]!.y);
      canvas.drawCircle(p1, strokeWidth, paint);
      if (index < leftArmLandmarks.length - 1) {
        canvas.drawLine(p1, p2, paint);
      }
    }

    //...中略...
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

// (6)
// ランドマークごとに色を割り当てるextension
extension PoseLandmarkColor on PoseLandmarkType {
  Color get color {
    if (this == PoseLandmarkType.rightHip ||
        this == PoseLandmarkType.rightKnee ||
        this == PoseLandmarkType.rightAnkle ||
        this == PoseLandmarkType.rightHeel ||
        this == PoseLandmarkType.rightFootIndex) {
      return Colors.blue;
    } else if (this == PoseLandmarkType.leftHip ||
        this == PoseLandmarkType.leftKnee ||
        this == PoseLandmarkType.leftAnkle ||
        this == PoseLandmarkType.leftHeel ||
        this == PoseLandmarkType.leftFootIndex) {
      return Colors.pink;
    } else if (this == PoseLandmarkType.leftShoulder ||
        this == PoseLandmarkType.leftElbow ||
        this == PoseLandmarkType.leftWrist ||
        this == PoseLandmarkType.leftPinky ||
        this == PoseLandmarkType.leftIndex ||
        this == PoseLandmarkType.leftThumb) {
      return Colors.deepPurple;
    } else if (this == PoseLandmarkType.rightShoulder ||
        this == PoseLandmarkType.rightElbow ||
        this == PoseLandmarkType.rightWrist ||
        this == PoseLandmarkType.rightPinky ||
        this == PoseLandmarkType.rightIndex ||
        this == PoseLandmarkType.rightThumb) {
      return Colors.green;
    } else {
      return Colors.amber;
    }
  }
}
```

## 7. `utilities.dart`について

- `getLocalPath()`
    - 端末のキャッシュを保存するディレクトリを取得。
    - 処理の途中で生成されるファイルなどを覗くために、パスを`print()`しています。
- `getVideoMetadata()`
    - ビデオのピクセルサイズとフレームレートを取得。
    - iPhoneでは縦撮影した内部的には横動画として記録した上でメタ情報の`orientation`で回転を与えて、実質的に縦動画として扱えるようにしています。そのため、元動画の見かけ通りのピクセルサイズ (`width`より`height`の方が大きい) を返すために`orientation`によって戻り値を分岐させています。
- `removeFFmpegFiles()`
    - ビデオを保存が完了してから、過程で生成されたキャッシュファイルを削除。
- `CommonValue`
    - アプリ内で使用する固定値をまとめるためのクラス。
    ファイル名の接頭辞に使う文字列`ffmpeg_`のみ。

```lib/utility/utilities.dart
import 'dart:io';

import 'package:flutter_video_info/flutter_video_info.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:path_provider/path_provider.dart';

// アプリからファイルを保存するディレクトリのパス
Future<String> getLocalPath() async {
  Directory tmpDocDir = await getTemporaryDirectory();
  // ignore: avoid_print
  print(tmpDocDir.path);
  return tmpDocDir.path;
}

// ビデオのメタデータ取得
Future<Map<String, dynamic>?> getVideoMetadata(String videoFilePath) async {
  final videoInfo = await FlutterVideoInfo().getVideoInfo(videoFilePath) as VideoData;
  // 縦持ち撮影ファイル対応
  if ((videoInfo.orientation! ~/ 90) % 2 == 1) {
    return {
      'width': videoInfo.height,
      'height': videoInfo.width,
      'fps': videoInfo.framerate,
    };
  } else {
    return {
      'width': videoInfo.width,
      'height': videoInfo.height,
      'fps': videoInfo.framerate,
    };
  }
}

// 生成される画像や動画のファイルのキャッシュを削除する
Future<void> removeFFmpegFiles() async {
  // キャッシュディレクトリ取得
  final localDirectory = await getTemporaryDirectory();
  // ディレクトリ下の全てのファイル中で
  for (var entry in localDirectory.listSync(recursive: true, followLinks: false)) {
    // 本アプリで生成されたファイルを削除
    final fileName = entry.path.split('/').last;
    if (fileName.startsWith(CommonValue.filePrefix)) {
      entry.deleteSync();
    }
  }
}

// アプリを通じて使うの固定の値
class CommonValue {
  // 生成されるキャッシュファイルの名前の頭につける文字列
  static String filePrefix = 'ffmpeg_';
}
```

これで全ファイルの解説は以上です！

## おわりに

今回はシミュレーターでもすぐにお試しで出力が得られるサンプルとして動画でポーズ推定を行なってみました。
実用を考えると、[**Camera**](https://pub.dev/packages/camera) プラグインを利用してカメラから取得した映像をリアルタイムで扱う方が用途は広いと思います。

参考にしていただけましたらぜひ「いいね」をお願いいたします！
ご質問やご指摘、その他コメントいつでもお待ちしております。