← ブログ一覧に戻る
技術解説

骨格推定モデルを使ったFlutterアプリ開発のノウハウ

#Flutter#骨格推定#機械学習#MediaPipe#モバイルアプリ

はじめに

スポーツのフォーム分析、リハビリ支援、フィットネスアプリ——こうした用途で**骨格推定(Pose Estimation)**の需要が急速に高まっています。かつてはサーバーサイドでの処理が主流でしたが、モデルの軽量化とスマートフォンのハードウェア進化により、今ではオンデバイスでリアルタイムに骨格を推定できるようになりました。

本記事では、Flutterで骨格推定アプリを開発する際の実践的なノウハウを解説します。モデル選定から実装パターン、パフォーマンスチューニングまで、実際の開発で得た知見をまとめています。


骨格推定モデルの選定

主要なモデルの比較

モバイル向けの骨格推定モデルは複数存在します。それぞれ特性が異なるため、用途に合わせた選定が重要です。

モデル提供元特徴向いているユースケース
BlazePoseGoogle / MediaPipe33キーポイント、高精度、全身対応フィットネス、スポーツ分析
MoveNet LightningGoogle / TFLite17キーポイント、超軽量・高速リアルタイム処理優先
MoveNet ThunderGoogle / TFLite17キーポイント、高精度精度と速度のバランス
PoseNetGoogle / TFLite17キーポイント、旧世代軽量端末・レガシー対応

選定の指針

リアルタイムで動作させたい場合 → MoveNet Lightning 処理速度が最優先であれば、MoveNet Lightningが最良の選択です。低スペック端末でも30fps以上を維持できます。

フォーム分析など精度が必要な場合 → BlazePose または MoveNet Thunder BlazePoseは腕・脚の関節に加え、手首・足首・指先まで含む33キーポイントを検出します。ヨガのポーズ判定や野球のスイング分析など、細かい動作を評価する場面に向いています。


FlutterでのMediaPipe統合

パッケージ構成

FlutterからMediaPipeを利用する方法は主に2つあります。

方法1:google_mlkitパッケージを利用する

# pubspec.yaml
dependencies:
  google_mlkit_pose_detection: ^0.10.0

google_mlkit_pose_detection はAndroid・iOSの両方に対応しており、セットアップが容易です。MediaPipeのBlazePoseベースの実装が内包されています。

方法2:tflite_flutterでモデルを直接組み込む

dependencies:
  tflite_flutter: ^0.10.4
  tflite_flutter_helper: ^0.3.1
  image: ^4.1.7

MoveNetなどTFLiteモデルを直接使用する場合はこちらを選択します。モデルの制御が細かくできる反面、前処理・後処理を自前で実装する必要があります。


実装パターン:カメラ映像からのリアルタイム骨格推定

カメラプレビューのセットアップ

import 'package:camera/camera.dart';
import 'package:google_mlkit_pose_detection/google_mlkit_pose_detection.dart';

class PoseDetectorService {
  final PoseDetector _poseDetector = PoseDetector(
    options: PoseDetectorOptions(
      mode: PoseDetectionMode.stream, // ストリームモードで連続処理
      model: PoseDetectionModel.accurate, // accurateまたはfast
    ),
  );

  Future<List<Pose>> detectPose(InputImage inputImage) async {
    return await _poseDetector.processImage(inputImage);
  }

  void dispose() {
    _poseDetector.close();
  }
}

カメラフレームを推論に渡す

class CameraService {
  CameraController? _controller;

  Future<void> initialize(CameraDescription camera) async {
    _controller = CameraController(
      camera,
      ResolutionPreset.medium, // 解像度は用途に合わせて調整
      enableAudio: false,
      imageFormatGroup: Platform.isAndroid
          ? ImageFormatGroup.nv21   // Android
          : ImageFormatGroup.bgra8888, // iOS
    );
    await _controller!.initialize();
  }

  void startImageStream(Function(CameraImage) onFrame) {
    _controller?.startImageStream(onFrame);
  }
}

InputImageへの変換

カメラフレーム(CameraImage)をMLKitが受け取れるInputImageに変換する処理は、Androidと iOSで書き方が異なります。

