Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Flutter でカメラ映像と Widget を重ね合わせて劣化させずに撮影する

こんにちは!この記事では Flutter でカメラを扱うアプリを作成する際の工夫について、紹介します。

はじめに

弊社で開発されている駅メモ!おでかけカメラ(以下「おでかけカメラ」)は 2022 年 11 月にリニューアルし、UI の刷新や動作不良の解消、機能の拡充を行いました。 内部的には、これまでの Unity 製だったおでかけカメラ(以下「旧おでかけカメラ」)を一度全て捨て、新しく Flutter で作り直すということをしています。

社内には Flutter に関する知見がほとんどなかったため、おでかけカメラのリニューアルは技術のキャッチアップから始まり、試行錯誤を重ねた開発となりました。

今回はその試行錯誤の中から、カメラの映像内にフレームやスタンプのような装飾を Widget として配置し、撮影した際の、写真と Widget を重ね合わせる工夫について説明します。

プレビューをそのまま撮影結果とすることの問題点

カメラ映像と Widget を重ね合わせて撮影する最も簡単な方法として、画面のスクリーンショットを撮影し、プレビュー領域以外の部分を削除することが考えられます。

この方法であれば、プレビューで表示されていたものが間違いなくそのまま撮影結果として得られますし、実装も単純になるのですが、大きな欠点があります。 それは、撮影画質がディスプレイ解像度に依存しているため、多くの場合カメラの性能を活かしきれず、残念画質な写真にしかならないと言うことです。

撮影後の画像サイズに合わせて Widget を再構築し、画像化する

上記の問題点を解消し、カメラの性能を最大限活かしつつプレビュー通りの撮影結果を得るために、おでかけカメラでは写真のサイズや縦横比と合うように一緒に撮影する Widget を画像化し、写真と合成するアプローチを採用しました。

具体的には画面に描画していないオフスクリーンな Widget Tree を構築できる BuildOwner というクラスを使用しています。

以下は撮影した写真のサイズに合わせた Widget を作成し、画像にしたものを取得するコードです。

import 'dart:ui' as ui;
import 'package:image/image.dart' as img;

Future<img.Image> getOverlayImage(Size imageSize) async {
  // 描画、再描画を効率的に行えるようにする Class
  // ただし、ここでは Widget を画像化する機能を利用するために使っている
  final repaintBoundary = RenderRepaintBoundary();

  // 描画する場所
  // 写真のサイズで Widget が描画されるようにパラメータを渡している
  final renderView = RenderView(
    window: ui.window,
    child: RenderPositionedBox(alignment: Alignment.center, child: repaintBoundary),
    configuration: ViewConfiguration(
        size: imageSize,
        devicePixelRatio: 1.0,
    ),
  );

  // Widget Tree の描画を制御する Class
  final pipelineOwner = PipelineOwner();

  pipelineOwner.rootNode = renderView;
  renderView.prepareInitialFrame();

  // Widget Tree の構築、再構築を制御する Class
  final buildOwner = BuildOwner(focusManager: FocusManager());

  // 描画するもの
  // CameraOverlay() を画像化したものが最終的に欲しいもの
  final element = RenderObjectToWidgetAdapter<RenderBox>(
    container: repaintBoundary,
    child: CameraOverlay(),
  ).attachToRenderTree(buildOwner);

  // BuildOwner に構築する Widget Tree のスコープを指定
  buildOwner.buildScope(element);
  buildOwner.finalizeTree();

  // PipelineOwner で描画
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();

  // 画像に変換して返却する
  final ui.Image widgetImage = await repaintBoundary.toImage();
  final ByteData byteData = await widgetImage.toByteData(format: ui.ImageByteFormat.png);
  return img.decodeImage(byteData.buffer.asUint8List());
}

簡単に説明すると、 BuildOwner で構築した Widget Tree を PipelineOwnerRenderView にレンダリングし、 RenderRepaintBoundary の機能を使って画像として書き出しています。

あとは以下の通り、画像化した Widget を撮影した写真と合成してあげれば、出来上がりです。

final img.Image cameraImage = takePicture();
final Size imageSize = Size(cameraImage.width.double(), cameraImage.height.double());

final img.Image overlayImage = await getOverlayImage(imageSize);

final img.Image compositeImage = img.drawImage(cameraImage, overlayImage);

まとめ

Flutter でカメラ映像と、その上に重ねて表示した Widget を合わせて撮影する際に、品質を落とさずプレビュー通りの結果を得るための工夫について紹介しました。

やりたいことは単純なのに意外と一工夫が必要ということで、詰まりやすいところなのかなと思いました。 カメラアプリを作る際にでも参考になれば幸いです。