Flutterで日本語のOCR(テキスト認識)を実現する方法(Cloud Vision APIの利用)

Flutter

Flutterで、カメラやスマホ内の画像から日本語でテキスト抽出をする機能を作りたいと思い、試行錯誤の上、やっと実現できたので、その内容を共有したいと思います。

 

と言っても、私が考えたものではなく、「Cloud Vision APIをCloud Functions経由で呼び出す」という方法を紹介されている、こちらの記事(以下「参考記事」とします)を全面的に参考にさせていただきました。ありがとうございます!

 

 

ただ、自分の理解力が乏しく、色々な所でつまづいたので、その過程を共有して、参考記事の補足になればと思います。

 

コード全体をご覧になりたい方は、こちらからどうぞ。

 


40代からプログラミング(Flutter)を始めて、GooglePlayAppStoreに初アプリを公開したhalzo appdevです。

 

作成したアプリはこちら↓

シンプルメモ帳「BasicMemo」 – 文字数カウント、ワンタッチ入力、タグ管理等の便利機能を搭載

Google Play で手に入れよう
Download on the App Store

 

スポンサーリンク

FlutterでOCRアプリを作る方法は?

最初、オフラインかつ無料でOCRを利用できるパッケージ「mlkit」を試しました。

 

 

これは、Firebaseの「Machine Learning」機能の1つ「ML Kit」を利用できるパッケージです。

 

以下の記事を参考にさせていただき、何とか実装できたのですが、日本語が認識できず(ラテン語しか対応していない)、結局、この方法は諦めました。

 

 

その後、「firebase_ml_vision」というパッケージを使って、日本語OCRを実現している記事が複数見つかりました。

 

これもFirebaseの「Machine Learning」機能ですが、有料かつクラウド上で処理するものになります。

 

早速参考にしようと思ったのですが、Googleからの説明によると、最近(2021年5月頃?)、このパッケージの更新は中止になり、後継の「google_ml_kit」に移行したとのことでした。

 

 

しかし、現時点(2021年7月)時点では、「google_ml_kit」を用いた日本語OCRのサンプルコードを見つけられませんでした。

 

そこで、「firebase_ml_vision」の使い方を説明されている下記記事や、

 

 

「google_ml_kit」のページに掲載されているサンプルコードを組み合わせて構築してみました。

 

 

結果、「google_ml_kit」を使ってOCRはできたのですが、言語を指定する方法が分からず、結局、日本語での認識ができませんでした。

 

最終的に、今回の「Cloud Vision APIをCloud Functions経由で呼び出す」という方法を紹介されている下記記事にたどり着きました。

 

 

以降、試行錯誤した点を含め、この方法の実装手順を説明していきます。

  

実装の大まかな手順

大きな実装の流れは以下になります。

 

  1. Flutterプロジェクトを作成し、image_pickerパッケージを導入して、カメラ・画像の読み込み部分を作る
  2. Firebase上にプロジェクトをつくり(有料版に変更)、Flutterプロジェクトと連携させる
  3. Cloud APIを有効化し、APIキーを制限する
  4. Firebase CLIをインストールし、コマンドラインでFirebaseにログイン・アクセスできるようにする
  5. Cloud Vision APIを呼び出すCloud Functionsのサンプルコードを取得する
  6. 当該サンプルコードを自分のFirebaseプロジェクトにデプロイ(配置)する
  7. FlutterプロジェクトからデプロイしたCloud Functionsを呼び出す部分を作る

 

Cloud Functionsとは?

その名の通り、「クラウド上に関数(イベント)を設置できる機能」ということです。

 

以下の記事で分かりやすく説明されています。

 

 

前述の「firebase_ml_vision」の中止により、Flutterから直接的に「Cloud Vision API」を呼べなくなりました。

 

しかし、Cloud Functionsの機能を使うことで、Flutterからクラウド上に配置した関数を呼び、その関数から「Cloud Vision API」を呼ぶことで、OCRをできるようにするのが今回の方法です(理解が違っていたらご指摘くださいw)。

 

具体的な実装内容

image_pickerを導入し、カメラ・画像の読み込み部分を作成

カメラもしくはフォルダ内から画像を読み込むのに便利なパッケージ「image_picker」を導入します。

 

 

dependencies:
  image_picker: ^0.8.2

 

上記の通り、pubspec.yamlの「dependencies:」に追記して、「pub get」します。

 

なお、本記事の主題とはズレますが、実装にあたりいくつか注意点がありました。

 

1つ目は、null safety対応に気をつける点です。下記に掲載したコード内にコメントで説明をつけました。

 

2つ目は、Androidの「targetSdkVersion」が30以上の場合は、「AndroidManifest.xml」に以下を追記する必要がある点です。

 

