Flutter で CameraImage から FirebaseVisionImage に変換する

Flutter で ML Kit for Firebase を使ってカメラと連動したアプリを開発するときには、次のような実装が多いと思います。

1. [camera](https://pub.dartlang.org/packages/camera) パッケージの [CameraPreview](https://pub.dartlang.org/documentation/camera/latest/camera/CameraPreview-class.html) を使ってカメラのプレビューを表示させる
2. 同時に、[startImageStream](https://pub.dartlang.org/documentation/camera/latest/camera/CameraController/startImageStream.html) でカメラから画像の取得を連続して行い、取得した画像を ML Kit for Firebase で処理する


ここで、2. でのカメラからの画像の取得では、startImageStream の引数に渡したコールバック関数には [CameraImage](https://pub.dartlang.org/documentation/camera/latest/camera/CameraImage-class.html) という型のデータが与えられます。この CameraImage という型は [firebase_ml_vision](https://pub.dartlang.org/packages/firebase_ml_vision)（ML Kit for Firebase の公式ライブラリ）で使用される [FirebaseVisionImage](https://pub.dartlang.org/documentation/firebase_ml_vision/latest/firebase_ml_vision/FirebaseVisionImage-class.html) という型とは別の型なので、型を合わせる処理が必要です。

ところが、そのための方法は公式ドキュメントに載っていません。GitHub でサンプルコードを眺めてみても処理方法がバラバラでした。ドキュメント拡充の要望は Issue に上がっているので、困っている方は reaction をつけましょう。

https://github.com/flutter/flutter/issues/26348

とはいえドキュメントが拡充されるには時間がかかります。ML Kit for Firebase を利用するための最も無難な処理方法を見つけるため、GitHub 上で見つかるサンプルコードでの処理方法を眺めてみました。

## 調査方法

GitHub の全体検索で startImageStream を含む Dart のコードを検索して、調査時点で動作確認ができたものを列挙しました。

重複は個人の判断で排除しました。

## 調査結果

### [GoogleCloudPlatform/iot-smart-home-cloud](https://github.com/GoogleCloudPlatform/iot-smart-home-cloud)

```dart
// Collect all planes into a single buffer
final WriteBuffer allBytesBuffer = WriteBuffer();
image.planes.forEach((Plane plane) => allBytesBuffer.putUint8List(plane.bytes));
final Uint8List allBytes = allBytesBuffer.done().buffer.asUint8List();

// Convert the image buffer into a Firebase detector frame
FirebaseVisionImage firebaseImage = FirebaseVisionImage.fromBytes(allBytes,
  FirebaseVisionImageMetadata(
    rawFormat: image.format.raw,
    size: Size(image.width.toDouble(), image.height.toDouble()),
    rotation: ImageRotation.rotation90,
    planeData: image.planes.map((plane) => FirebaseVisionImagePlaneMetadata(
      height: plane.height,
      width: plane.width,
      bytesPerRow: plane.bytesPerRow,
    )).toList(),
  ),
);
```

https://github.com/GoogleCloudPlatform/iot-smart-home-cloud/blob/e65a1535d909639de37e8bdda81bbb8167eaed96/mobile/lib/register.dart#L55

Google が出しているので、これを参考にしている人も多そうです。`rotation: ImageRotation.rotation90` と決め打ちしてしまっているのが少し気になりますね。

### [bparrishMines/mlkit_demo](https://github.com/bparrishMines/mlkit_demo)

```dart
Uint8List concatenatePlanes(List<Plane> planes) {
  final WriteBuffer allBytes = WriteBuffer();
  planes.forEach((Plane plane) => allBytes.putUint8List(plane.bytes));
  return allBytes.done().buffer.asUint8List();
}

FirebaseVisionImageMetadata buildMetaData(
    CameraImage image,
    ImageRotation rotation,
    ) {
  return FirebaseVisionImageMetadata(
    rawFormat: image.format.raw,
    size: Size(image.width.toDouble(), image.height.toDouble()),
    rotation: rotation,
    planeData: image.planes.map(
          (Plane plane) {
        return FirebaseVisionImagePlaneMetadata(
          bytesPerRow: plane.bytesPerRow,
          height: plane.height,
          width: plane.width,
        );
      },
    ).toList(),
  );
}

Future<List<Face>> detect(
    CameraImage image,
    HandleDetection handleDetection,
    ImageRotation rotation,
    ) async {
  return handleDetection(
    FirebaseVisionImage.fromBytes(
      concatenatePlanes(image.planes),
      buildMetaData(image, rotation),
    ),
  );
}

ImageRotation rotationIntToImageRotation(int rotation) {
  switch (rotation) {
    case 0:
      return ImageRotation.rotation0;
    case 90:
      return ImageRotation.rotation90;
    case 180:
      return ImageRotation.rotation180;
    default:
      assert(rotation == 270);
      return ImageRotation.rotation270;
  }
}
```

https://github.com/bparrishMines/mlkit_demo/blob/d1aec3d647f0cbb8165e86d552dc11153679b906/lib/utils.dart#L19

公式とは関係のないリポジトリと思いきや、[Flutter Live で登壇している Flutter チームのエンジニア](https://youtu.be/OAEWySye0BQ?t=1279) のリポジトリなので、変なことはしていないと期待できます。

大まかな処理は GoogleCloudPlatform/iot-smart-home-cloud と同じですが、rotation をちゃんと計算しているところが違います。CameraDescription の sensorOrientation を rotationIntToImageRotation に渡して ImageRotation を取ってくるようです。

orientation 周りを処理しているサンプルコードがなかなか見つからないのですが、これは Flutter で orientation を取得できるようになったのが最近のことだからです。camera ライブラリでは orientation はバージョン 0.4.2 から取得できるようになりました。今から開発を行うのであれば、ちゃんと処理したほうがよいでしょう。

> 0.4.2
> Add sensor orientation value to CameraDescription.

https://github.com/flutter/plugins/blob/952270a510fd5832440f62091c1e8f2e141d60f9/packages/camera/CHANGELOG.md#042

### [dshukertjr/snow](https://github.com/dshukertjr/snow)

```dart
final FirebaseVisionImageMetadata metadata = FirebaseVisionImageMetadata(
  rawFormat: availableImage.format.raw,
  planeData: availableImage.planes
      .map(
        (currentPlane) => FirebaseVisionImagePlaneMetadata(
              bytesPerRow: currentPlane.bytesPerRow,
              height: currentPlane.height,
              width: currentPlane.width,
            ),
      )
      .toList(),
  size: Size(
      availableImage.width.toDouble(), availableImage.height.toDouble()),
  rotation: ImageRotation.rotation270,
);

final FirebaseVisionImage visionImage =
    FirebaseVisionImage.fromBytes(availableImage.planes[0].bytes, metadata);
```

https://github.com/dshukertjr/snow/blob/b713485a27f0cfdac30aaab22a25878fb987b47b/lib/main.dart#L94

`rotation: ImageRotation.rotation270` を決め打ちしてしまっている点は GoogleCloudPlatform/iot-smart-home-cloud と同じです。

おもしろいのは `FirebaseVisionImage.fromBytes` に `planes[0]` しか渡していない点です。[FirebaseVisionImage.fromBytes のドキュメント](https://pub.dartlang.org/documentation/firebase_ml_vision/latest/firebase_ml_vision/FirebaseVisionImage/FirebaseVisionImage.fromBytes.html) によれば Android では NV21 フォーマット（YUV_420_888 の plane を連結したフォーマット）のバイト列が渡されることを期待しているのですが、画像処理では輝度成分、すなわち YUV の Y 成分しか必要ないことも多いので `planes[0]` だけ渡していると考えられます。こうなってくると `planeData` も `planes[0]` の分しか必要ないのではないかと思えてきましたが、検証していないのでわかりません。

## まとめ

私が開発するときには以下の方針で進めようと思います。

* 基本的に [bparrishMines/mlkit_demo](https://github.com/bparrishMines/mlkit_demo) を参考にする。
* 最近はカメラの orientation が取得できるようになっているので、ちゃんと処理する。
* カラー画像である必要が無いのであれば輝度成分だけでもいいかも。