Flutter で画像認識

Flutterでカメラにアクセスして、AI (Machine Learning) で画像認識などしてみようと思います.
まずは、普通にカメラの画像を撮影するまで.

カメラアプリの準備

今回は、普通にスマホのカメラで写真を撮るところまでを実装します。

例によって、ソースコードは以下にあります。

Github - flutter_read_thermo の basic_camera

プラットフォーム毎の設定

Flutterのサンプル に詳しい説明が載ってます。 カメラで写真を撮るところまでは、そちらも参照に。サンプルとは、所々違うところがあります。

flutter createでプロジェクトを作ったら、カメラのハードウェアにアクセスするための権限設定をします。 当然カメラがないと動作確認できないので、実機での開発をお勧めします。

android/app/build.gradleを編集して、minSdkVersionを 21以上にします。

    defaultConfig {
        ...
        minSdkVersion 21
        ...
    }

ios/Runner/Info.plistにカメラアクセス用の権限リクエストを追加します。

<dict>
    ...
	<key>NSCameraUsageDescription</key>
	<string>写真をとります</string>
</dict>

あと、iOSでは通常の Flutter アプリと同じで、XCodeを使ってSigningBundle IDの変更が必要です。

Pub.dev

カメラのハードウェアを使うには、別途パッケージのインストールが必要となります。

Flutterでは、Flutterのコアチームを始め、色々なデベロッパーがライブラリ類をパッケージとして公開してくれています。 ほとんどが、Android と同じで、BSD ライセンスです。

pub.dev - flutterパッケージの公開リポジトリ

サーチすると様々なパッケージがあると思いますので、汎用的なもので、みんなが使うだろう機能は、作る前にここを探してみると二度手間が省けたりします。

main

普通の Flutter アプリは、main()から、runApp()を呼んで、アプリ Widget を渡すだけです。 今回は、アプリの起動前に初期化処理を行うので、main()を非同期実行します。 カメラのハードウェア初期化は実行に時間がかかるために非同期にします。

Future<void> main() async {
  try {
    WidgetsFlutterBinding.ensureInitialized();
    final cameras = await availableCameras();
    final firstCamera = cameras.first;
    runApp(MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CameraView(camera: firstCamera),
    ));
  } on CameraException catch (e) {
    debugPrint('Initialize Error: ${e.description}');
  }
}

Dartで非同期実行にするには、asyncキーワードを関数に付けます。

availableCameras()の戻り値は、Future<List<CameraDescription>> です。

Future<T>は、なんらかの処理を非同期で実行して、終了時、もしくはエラー時に実行されるコールバックを登録できるようにするものです。 Futureにawaitすると、結果が出るまで処理を待って、その値を受け取ることができるようになります。 ただし、awaitするためには、それを呼んでいる関数全体をasyncしなければいけません。

カメラは複数存在する機器もあるので、Listの中の最初のエントリfirstを使う様にします。 (今回はテスト実装なので決め打ちにしてます)

CameraView

CameraViewは、カメラ画像のプレビュー表示をして、ボタンが押されたら写真を撮る機能です。 StatefulWidgetにして、コンストラクタに渡すcameraの状態に反応するようにします。

class CameraView extends StatefulWidget {
  final CameraDescription camera;

  const CameraView({
    Key? key,
    required this.camera,
  }) : super(key: key);

  @override
  _CameraViewState createState() => _CameraViewState();
}

CameraViewのStateには、_controller_initializeControllerFutureを持たせます。

class _CameraViewState extends State<CameraView> {
  late final CameraController _controller;
  late final Future<void> _initializeControllerFuture;

  // Initializes camera controller to preview on screen
  void _initializeCamera() async {
    final CameraController cameraController = CameraController(
      widget.camera,
      ResolutionPreset.high,
      enableAudio: false,
    );
    _controller = cameraController;
    _initializeControllerFuture = _controller.initialize();
  }

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

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

_controllerは、カメラハードウェアの制御をするためのクラスです。 _initializeControllerFutureは、カメラの初期化を実行するためのFutureです。 カメラ自体の初期化には時間がかかるので、初期化終了時の処理は非同期実行になります。

initState()は、このStateが初期化された時、dispose()は、終わりに呼び出されます。 そこで、カメラのハードウェアの初期化を行っています。

UIツリーの方ですが、Scaffoldは画面要素を持った枠組みで、appBarbodyfloatingActionButton などのプロパティを設定するとUIのデザインに合わせて適当に配置してくれます。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('Camera Preview'),
        ),

FutureBuilderは、future:に指定したFutureが終了した後に、builder:が実行されます。 (厳密に言うとちょっと違うのですが、使い方としてはそんな感じです。)

        body: FutureBuilder<void>(
          future: _initializeControllerFuture,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.done) {
              return Center(child: CameraPreview(_controller));
            } else {
              return const Center(child: CircularProgressIndicator());
            }
          },
        ),

最初は、ConnectionStatewaitingになるので、CircularProgressIndicatorを中央に表示します。

_initializeControllerFutureは、カメラが使える様になったら complete します。 CameraPreviewでリアルタイムのプレビュー画面に変えます。

最後は、floatingActionButtonの設定で、画面の下の方に浮き上がる形のボタンを配置します。 ボタンが押されると、onPressed()が呼び出されます。

        floatingActionButton: FloatingActionButton(
          onPressed: () async {
            try {
              await _initializeControllerFuture;
              final image = await _controller.takePicture();
              debugPrint('Picture: ${image.path}');
              await Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => DetectView(
                    imagePath: image.path,
                  ),
                ),
              );
            } catch (e) {
              // If an error occurs, log the error to the console.
              debugPrint('Picture Error: Error: ${e}');
            }
          },
          child: const Icon(Icons.camera_alt),
        ));
  }

_controller.takePicture()が写真の撮影です。Future<XFile>が返ってきます。 XFile は、カメラ画像を一時ファイルに保存したものです。

Navigator.of(context).push()は詳しくは触れませんが、画面遷移を実行します。 pushすると画面スタックに現在の画面を積んで、その上に引数で与えた画面を描画することで、その画面に遷移します。

ここでは、DetectViewに、そのファイルのパス名を渡しています。 DetectViewは、この時点では、渡されたファイルの中身を表示するだけです。 そちらは次回に見ていきます。