<queries>
        <intent>
            <action android:name="android.media.action.IMAGE_CAPTURE" />
        </intent>
</queries>

 

自分の場合、これを追記しないと下記エラーになりました。パッケージサイトのReadmeにもこの点の説明はありませんでした。

 

E/flutter ( 9360): [ERROR:flutter/lib/ui/ui_dart_state.cc(199)] Unhandled Exception: 
PlatformException(no_available_camera, No cameras available for taking pictures., null, null)

 

参考にしたのは以下の記事です。

 

 

Firebaseを有料版にアップグレードし、Flutterプロジェクトと連携

まず、Firebaseの「Authentication」と「Functions」の機能を使用するため、Flutterプロジェクトに以下のパッケージを導入しておきます(pubspec.yamlの「dependencies:」に追記して、「pub get」する)。

 

dependencies:
  firebase_core: ^1.3.0
  firebase_auth: ^2.0.0
  cloud_functions: ^2.0.0

 

次に、「Cloud Vision API」を使うには、Firebaseにプロジェクトを作成し、有料プランにアップグレードする必要があります。

 

 

ただ、有料プランであっても、「Cloud Vision API」への月1,000回までのアクセスは無料です(2021年7月現在)。

 

 

Firebaseプロジェクトの作成方法、FirebaseプロジェクトとFlutterプロジェクトと連携方法については、他に分かりやすいサイトが多くあるので、ここでは割愛します。

 

<Google公式>

 

<分かりやすい解説サイト>

 

また、Firebaseの機能を使うため、ユーザーはFirebaseにログインする必要がありますが、今回はテストアプリのため、個別のメールアドレスやGoogleアカウントでログインしなくて済むよう、「匿名」でのログインを許可します(ユーザー管理が必要なアプリを作る場合は、「匿名」以外の方法で許可設定をする必要があります)。

 

具体的には、「Authentication」→「Sign-in Method」で、一番下の「匿名」を「有効」にします。

 

 

Cloud APIsを有効化し、APIキーを制限する

Firebaseの「Machine Learning」から「Cloud ベースのAPIを有効化」にチェックを入れます。

 

 

次に、セキュリティを強化するため、使用しないAPIキーに誤ってアクセスされないよう、制限をかけます。

 

具体的には、有効化ボタン左の「Cloud APIの使用状況」から、Google Cloud Platform(GCP)のコンソールに入ります。

 

 

画面左の「認証情報」を押し、以下の画面の流れで「Cloud Vision API」以外のAPIにチェックを入れ、制限をかけます。

 

iOS、Android、Web(Browser)で使用する場合には、3種類のAPIキー全てに同様の制限をかけます。

 

 

 

以下のように緑のチェックマークが入れば、制限設定完了です。

 

 

Node.js、Firebase CLIをインストールし、コマンドラインでFirebaseにログイン

Node.jsは、後で「npm」コマンドでパッケージをインストールする際に必要になります。

 

ターミナルで「npm -v」と打って、バージョン情報が表示されれば使える状態なので、このインストールは飛ばして大丈夫です。

 

自分は入っていなかったので、Node.jsをインストールしました。

 

インストールの方法はいくつかありますが、以下のサイトからインストーラーをダウンロードする方法が簡単でした。画面上の緑のボタンからインストーラーをダウンロードして指示に従うだけです。

 

 

自分は左の推奨版にしました。インストール後、「npm -v」でバージョン情報が表示されれば、インストール完了です。

 

次に、Cloud Functionsを使う際、ターミナルからコマンドラインでFirebaseを操作する必要があるため、「Firebase CLI」というツールをインストールします。

 

CLIのインストール方法は、下記のFirebaseチュートリアルで説明されています。

 

 

自動インストールスクリプト、スタンドアロンバイナリ、npmの3通りの方法が書かれています。

 

Node.jsの最新版を入れなかったせいか、npmでやったらエラーになってしまったので、自動インストール スクリプト(下記コマンド)でインストールしました。

 

curl -sL https://firebase.tools | bash

 

 

インストールしたら、ターミナルから

 

firebase login

 

でログインを試します。

 

「Allow Firebase to collect CLI usage and error reporting information?」と聞かれるので、「Yes」を入力すると、Webページが開いて、Googleアカウントのログイン画面になります。

 

Firebaseに使っているGoogleアカウントを選択し、「許可」を押します。

 

   

Webページとターミナルに、Firebaseログイン成功と表示されます。

 

 

なお、ログアウトは簡単で、

 

firebase logout

 

とするだけです。

 

Cloud Functionsのサンプルを取得

「Cloud Vision API」を呼び出すFunction(関数)のサンプルコードが、以下に用意されています。

 

 