InputImage? convertCameraImage(
  CameraImage image,
  CameraDescription camera,
) {
  final WriteBuffer allBytes = WriteBuffer();
  for (final Plane plane in image.planes) {
    allBytes.putUint8List(plane.bytes);
  }
  final bytes = allBytes.done().buffer.asUint8List();

  final imageSize = Size(
    image.width.toDouble(),
    image.height.toDouble(),
  );

  final imageRotation = InputImageRotationValue.fromRawValue(
    camera.sensorOrientation,
  );
  if (imageRotation == null) return null;

  final inputImageFormat = InputImageFormatValue.fromRawValue(
    image.format.raw,
  );
  if (inputImageFormat == null) return null;

  return InputImage.fromBytes(
    bytes: bytes,
    metadata: InputImageMetadata(
      size: imageSize,
      rotation: imageRotation,
      format: inputImageFormat,
      bytesPerRow: image.planes.first.bytesPerRow,
    ),
  );
}

骨格のオーバーレイ描画

CustomPainterで骨格を描画する

推定したキーポイントをカメラプレビューの上に重ねて表示するには、CustomPainterを使います。

class PosePainter extends CustomPainter {
  final List<Pose> poses;
  final Size imageSize;
  final InputImageRotation rotation;

  // 接続する関節のペア(骨格ライン)
  static const _connections = [
    [PoseLandmarkType.leftShoulder, PoseLandmarkType.rightShoulder],
    [PoseLandmarkType.leftShoulder, PoseLandmarkType.leftElbow],
    [PoseLandmarkType.leftElbow, PoseLandmarkType.leftWrist],
    [PoseLandmarkType.rightShoulder, PoseLandmarkType.rightElbow],
    [PoseLandmarkType.rightElbow, PoseLandmarkType.rightWrist],
    [PoseLandmarkType.leftHip, PoseLandmarkType.rightHip],
    [PoseLandmarkType.leftShoulder, PoseLandmarkType.leftHip],
    [PoseLandmarkType.rightShoulder, PoseLandmarkType.rightHip],
    [PoseLandmarkType.leftHip, PoseLandmarkType.leftKnee],
    [PoseLandmarkType.leftKnee, PoseLandmarkType.leftAnkle],
    [PoseLandmarkType.rightHip, PoseLandmarkType.rightKnee],
    [PoseLandmarkType.rightKnee, PoseLandmarkType.rightAnkle],
  ];

  PosePainter({
    required this.poses,
    required this.imageSize,
    required this.rotation,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final jointPaint = Paint()
      ..color = Colors.greenAccent
      ..strokeWidth = 6
      ..style = PaintingStyle.fill;

    final bonePaint = Paint()
      ..color = Colors.white.withOpacity(0.8)
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke;

    for (final pose in poses) {
      // 骨格ラインを描画
      for (final connection in _connections) {
        final start = pose.landmarks[connection[0]];
        final end = pose.landmarks[connection[1]];
        if (start != null && end != null) {
          if (start.likelihood > 0.5 && end.likelihood > 0.5) {
            canvas.drawLine(
              _translateOffset(start, size),
              _translateOffset(end, size),
              bonePaint,
            );
          }
        }
      }

      // 関節点を描画
      for (final landmark in pose.landmarks.values) {
        if (landmark.likelihood > 0.5) {
          canvas.drawCircle(
            _translateOffset(landmark, size),
            5,
            jointPaint,
          );
        }
      }
    }
  }

  Offset _translateOffset(PoseLandmark landmark, Size canvasSize) {
    // カメラ座標をキャンバス座標に変換
    final double x = landmark.x * canvasSize.width / imageSize.width;
    final double y = landmark.y * canvasSize.height / imageSize.height;
    return Offset(x, y);
  }

  @override
  bool shouldRepaint(PosePainter oldDelegate) => true;
}

パフォーマンス最適化のポイント

1. フレームスキップによる負荷制御

骨格推定はCPU/GPU負荷が高いため、毎フレーム処理すると端末が発熱しやすくなります。フレームスキップを導入して、処理頻度を制御します。

int _frameCount = 0;
bool _isProcessing = false;

void onCameraFrame(CameraImage image) async {
  _frameCount++;
  // 2フレームに1回だけ処理(実質15fps相当)
  if (_frameCount % 2 != 0) return;
  // 前の処理が完了していない場合はスキップ
  if (_isProcessing) return;

  _isProcessing = true;
  try {
    final inputImage = convertCameraImage(image, _camera);
    if (inputImage != null) {
      final poses = await _poseDetector.detectPose(inputImage);
      setState(() => _poses = poses);
    }
  } finally {
    _isProcessing = false;
  }
}

2. Isolateを使った非同期処理

重い前処理をUIスレッドから切り離すことでジャンクを防ぎます。

import 'dart:isolate';

Future<Uint8List> preprocessImageInIsolate(Uint8List rawBytes) async {
  final receivePort = ReceivePort();
  await Isolate.spawn(_preprocessIsolate, [receivePort.sendPort, rawBytes]);
  return await receivePort.first as Uint8List;
}

void _preprocessIsolate(List<dynamic> args) {
  final SendPort sendPort = args[0];
  final Uint8List bytes = args[1];
  // ここで前処理(リサイズ・正規化など)を実行
  final processed = _applyPreprocessing(bytes);
  sendPort.send(processed);
}

3. 解像度の選択

解像度を下げることが最も効果的なパフォーマンス改善策の一つです。

// 骨格推定だけが目的なら低解像度で十分
ResolutionPreset.low    // 240p相当 - 最高速
ResolutionPreset.medium // 480p相当 - バランス型(推奨)
ResolutionPreset.high   // 720p相当 - 高精度が必要な場合

一般的なフォーム分析であれば medium で十分な精度が得られます。


Flutter開発全般のコツ

状態管理はRiverpodを使う

骨格推定アプリのように「カメラ状態」「推論結果」「UI状態」が複雑に絡み合う場面では、Riverpodが非常に有効です。

// 推論結果のプロバイダ
final posesProvider = StateNotifierProvider<PosesNotifier, List<Pose>>(
  (ref) => PosesNotifier(),
);

class PosesNotifier extends StateNotifier<List<Pose>> {
  PosesNotifier() : super([]);

