カテゴリー: Tech

[Flutter]カメラのフレームデータを使ってリアルタイム顔認識

はじめに

今回はFlutterでカメラのフレームデータを取得して、リアルタイムで顔認識を行いたいと思います。
文字認識がしたい場合は、以前の記事「[Flutter]カメラのフレームデータを使ってリアルタイム画像認識」をよろしくお願いします。

準備

まずはパッケージのcameraをpubspec.yamlに追加しましょう。

flutter pub add camera

pubspec.yamlに追加されていれば成功です。

camera: ^0.10.0+1

次は、iOSでカメラを使用するために、Info.plistに以下の内容を追加します。
cameraパッケージでは動画も扱える関係で、カメラとマイク二つの許可が必要になります。

  <key>NSCameraUsageDescription</key>
    <string>カメラ使います</string>
  <key>NSMicrophoneUsageDescription</key>
    <string>マイクを使います</string>

また、cameraパッケージの動作環境は、iOSでは10以上、Androidは21以上になっています。それ以下のバージョンでは動作しません。

そうしたら次は、画像認識のためにgoogle_ml_kitパッケージを追加します。

flutter pub add google_ml_kit

こちらも、pubspec.yamlに追加されていれば成功です。

google_ml_kit: ^0.12.0

最後にカメラから取得した画像をgoogle_ml_kitに渡す際に
画像の回転処理などをスムーズに行うためにimageパッケージを追加します。

flutter pub add image

こちらも、pubspec.yamlに追加されていれば成功です。

image: ^3.2.0

実装

カメラプレビューの作成

最初に、カメラのプレビューをアプリで表示したいと思います。
まずは、デバイスで使用できるカメラの一覧を取得します。

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';

late List<CameraDescription> _cameras;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 使用できるカメラの一覧取得
  _cameras = await availableCameras();

  runApp(const CameraApp());
}

CameraDescriptionの中身は以下のようになっており、バックカメラやフロントカメラを取得することができます。

CameraDescription(com.apple.avfoundation.avcapturedevice.built-in_video:0, CameraLensDirection.back, 90)

次にカメラを制御するためのCameraControllerを作成します。

class CameraApp extends StatefulWidget {
  /// Default Constructor
  const CameraApp({Key? key}) : super(key: key);

  @override
  State<CameraApp> createState() => _CameraAppState();
}

class _CameraAppState extends State<CameraApp> {
  late CameraController controller;

  @override
  void initState() {
    super.initState();
    controller = CameraController(_cameras[0], ResolutionPreset.max);
    controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      setState(() {});
    }).catchError((Object e) {
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            print('User denied camera access.');
            break;
          default:
            print('Handle other errors.');
            break;
        }
      }
    });
  }

CameraControllerの第一引数に最初に取得したCameraDescriptionを、第二引数に解像度の指定を入れます。ResolutionPreset.maxを指定することで使用できる最大の解像度を設定してくれます。CameraControllerを生成したらinitializeを行い、CameraControllerの準備は完了です。

@override
Widget build(BuildContext context) {
  if (!controller.value.isInitialized) {
    return Container();
  }
  return MaterialApp(
    home: CameraPreview(controller),
  );
}

最後にCameraPreviewにCameraControllerをセットすれば、カメラのプレビュー表示は完了です。

またカメラプレビューやカメラを使っての写真撮影は過去記事をご確認ください。

プレビューからフレームデータ取得

次はプレビューからフレームデータを取得したいと思います。プレビューの初期化が完了している状態なら、CameraControllerのstartImageStreamを使用することでプレビュー画像の更新を受け取ることができます。

void _start() {
  _controller?.startImageStream(_processImage);
}

void _processImage(CameraImage cameraImage) async {

フレームデータから顔認識

それでは早速、_processImageで顔認識をおこないたいと思います。以下はサンプルソースです。

bool _isDetecting = false;
Size? _imageSize;
List<Face> _faces = [];
void _processImage(CameraImage cameraImage) async {
  if (!_isDetecting && mounted) {
    _isDetecting = true;

    // CameraImageからInputImageを作成する
    final WriteBuffer allBytes = WriteBuffer();
    for (final Plane plane in cameraImage.planes) {
      allBytes.putUint8List(plane.bytes);
    }
    final bytes = allBytes.done().buffer.asUint8List();

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

    final InputImageRotation imageRotation =
        _rotationIntToImageRotation(widget.camera.sensorOrientation);

    final InputImageFormat inputImageFormat =
        InputImageFormatValue.fromRawValue(cameraImage.format.raw) ??
            InputImageFormat.nv21;

    final planeData = cameraImage.planes.map(
          (Plane plane) {
        return InputImagePlaneMetadata(
          bytesPerRow: plane.bytesPerRow,
          height: plane.height,
          width: plane.width,
        );
      },
    ).toList();

    final inputImageData = InputImageData(
      size: imageSize,
      imageRotation: imageRotation,
      inputImageFormat: inputImageFormat,
      planeData: planeData,
    );

    final inputImage = InputImage.fromBytes(bytes: bytes, inputImageData: inputImageData);

    final options = FaceDetectorOptions();
    final detector = GoogleMlKit.vision.faceDetector(options);
    final faces = await detector.processImage(inputImage);

    setState(() {
      // 画像認識したサイズを保持
      _imageSize = Size(cameraImage.height.toDouble(), cameraImage.width.toDouble());
      // 画像認識で取得したFaceを保持
      _faces = faces;
    });
    // 250msスリープさせて負荷を下げる
    await Future.delayed(Duration(milliseconds: 250));
    _isDetecting = false;
  }
}

まずは取得したCameraImageを画像認識で使用するInputImageに変換します。CameraImageからInputImageへの変換はgoogle_mlkit_commonsで載せられているソースをほぼそのまま使用しています。具体的には以下の箇所になります。

// CameraImageからInputImageを作成する
final WriteBuffer allBytes = WriteBuffer();
for (final Plane plane in cameraImage.planes) {
  allBytes.putUint8List(plane.bytes);
}
final bytes = allBytes.done().buffer.asUint8List();

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

final InputImageRotation imageRotation =
    _rotationIntToImageRotation(widget.camera.sensorOrientation);

final InputImageFormat inputImageFormat =
    InputImageFormatValue.fromRawValue(cameraImage.format.raw) ??
        InputImageFormat.nv21;

final planeData = cameraImage.planes.map(
      (Plane plane) {
    return InputImagePlaneMetadata(
      bytesPerRow: plane.bytesPerRow,
      height: plane.height,
      width: plane.width,
    );
  },
).toList();

final inputImageData = InputImageData(
  size: imageSize,
  imageRotation: imageRotation,
  inputImageFormat: inputImageFormat,
  planeData: planeData,
);

final inputImage = InputImage.fromBytes(bytes: bytes, inputImageData: inputImageData);

InputImageを作成したらFaceDetectorOptionsを作成し、faceDetectorで顔認識をおこないます。

final options = FaceDetectorOptions();
final detector = GoogleMlKit.vision.faceDetector(options);
final faces = await detector.processImage(inputImage);

これで、facesに顔認識情報のFaceがListで取得できます。複数の顔認識ができた場合は、認識できた数分取得できます。

setState(() {
  // 画像認識したサイズを保持
  _imageSize = Size(cameraImage.height.toDouble(), cameraImage.width.toDouble());
  // 画像認識で取得したtextとrectを保持
  _faces = faces;
});

上記部分に関しては次の項目で顔認識した箇所に枠線を表示するための準備なので、顔認識だけ行いたいという方はスルーして大丈夫です。

顔認識した箇所に枠線の表示

CustomPaintを使用してカメラプレビューの上に顔認識で取得した顔の位置を枠線で表示したいと思います。CustomPaintについては過去記事をご確認ください。まずはカメラプレビューをCustomPaintのchildにして、foregroundで枠線を書いていきたいと思います。

Center(
  child: CustomPaint(
    foregroundPainter: FacePainter(
      _imageSize!,
      _faces,
    ),
    child: CameraPreview(_controller!),
  ),
),

_imageSizeと_facesは、ひとつ前の項目のサンプルソースで保持していますので、ご確認ください。
描画処理自体は単純で、保持したFace情報をもとに、認識した顔の輪郭を表示しているだけです。
気をつける箇所は、顔認識で使用しているカメラの解像度とCanvasのサイズが異なるので、_imageSizeで保持しているカメラの解像度に基づいてscale処理を行うようにしています。

class FacePainter extends CustomPainter {
  FacePainter(this.absoluteImageSize, this.elements);

  final Size absoluteImageSize;
  final List<Face> elements;
  final TextPainter painter = TextPainter(
    textDirection: TextDirection.ltr,
  );

  @override
  void paint(Canvas canvas, Size size) {
    // 画像認識した画像のサイズと実際に描画されるCanvasのサイズが違うためscaleをかける
    final double scaleX = size.width / absoluteImageSize.height;
    final double scaleY = size.height / absoluteImageSize.width;

    Rect _scaleRect(Face container) {
      return Rect.fromLTRB(
        container.boundingBox.left * scaleX,
        container.boundingBox.top * scaleY,
        container.boundingBox.right * scaleX,
        container.boundingBox.bottom * scaleY,
      );
    }

    // 枠線の描画
    final Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = Colors.blue
      ..strokeWidth = 2.0;

    for (Face element in elements) {
      canvas.drawRect(_scaleRect(element), paint);
    }
  }

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

ランドマークと輪郭の検出

上記までのサンプルコードだと、顔の位置を四角く検出するだけでしたが、FaceDetectorOptionsを指定することで、さらにランドマークの位置や、顔の輪郭や目の輪郭なども取得することができます。まずはFaceDetectorOptionsに「enableLandmarks」「enableContours」を指定します。

final options = FaceDetectorOptions(
  enableContours: true,
  enableLandmarks: true,
);

これでFace情報の中にランドマークと、各パーツの輪郭が入ってくるようになるので、上記サンプルのFacePainterを修正して、ランドマークと輪郭を表示できるようにします。

class FacePainter extends CustomPainter {
  FacePainter(this.absoluteImageSize, this.elements);

  final Size absoluteImageSize;
  final List<Face> elements;
  final TextPainter painter = TextPainter(
    textDirection: TextDirection.ltr,
  );

  @override
  void paint(Canvas canvas, Size size) {
    // 画像認識した画像のサイズと実際に描画されるCanvasのサイズが違うためscaleをかける
    final double scaleX = size.width / absoluteImageSize.height;
    final double scaleY = size.height / absoluteImageSize.width;

    Rect _scaleRect(Face container) {
      return Rect.fromLTRB(
        container.boundingBox.left * scaleX,
        container.boundingBox.top * scaleY,
        container.boundingBox.right * scaleX,
        container.boundingBox.bottom * scaleY,
      );
    }

    void _drawLandmark(Face element) {
      element.landmarks.forEach((key, value) {
        // 認識した顔の描画
        painter.text = TextSpan(
          text: key.name,
          style: TextStyle(
            color: Colors.white,
            backgroundColor: Colors.blue,
            fontSize: 8.0,
          ),
        );
        Offset position = Offset(
          (value?.position.x ?? 0) * scaleX,
          (value?.position.y ?? 0) * scaleY,
        );
        painter.layout();
        painter.paint(canvas, position);
      });
    }

    void _drawContour(Face element) {
      element.contours.forEach((key, value) {
        final path = Path()
          ..moveTo(
            (value?.points[0].x ?? 0) * scaleX,
            (value?.points[0].y ?? 0) * scaleY,
          );
        for(var point in value!.points) {
          path.lineTo(
            point.x * scaleX,
            point.y * scaleY,
          );
        }
        path.moveTo(
          (value?.points[0].x ?? 0) * scaleX,
          (value?.points[0].y ?? 0) * scaleY,
        );
        final paint = Paint()
          ..color = Colors.green
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2;

        canvas.drawPath(path, paint);
      });
    }
    // 枠線の描画
    final Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = Colors.blue
      ..strokeWidth = 2.0;

    for (Face element in elements) {
      canvas.drawRect(_scaleRect(element), paint);
      _drawLandmark(element);
      //_drawContour(element);
    }
  }

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

_drawLandmarkを有効化した場合

_drawContourを有効化した場合

おすすめ書籍

nukky

シェア
執筆者:
nukky
タグ: Flutter

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

2週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

3週間 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前