このページは、Firebaseのページの「ドキュメント」→「サンプル」→「Cloud Functionsのクイックスタート」からたどれます。 

 

 

これをターミナルから「git clone」で自分のPC内にクローンします(Gitの導入は終わっている前提ですが、未導入の場合は「Git」でググると簡単にインストール方法が分かると思います)。

 

git clone https://github.com/firebase/functions-samples

 

クローンしてくるPC内の場所は、Flutterのプロジェクトフォルダとは関係ない場所で大丈夫です。

  

クローンするとこんな感じのフォルダ一式ができると思います。

 

  

次に、「Cloud Vision API」を呼び出すサンプルコードがある「vision-annotate-images」→「functions」フォルダに移り、パッケージをインポートします。

 

ここでNode.jsのインストールで使えるようにした「npm」コマンドを使います。

 

npm install

 

以下のような画面になれば、インストール完了です。Finderで、functionsフォルダ内にパッケージのファイルが保存されたことを確認できると思います。

 

 

Firebaseプロジェクトをイニシャライズする

firebase init

 

を実行します。

 

当初、どの場所で実行するのか分からず、悩みましたが、「vision-annotate-images」(「functions」のひとつ上)のフォルダでやります。

 

Firebaseの中で利用するサービスを聞かれるので、「Functions」を選択し、連携させるFirebaseプロジェクトについては、「Use an existing project」を選び、自分の作成したFirebase上のプロジェクト名を選択します。

 

Functionsで用いる言語はどちらでも良いようですが、自分はTypScriptを選びました。

 

 

最大の注意点は、「既にファイルがあるけど、Overwriteしてもいいか?」という質問を「N」にすることです。自分はこれが分からず、相当ハマりました。。

 

 

当初、Overwriteを「Y」にしてしまった結果、「Cloud Vision API」を呼び出すコードが書かれている「index.ts」が、中身のないファイル(イチから作成する前提の状態)に上書きされてしまい、次項のデプロイでエラーになってしまいました。

 

 

後で気づいて、サンプルコードのcloneからやり直すことになりましたw

  

なお、最後の「Do you want to install dependencies with npm now?」は、「npm install」済なので、「N」にします。

 

以下のような画面になれば、無事、イニシャライズ成功です。

 

 

FirebaseプロジェクトにFunctionをデプロイ

firebase deploy --only functions:annotateImage

 

でFirebaseプロジェクトに、Functionのコードをデプロイします。

 

こんな感じになればデプロイ成功です。

 

 

FirebaseプロジェクトのFunctionsを見ると、デプロイしたFunctionが登録されているのが分かります。

 

 

Cloud Functionを呼び出す部分を作成(コード全体)

やっとFlutterに戻ってきました。

 

前段で用意したimage_pickerでカメラ・画像読み込みをするコードに、Cloud FunctionsにデプロイしたFunctionを呼び出すコードを追記していきます。

 

前述のとおり、全面的にこちらの参考記事を参考にさせていただきつつ、一部エラーになる部分の修正、null safety対応、ギャラリーからの画像取得などを追記して作成しました。

 

以下に、コード全体を掲載します。

 

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'dart:convert';
import 'package:cloud_functions/cloud_functions.dart';

void main() {

  // main関数内での非同期処理(下のFirebase.initializeApp)を可能にする処理
  WidgetsFlutterBinding.ensureInitialized();

  runApp(App());
}

class App extends StatefulWidget {
  @override
  _AppState createState() => _AppState();
}

// アプリ画面を描画する前に、Firebaseの初期化処理を実行
class _AppState extends State<App> {

  Future<FirebaseApp> _initialize() async {
    return Firebase.initializeApp();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(

      future: _initialize(),
      builder: (context, snapshot) {
        if (snapshot.hasError) {

          // MaterialAppの前なので、 textDirection: TextDirection.ltr
          // がないと、文字の方向がわからないというエラーになる
          return Center(
              child: Text(
            '読み込みエラー',
            textDirection: TextDirection.ltr,
          ));
        }

        if (snapshot.connectionState == ConnectionState.done) {
          return MyApp();
        }

        // 上記と同様。 textDirection: TextDirection.ltr が必要
        return Center(
            child: Text(
          '読み込み中...',
          textDirection: TextDirection.ltr,
        ));
      },
    );
  }
}

// OCRアプリ画面の描画
class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '日本語OCR',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: '日本語OCR'),
    );
  }
}

class MyHomePage extends StatefulWidget {

