• トップ
  • ブログ一覧
  • [Flutter] Firebase Cloud FunctionsとCloud Vision APIを使って画像のテキスト検出をしてみた
  • [Flutter] Firebase Cloud FunctionsとCloud Vision APIを使って画像のテキスト検出をしてみた

    いまむー(エンジニア)いまむー(エンジニア)
    2023.07.11

    IT技術

    今回やったこと

    Flutterアプリで画像の文字認識をしたいと思い、Firebase Cloud FunctionsとColoud Vision APIで使ってOCRアプリを作ってみました。
    実際に作成したアプリとしては、カメラで撮った画像をFirebase Cloud Functionsを通してCloud Vision APIにリクエストし、検出した文字を次の画面に表示するといったものです。

    使用したもの

    • Firebase Authentication(匿名認証のみ)
    • Firebase Cloud Functions
    • Cloud Vision API
    • camera(Flutterのカメラパッケージ)

    デモ

    環境

    • macOS: Venture 13.4
    • プロセッサ: Intel
    • Xcode: 14.3.1
    • Android Studio: Dolphin (2021.3.1)
    • Flutter SDK: 3.7.8

    アプリの実装

    手順

    • プロジェクトの作成
    • Firebaseの導入
    • Google Cloudの導入
    • Firebase Cloud Functions側の実装
    • Flutter側の実装
    • 動作確認

    プロジェクトの作成

    Flutterプロジェクトの作成をします。
    私の場合、FVMでFlutter SDKのバージョンを管理しているため、以下のコマンドでプロジェクトを作成しました。

    1~$ mkdir ocr-app
    2~$ cd ocr-app
    3~/ocr-app$ fvm use {使用したいバージョン} --force
    4~/ocr-app$ fvm flutter create . --project-name ocr_app --platforms android,ios -e

    Firebaseの導入

    ドキュメントを参考にFirebaseを導入していきます。

    今回使用するFirebaseのプラグイン

    今回使用するFirebaseのプラグインはAuthenticationとCloud Functionsです。
    以下のドキュメント内容を参考に、AuthenticationとCloud Functionsを使ってCloud Vision APIを呼び出すように実装していきます。

    アプリから Google Cloud API を呼び出すには、認可を処理し、API キーなどのシークレット値を保護するための中間 REST API を作成する必要があります。次に、モバイルアプリでこの中間サービスに対する認証と通信を行うためのコードを記述します。
    この REST API を作成する方法の一つとして、Firebase Authentication と Functions を使用する方法があります。この方法では、Google Cloud API に対するサーバーレスのマネージド ゲートウェイが提供され、そこで認証が処理されます。このゲートウェイは、事前構築された SDK を使用してモバイルアプリから呼び出すことができます。

    Firebase Auth と Functions を使用して Cloud Vision で画像内のテキストを安全に認識する(Apple プラットフォーム)

    コマンドラインツールのインストール

    Firebase CLIをインストールします。
    Firebase CLIをインストールする方法はいくつかあるのですが、今回はnpmでインストールをしました。
    npmでインストールする場合は以下のコマンドを実行します。

    1~$ npm install -g firebase-tools

    これにより、グローバルに使用できるfirebaseコマンドが有効になります。

    次に以下のコマンドを実行し、Firebaseにログインします。

    1~$ firebase login

    私の場合、以前ログインしたことがあり、`Already logged in as 〇〇@gmail.com`と表示されましたが、まだの方は流れに沿ってログインします。

    Firebaseにログインできたら次に、任意のディレクトリで以下のコマンドを実行してFlutterFire CLIをインストールします。
    任意のディレクトリで実行とのことなので、ホームディレクトリ(~)で以下のコマンドを実行しました。

    1~$ dart pub global activate flutterfire_cli

    ここまでで必要なコマンドラインツールのインストール完了です。

    Firebaseの構成ファイルを作成

    次のコマンドでアプリの構成ワークフローを実行します。

    1~/ocr-app$ flutterfire configure

    コマンド実行時に、既存のFirebaseプロジェクトを選択するか、Firebaseプロジェクトを新規作成する必要があります。
    新規作成する場合は、一意のプロジェクトIDを入力する必要があり、すでに存在するIDだと作成が失敗してしまいます。
    コンソールから新規プロジェクトを作成すると自動でIDが決定するので、コンソールで作成するのが個人的にオススメです。

    上記コマンドを流れに沿って進めると、Firebaseの接続に必要なファイルがプロジェクトに追加されます。

    使用するFirebaseのプラグインをインストール

    FlutterアプリをFirebaseプロジェクトに接続するためのFirebase Core
    ユーザー認証するためのFirebase Authentication
    Cloud Vision APIを呼び出すために使用するFirebase Cloud Functions
    以上の3つをFlutterプロジェクトにインストールしていきます。

    以下のコマンドを実行して各プラグインをインストールします。

    1~/ocr-app$ flutter pub add firebase_core firebase_auth cloud_functions

    次に以下のコマンドでFirebaseの構成ファイルを更新します。

    1~/ocr-app$ flutterfire configure

    今回のアプリでは匿名認証を行うのため、Firebaseコンソールから匿名認証を有効化します。
    コンソールのAuthenticationを開き、ネイディブのプロバイダの匿名を選択します。

    トグルボタンを有効にして保存し、匿名認証を有効化します。

    アプリでFirebaseを初期化

    main関数でFirebase.initializeAppを定義し、Firebaseを初期化します。

    1import 'package:firebase_core/firebase_core.dart';
    2import 'package:ocr_app/firebase_options.dart';
    3
    4void main() async {
    5  WidgetsFlutterBinding.ensureInitialized();
    6  await Firebase.initializeApp(
    7    options: DefaultFirebaseOptions.currentPlatform,
    8  );
    9  runApp(const MainApp());
    10}

    Firebase Cloud Functionsの初期化

    ドキュメントを参考に、以下のコマンドでFirebase Cloud Functionsを初期化します。

    1~/ocr-app$ firebase init functions

    コマンドを実行すると、オプションの選択が求められます。
    まず、既存のプロジェクトを使用するか、新規プロジェクトを使用するかなどを選択します。
    今回の場合、すでにプロジェクトがあるので、既存のプロジェクトを選択し、今回のプロジェクトを選択します。
    次に、Cloud Functionsで使用する言語を選択します。
    JavaScriptとTypeScriptとPythonが選択でき、今回はTypeScriptを選択しました。
    次にESLintを使用するかと、npm installを実行するかを聞かれたので、どちらもYを入力しました。
    以上で実行が完了すると、プロジェクトにfunctionsディレクトリが作成されます。

    Cloud Vision APIの有効化

    Cloud Vision APIを使用するためには、Blaze料金プラン(従量課金制)にFirebaseプロジェクトをアップグレードする必要があります。
    Cloud Vision APIは1000ユニット/月まで無料で使用できますが、1000ユニットを超えると1000ユニット単位で$1.5料金が発生するのでご注意ください。
    ※1つの画像に対してテキスト検出を行うと1ユニットがカウントされます。

    Blaze料金プランにアップグレードするため、FirebaseコンソールのFirebase ML の[APIs]ページを開きます。
    ページを開いたら、アップグレードボタンからBlaze料金プランへアップグレードを行います。

    アップデートができたら、CloudベースのAPIを有効化のトグルが表示されるのでONにします。

    次に上記手順で作成されたAPIキーのアクセスを制限します。
    Cloudコンソールの認証情報ページを開きます。
    ページを開いたら、キーを制限を選択し今回使用するCloud Functions APIとCloud Vision APIを選択して保存します。

    以上でCloud Vision APIの有効化完了です。

    Google Cloudの導入

    以下のドキュメントを参考にCloud Vision APIを導入します。

    gcloud CLIをインストール

    プラットフォーム別にパッケージのファイルが用意されているので、自身のプラットフォームにあったファイルをダウンロードします。
    ダウンロードしたファイルをホームディレクトリ(~)で開きます。

    次に以下のコマンドでインストールスクリプトを実行し、gcloud CLIツールをPATHに追加します。

    1~$ sh ./google-cloud-sdk/install.sh

    流れに沿ってコマンド実行が完了すると、gcloudコマンドが使用可能になります。

    次に以下のコマンドでgcloud CLIを初期化します。

    1~$ gcloud init

    流れに沿ってコマンド実行が完了すると、Google Cloud SDKが使用可能になります。

    Vision APIの有効化

    以下のコマンドを実行し、Vision APIを有効にします。

    1~$ gcloud services enable vision.googleapis.com

    クライアントライブラリをインストール

    Cloud Vision APIのライブラリをインストールします。
    今回、Firebase Cloud FunctionsではTypeScriptを使用しているので、以下のコマンドを実行しNode.jsのライブラリをインストールします。

    1~/ocr-app/functions$ npm install --save @google-cloud/vision

    Firebase Cloud Functions側の実装

    以下のドキュメントを参考にFirebaes Cloud FunctionsでCloud Vision APIにリクエストする関数を実装していきます。

    1import vision from "@google-cloud/vision";
    2import * as functions from "firebase-functions";
    3
    4const client = new vision.ImageAnnotatorClient();
    5
    6export const textDetection =
    7  functions.https.onCall(async (data: Buffer, context) => {
    8    // ユーザー認証されていない場合、エラーを返す
    9    if (!context.auth) {
    10      throw new functions.https.HttpsError(
    11        "unauthenticated",
    12        "ユーザー認証されていません"
    13      );
    14    }
    15    try {
    16      const request = {
    17        image: {
    18          content: data,
    19        },
    20      };
    21      // 画像のテキスト検出をリクエスト
    22      const result = await client.textDetection(request);
    23      const fullText = result[0].fullTextAnnotation?.text;
    24      return fullText;
    25    } catch (error) {
    26      if (error instanceof Error) {
    27        throw new functions.https.HttpsError(
    28          "internal",
    29          error.message
    30        );
    31      } else {
    32        throw new functions.https.HttpsError(
    33          "unknown",
    34          "不明なエラー"
    35        );
    36      }
    37    }
    38  });

    このコードでは、ImageAnnotatorClientを使用して画像のテキスト検出を行う関数を定義しています。
    引数のdataでは、リクエスト時に必要な画像のデータ(Buffer)を受け取るようにします。
    contextのユーザーの認証情報を使用し、ユーザーの認証がされていない場合、認証エラーを返します。
    画像のテキスト検出が成功した場合、fullTextAnnotation.textで検出結果を取り出して返します。

    エラーを返す時にHttpsErrorをスローしていますが、FunctionsではHttpsErrorをスローすることによりアプリ側でエラーの詳細を取得できるようになります。
    HttpsErrorは引数のcodeにFunctionsのエラーコード、messageに文字列、オプションでdetails?に任意の値を設定することができます。
    HttpsError以外をスローすると、エラーのcodeにinternal、messageにINTERNALが設定されたエラーが返されます。
    Functionsのエラーコード

    Flutter側の実装

    カメラで撮影した画像のテキスト検出をし、次の画面で検出結果を表示するアプリを実装していきます。

    カメラ画面の作成

    以下のドキュメントを参考にカメラ画面を作成します。

    まず以下のコマンドでカメラパッケージをインストールします。

    1~/ocr-app$ flutter pub add camera
    2~/ocr-app$ flutter pub get

    次にiOSとAndroidそれぞれの設定をしていきます。
    iOSではInfo.plistにNSCameraUsageDescriptionを設定します。
    NSCameraUsageDescriptionにはアプリがデバイスのカメラへのアクセスを要求している理由を設定します。
    設定するとカメラアクセスの許可を求めるダイアログに設定した文字が表示されます。
    今回は使用しないのですが、マイクを使用する場合はNSMicrophoneUsageDescriptionも設定します。
    NSMicrophoneUsageDescriptionにはアプリがデバイスのマイクへのアクセスを要求している理由を設定します。

    1<key>NSCameraUsageDescription</key>
    2<string>写真を撮影するためにカメラを利用します</string>
    3<key>NSMicrophoneUsageDescription</key>
    4<string>録音するためマイクを利用します</string>

    Androidではandroid/app/build.gradleのminSdkVersionを21に設定します。

    1minSdkVersion 21

    各プラットフォームの設定ができたので、カメラ画面を作成します。

    1import 'package:camera/camera.dart';
    2import 'package:flutter/material.dart';
    3import 'package:ocr_app/annotate_image_repository.dart';
    4import 'package:ocr_app/read_result_screen.dart';
    5
    6class CameraScreen extends StatefulWidget {
    7  const CameraScreen({super.key});
    8
    9  @override
    10  State<CameraScreen> createState() => _CameraScreenState();
    11}
    12
    13class _CameraScreenState extends State<CameraScreen>
    14    with WidgetsBindingObserver {
    15  CameraController? _cameraController;
    16  double get previewHeight => _cameraController!.value.previewSize!.height;
    17  double get previewWidth => _cameraController!.value.previewSize!.width;
    18  bool isLoading = false;
    19
    20  @override
    21  void initState() {
    22    super.initState();
    23    _initializeCameraController();
    24  }
    25
    26  Future<void> _initializeCameraController() async {
    27    // 利用可能なカメラの一覧を取得
    28    final cameras = await availableCameras();
    29    // 外カメを取得
    30    final camera = cameras.first;
    31    // 外カメ、利用可能な最高の解像度、録音OFFでCameraControllerを作成
    32    _cameraController = CameraController(
    33      camera,
    34      ResolutionPreset.max,
    35      enableAudio: false,
    36    );
    37    // カメラを初期化
    38    await _cameraController?.initialize();
    39    setState(() {});
    40  }
    41
    42  @override
    43  void didChangeAppLifecycleState(AppLifecycleState state) {
    44    if (!(_cameraController?.value.isInitialized ?? false)) {
    45      return;
    46    }
    47
    48    if (state == AppLifecycleState.inactive) {
    49      _cameraController?.dispose();
    50    } else if (state == AppLifecycleState.resumed) {
    51      _initializeCameraController();
    52    }
    53  }
    54
    55  @override
    56  void dispose() {
    57    _cameraController?.dispose();
    58    super.dispose();
    59  }
    60
    61  @override
    62  Widget build(BuildContext context) {
    63    final isPortrait =
    64        MediaQuery.of(context).orientation == Orientation.portrait;
    65    return Stack(
    66      children: [
    67        Scaffold(
    68          body: !(_cameraController?.value.isInitialized ?? false)
    69              ? Container()
    70              // 縦横比を保ったまま、画面最大まで拡大
    71              : SizedBox.expand(
    72                  child: FittedBox(
    73                    fit: BoxFit.contain,
    74                    child: SizedBox(
    75                      width: isPortrait ? previewHeight : previewWidth,
    76                      height: isPortrait ? previewWidth : previewHeight,
    77                      child: CameraPreview(_cameraController!),
    78                    ),
    79                  ),
    80                ),
    81          floatingActionButton: FloatingActionButton(
    82            onPressed: () {
    83              // TODO: 画像を撮影と検出した文字を次の画面に表示する処理を実装
    84            },
    85            child: const Icon(Icons.camera_alt),
    86          ),
    87        ),
    88        if (isLoading)
    89          const ColoredBox(
    90            color: Colors.black38,
    91            child: SizedBox.expand(
    92              child: Center(
    93                child: CircularProgressIndicator(),
    94              ),
    95            ),
    96          )
    97      ],
    98    );
    99  }
    100}

    このコードでは、cameraControllerの初期化とプレビューの表示をしています。
    Future<void> _initializeCameraController()では、利用可能なカメラの一覧を取得し、外カメ、最高の解像度、録音OFFでCameraControllerを作成します。
    Widget build(context)では、cameraControllerが初期化されている場合、縦横比を保ちながら画面最大まで拡大したプレビューを表示します。
    注意点としては、高さと幅を指定する時に、画面の向きからPreviewSizeの高さと幅を切り替えているところです。
    このようにしている理由は、CameraPreviewが画面の向きからPreviewSizeの向きを変えているためです。
    以下はCameraPreviewの実装です。

    1class CameraPreview extends StatelessWidget {
    2  
    3  ...
    4
    5  @override
    6  Widget build(BuildContext context) {
    7    return controller.value.isInitialized
    8        ? ValueListenableBuilder<CameraValue>(
    9            valueListenable: controller,
    10            builder: (BuildContext context, Object? value, Widget? child) {
    11              return AspectRatio(
    12                aspectRatio: _isLandscape()
    13                    ? controller.value.aspectRatio
    14                    : (1 / controller.value.aspectRatio),
    15                child: Stack(
    16                  fit: StackFit.expand,
    17                  children: <Widget>[
    18                    _wrapInRotatedBox(child: controller.buildPreview()),
    19                    child ?? Container(),
    20                  ],
    21                ),
    22              );
    23            },
    24            child: child,
    25          )
    26        : Container();
    27  }
    28
    29  ...
    30
    31  bool _isLandscape() {
    32    return <DeviceOrientation>[
    33      DeviceOrientation.landscapeLeft,
    34      DeviceOrientation.landscapeRight
    35    ].contains(_getApplicableOrientation());
    36  }
    37
    38  ...
    39
    40}

    実装を見ると、aspectRatioを画面の向きによって変えていることがわかります。
    このことから、横に長いの長方形(PreviewSize)を縦画面の時には縦向きで表示し、横画面の時はそのまま横向きで表示しているような内部実装になっていることが考えられます。
    ですので、それに合わせてCameraPreviewを表示する時にも、画面の向きからSizedBoxの高さと幅を切り替えています。

    カメラ画面ができたので、main.dartを修正して初期画面でカメラ画面を表示するよう修正します。
    今回はOcrAppというStatelessWidgetを作成し、MaterialAppのhomeでCameraScreenを定義しています。

    1import 'package:firebase_core/firebase_core.dart';
    2import 'package:flutter/material.dart';
    3import 'package:ocr_app/firebase_options.dart';
    4
    5import 'camera_screen.dart';
    6
    7void main() async {
    8  // Firebaseを初期化
    9  WidgetsFlutterBinding.ensureInitialized();
    10  await Firebase.initializeApp(
    11    options: DefaultFirebaseOptions.currentPlatform,
    12  );
    13  runApp(const OcrApp());
    14}
    15
    16class OcrApp extends StatelessWidget {
    17  const OcrApp({super.key});
    18
    19  @override
    20  Widget build(BuildContext context) {
    21    return const MaterialApp(
    22      home: CameraScreen(),
    23    );
    24  }
    25}

    撮影した画像のテキスト検出

    以下のドキュメントを参考にテキスト検出をリクエストし、検出したテキストを表示する画面を作成します。

    まず始めにテキスト検出をリクエストする部分の実装をしていきます。

    1import 'dart:typed_data';
    2
    3import 'package:cloud_functions/cloud_functions.dart';
    4import 'package:firebase_auth/firebase_auth.dart';
    5
    6class AnnotateImageRepository {
    7  final auth = FirebaseAuth.instance;
    8  final functions = FirebaseFunctions.instance;
    9
    10  Future<String?> textDetection(Uint8List data) async {
    11    // ユーザー認証がされていない場合、匿名認証を行う
    12    if (auth.currentUser == null) {
    13      await auth.signInAnonymously();
    14    }
    15
    16    // 画像データをテキスト検出する
    17    final result = await functions.httpsCallable('textDetection').call(data);
    18    return result.data;
    19  }
    20}

    このコードでは、テキスト検出をリクエストするtextDetectionという関数を実装しています。
    リクエスト時には、ユーザー認証をしておく必要があるので、ユーザー認証がまだされていない場合、匿名認証をします。
    ユーザー認証ができたら、Functionsで作成した関数(textDetection)を呼び出し、テキスト検出をリクエストします。

    次に検出した文字を表示する画面を実装します。
    今回は、読み込んだテキストを画面中央に表示する画面を作成しました。

    1import 'package:flutter/material.dart';
    2
    3class ReadResultScreen extends StatelessWidget {
    4  final String? text;
    5  const ReadResultScreen({super.key, required this.text});
    6
    7  @override
    8  Widget build(BuildContext context) {
    9    return Scaffold(
    10      appBar: AppBar(title: const Text('読み取り結果')),
    11      body: SafeArea(
    12        child: Center(
    13          child: Padding(
    14            padding: const EdgeInsets.all(24.0),
    15            child: Text(
    16              text ?? '読み込んだ文字がありません',
    17            ),
    18          ),
    19        ),
    20      ),
    21    );
    22  }
    23}

    最後にカメラ撮影をするボタンの処理を実装します。

    1          floatingActionButton: FloatingActionButton(
    2            onPressed: !(_cameraController?.value.isInitialized ?? false)
    3                ? null
    4                : () async {
    5                    setState(() {
    6                      isLoading = true;
    7                    });
    8                    final repository = AnnotateImageRepository();
    9                    // 撮影
    10                    final imageXFile = await _cameraController!.takePicture();
    11                    // プレビューを一時停止
    12                    await _cameraController!.pausePreview();
    13                    // 撮影した画像データをUint8Listに変換
    14                    final buffer = await imageXFile.readAsBytes();
    15                    // テキスト検出をリクエスト
    16                    final text = await repository.textDetection(buffer);
    17                    setState(() {
    18                      isLoading = false;
    19                    });
    20                    if (context.mounted) {
    21                      // 結果画面に遷移
    22                      await Navigator.of(context).push(
    23                        MaterialPageRoute(
    24                          builder: (context) => ReadResultScreen(text: text),
    25                        ),
    26                      );
    27                      // プレビューを再開
    28                      await _cameraController!.resumePreview();
    29                    }
    30                  },
    31            child: const Icon(Icons.camera_alt),
    32          ),

    このコードでは、画像データのテキスト検出をリクエストし、成功時に検出結果を渡して結果画面に遷移をしています。
    まず_cameraController!.takePicture()でカメラ撮影をします。
    次に撮影した画像をreadAsBytes()でUint8Listのデータに変換し、repository.textDetectionでテキスト検出をリクエストします。
    リクエストが成功したら、Navigatorのpushで検出結果を渡して結果画面に遷移します。

    動作確認

    以下のコマンドを実行し、Functionsの関数をデプロイします。

    1~/ocr-app/functions$ firebase deploy --only functions

    デプロイが完了したら、アプリを起動して確認します。

    おわりに

    今回、Cloud Vision APIを使ってOCRアプリを作ってみましたが、実装していく中でカメラプレビューのレイアウト調整が難しいなと感じました。
    Flutterでは、ネイティブの画面を表示することもできるらしいので、ネイティブでカメラ画面を実装してみてFlutterに表示することにもチャレンジしてみたいです。

    また、テキスト検出のレスポンスは、どう扱うかが大変そうだなと思いました。
    レスポンスには、ページ、ブロック、段落、単語、改行の情報が含まれるのですが、どこでブロックが分かれるかなどわからないので、欲しい情報をどう取り出すかが難しそうだなと思いました。
    Vision APIはこちらで簡単に試すことができるので、気になる方は試してみてください。

    いまむー(エンジニア)

    いまむー(エンジニア)

    おすすめ記事