  void updatePoses(List<Pose> poses) => state = poses;
  void clearPoses() => state = [];
}

プラットフォーム差異の吸収

骨格推定のように低レベルAPIを扱う場合、AndroidとiOSの差異が顕著に出ます。以下のようなアダプターパターンで差異を吸収しておくと保守しやすくなります。

abstract class PoseDetectorAdapter {
  Future<List<Pose>> detect(CameraImage image);
  void dispose();
}

class AndroidPoseDetectorAdapter implements PoseDetectorAdapter {
  // Android固有の実装
}

class IOSPoseDetectorAdapter implements PoseDetectorAdapter {
  // iOS固有の実装
}

// 利用側はインターフェースのみに依存
PoseDetectorAdapter createDetector() {
  return Platform.isAndroid
      ? AndroidPoseDetectorAdapter()
      : IOSPoseDetectorAdapter();
}

ライフサイクル管理を忘れずに

カメラやMLモデルのリソース解放はよく忘れがちです。WidgetsBindingObserverを使って、アプリがバックグラウンドに移行した際にも確実にリソースを解放します。

class _PoseCameraState extends State<PoseCamera>
    with WidgetsBindingObserver {

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive) {
      _cameraController?.stopImageStream();
    } else if (state == AppLifecycleState.resumed) {
      _initCamera(); // 再初期化
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _cameraController?.dispose();
    _poseDetector.dispose();
    super.dispose();
  }
}

ハマりやすいポイントと対処法

問題1:iOSでカメラが起動しない

原因: Info.plistにカメラ使用許可の記述が不足しています。

<!-- ios/Runner/Info.plist に追加 -->
<key>NSCameraUsageDescription</key>
<string>骨格推定のためカメラを使用します</string>

問題2:Androidでクラッシュが発生する

原因: minSdkVersionが低すぎる場合があります。MLKit系ライブラリはSDK 21以上が必要です。

// android/app/build.gradle
android {
    defaultConfig {
        minSdkVersion 21  // 21以上に設定
    }
}

問題3:推論結果の座標がずれる

原因: カメラの向き(回転)の扱いが不正確なことが多いです。フロントカメラ(インカメラ)と背面カメラで回転値が異なるため、カメラを切り替える際は sensorOrientation を再取得してください。

問題4:端末が熱くなる

対処法:

  • フレームスキップを導入する(前述)
  • 解像度を下げる
  • 処理間隔にクールダウンを設ける
  • compute() 関数でUIスレッドから処理を分離する

まとめ

骨格推定×Flutter開発のポイントをまとめます。

モデル選定:

  • 速度重視 → MoveNet Lightning
  • 精度重視 → BlazePose または MoveNet Thunder

実装:

  • google_mlkit_pose_detection パッケージが導入コストを大幅に削減してくれる
  • CustomPainter で骨格ラインをオーバーレイ描画
  • プラットフォーム差異はアダプターパターンで吸収

パフォーマンス:

  • フレームスキップで負荷制御
  • 解像度は medium が現実的なバランス
  • ライフサイクル管理でリソースリークを防ぐ

オンデバイスで骨格推定が動作するFlutterアプリは、ユーザーのプライバシーを守りながらリアルタイムなフィードバックを提供できる強みがあります。モデルの精度改善と端末性能の向上は今後も続くため、このアーキテクチャはますます実用的になっていくでしょう。

ご質問やご相談があればお気軽にどうぞ。


関連リンク: