Firebase ML Visionを使ってリアルタイムにバーコードをスキャンする機能を自作した

## 概要
FlutterではQRスキャン機能を持ったパッケージが複数あるが、スキャン部分がネイティブで書かれていたりして微妙に使い辛い。
また、Flutterのグレードアップに伴って利用できなくなるケースも多々ある。（本業では苦しめられた）
苦しめられたくないので、Google公式のパッケージである`camera`と`FirebaseML`を組み合わせてスキャン機能を自作した。
これを応用することで、リアルタイム顔認識機能やOCR機能をアプリに組み込むことも可能。
Flutterを実運用し始めてから約1年強、パッケージの依存関係には散々苦しめられたので、できる限り自作していこう（自戒）。

実際の挙動はこちら。
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478047/cbe80de9-296f-3edd-8644-a2b902057b34.gif)


## 実装
FirebaseをFlutterに組み込む方法や、`FirebaseML`の環境設定などは[公式ドキュメント](https://pub.dev/packages/firebase_ml_vision)に載っているので割愛する。
今回必要なパッケージは`camera`と`firebase_ml_vision`の２つだけ。

```yaml:pubspec.yaml
name: simple_qr_scan_sample
description: A new Flutter application.

version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  camera: ^0.5.7+3 // 追加
  firebase_ml_vision: ^0.9.3+5 // 追加

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
```

---

最小限？の実装は下記の通り。
特段難しいことはしていないが、`camera`の`startImageStream`の使い方を書いたドキュメントがイマイチなかったので少々苦戦した。

```dart:main.dart
import 'package:camera/camera.dart';
import 'package:firebase_ml_vision/firebase_ml_vision.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Simple QR Scan',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  CameraController _cameraController;
  bool _shouldSkipScanning;
  String _scannedData;

  @override
  void initState() {
    super.initState();

    // 画面の向きをポートレートモード（縦向き）に固定する
    // 固定する必要性はないが、ランドスケープモードの比率の計算が面倒だったので。
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
    ]);

    _shouldSkipScanning = false;
    _scannedData = 'スキャン中…';

    _cameraController = CameraController(
      null,
      null,
    );

    // この端末で利用可能なカメラを検出する
    availableCameras().then((value) {
      List<CameraDescription> cameraDescriptionList = value;

      _cameraController = CameraController(
        cameraDescriptionList[0], // 背面カメラを利用する。前面カメラならリスト1番目。
        ResolutionPreset.high, // 解像度高め。低すぎると認識精度が落ちる。
      );

      _cameraController.initialize().then((_) {
        if (!mounted) {
          return;
        }

        // スキャン処理を実行する
        _scanQrCode();

        setState(() {});
      });
    });
  }

  @override
  void dispose() {
    _cameraController.dispose();
    super.dispose();
  }

  Future<void> _scanQrCode() async {
    _cameraController.startImageStream(
      (CameraImage availableImage) async {
        if (_shouldSkipScanning) {
          return;
        }

        _shouldSkipScanning = true;

        // スキャンしたイメージをFirebaseMLで使える形式に変換する
        final FirebaseVisionImageMetadata metadata =
            FirebaseVisionImageMetadata(
          rawFormat: availableImage.format.raw,
          size: Size(
            availableImage.width.toDouble(),
            availableImage.height.toDouble(),
          ),
          planeData: availableImage.planes
              .map(
                (currentPlane) => FirebaseVisionImagePlaneMetadata(
                  bytesPerRow: currentPlane.bytesPerRow,
                  height: currentPlane.height,
                  width: currentPlane.width,
                ),
              )
              .toList(),
        );
        final FirebaseVisionImage visionImage = FirebaseVisionImage.fromBytes(
          availableImage.planes.first.bytes,
          metadata,
        );

        // バーコードを検出する
        final BarcodeDetector barcodeDetector =
            FirebaseVision.instance.barcodeDetector();
        final List<Barcode> barcodeList = await barcodeDetector.detectInImage(
          visionImage,
        );

        if (barcodeList.length != 0) {
          setState(() {
            _scannedData = barcodeList.first.rawValue;
          });
          _shouldSkipScanning = false;
        } else {
          _shouldSkipScanning = false;
        }
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black87,
      body: _cameraController.value.isInitialized // 初期化が終わったらカメラ映像を描画
          ? Column(
              mainAxisSize: MainAxisSize.max,
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Stack(
                  alignment: Alignment.center,
                  children: <Widget>[
                    AspectRatio(
                      aspectRatio:
                          1 / _cameraController.value.previewSize.aspectRatio,
                      child: CameraPreview(
                        _cameraController,
                      ),
                    ),
                    Column(
                      children: <Widget>[
                        Container(
                          height: MediaQuery.of(context).size.height * 0.6,
                        ),
                        FittedBox( // スキャンしたデータが何文字になるか分からないのでFittedBoxで囲った
                          child: Text(
                            _scannedData,
                            style: TextStyle(
                              color: Colors.greenAccent,
                              fontSize: 40.0,
                            ),
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            )
          : Center(
              child: CircularProgressIndicator(),
            ),
    );
  }
}
```


## 参考
Camera document how to use ImageStream
https://github.com/flutter/flutter/issues/26348