  // null safety対応のため、Keyに?をつけ、titleは初期値""を設定
  MyHomePage({Key? key, this.title = ""}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {

  // null safety対応のため、?でnull許容
  File? _image;

  final _picker = ImagePicker();

  // null safety対応のため、?でnull許容
  String? _result;

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

  // 匿名でのFirebaseログイン処理
  void _signIn() async {
    await FirebaseAuth.instance.signInAnonymously();
  }

  Future _getImage(FileMode fileMode) async {

    // null safety対応のため、lateで宣言
    late final _pickedFile;

    // image_pickerの機能で、カメラからとギャラリーからの2通りの画像取得(パスの取得)を設定
    if (fileMode == FileMode.CAMERA) {
      _pickedFile = await _picker.getImage(source: ImageSource.camera);
    } else {
      _pickedFile = await _picker.getImage(source: ImageSource.gallery);
    }

    setState(() {
      if (_pickedFile != null) {
        _image = File(_pickedFile.path);
      } else {
        print('No image selected.');
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('日本語OCR'),
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(16.0),

          // 写真のサイズによって画面はみ出しエラーが生じるのを防ぐため、
          // Columnの上にもSingleChildScrollViewをつける
          child: SingleChildScrollView(
            child: Column(children: [

              // 画像を取得できたら表示
              // null safety対応のため_image!とする(_imageはnullにならない)
              if (_image != null) Image.file(_image!, height: 400),

              // 画像を取得できたら解析ボタンを表示
              if (_image != null) _analysisButton(),
              Container(
                  height: 240,

                  // OCR(テキスト検索)の結果をスクロール表示できるようにするため
                  // 結果表示部分をSingleChildScrollViewでラップ
                  child: SingleChildScrollView(
                      scrollDirection: Axis.vertical,
                      child: Text((() {

                        // OCR(テキスト認識)の結果(_result)を取得したら表示
                        if (_result != null) {

                          // null safety対応のため_result!とする(_resultはnullにならない)
                          return _result!;
                        } else if (_image != null) {
                          return 'ボタンを押すと解析が始まります';
                        } else {
                          return 'OCR(テキスト認識)したい画像を撮影または読込んでください';
                        }
                      }())))),
            ]),
          ),
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [

          // カメラ起動ボタン
          FloatingActionButton(
            onPressed: () => _getImage(FileMode.CAMERA),
            tooltip: 'Pick Image from camera',
            child: Icon(Icons.camera_alt),
          ),

          // ギャラリー(ファイル)検索起動ボタン
          FloatingActionButton(
            onPressed: () => _getImage(FileMode.GALLERY),
            tooltip: 'Pick Image from gallery',
            child: Icon(Icons.folder_open),
          ),
        ],
      ),
    );
  }

  // OCR(テキスト認識)開始処理
  Widget _analysisButton() {

    return ElevatedButton(
      child: Text('解析'),
      onPressed: () async {

        // null safety対応のため_image!とする(_imageはnullにならない)
        List<int> _imageBytes = _image!.readAsBytesSync();
        String _base64Image = base64Encode(_imageBytes);

        // Firebase上にデプロイしたFunctionを呼び出す処理
        HttpsCallable _callable =
            FirebaseFunctions.instance.httpsCallable('annotateImage');
        final params = '''{
          "image": {"content": "$_base64Image"},
          "features": [{"type": "TEXT_DETECTION"}],
          "imageContext": {
            "languageHints": ["ja"]
          }
        }''';

        final _text = await _callable(params).then((v) {
          return v.data[0]["fullTextAnnotation"]["text"];
        }).catchError((e) {
          print('ERROR: $e');
          return '読み取りエラーです';
        });

        // OCR(テキスト認識)の結果を更新
        setState(() {
          _result = _text;
        });
      },
    );
  }
}

// カメラ経由かギャラリー(ファイル)経由かを示すフラグ
enum FileMode{
  CAMERA,
  GALLERY,
}

 

「”imageContext”」のところで、「”languageHints”: [“ja”]」と日本語での読み取りを指定していることが分かります。

 

OCR(テキスト認識)を実行

実行すると、無事、日本語で読み取りされることを確認できました。

  

撮影画像「スッキリわかるJava入門 第3版」中山清喬 (著), 国本大悟 (著), 株式会社フレアリンク (監修)

 

横向きでも正しく認識されており、ページの上端から下端までOCRされています。

 

手書き文字を読み込んだときは、若干認識違いがありましたが、上記のように活字であれば、かなり精度高く認識されます。

 

以上、参考記事を拝見したお陰で、何とかFlutterで日本語OCRアプリを試作することができました。

 

最後までお読みいただき、ありがとうございました。

 

\ Flutterの学習で役立ったコンテンツ・書籍 /

Udemy 【ゼロからのFlutter】iOS/Androidアプリを”いっぺんに”開発入門・初級編<みんプロ式>

 

 


Dart入門 – Dartの要点をつかむためのクイックツアー

コメント

タイトルとURLをコピーしました