【Flutter】OCRを使って画像からテキストを読み取る

こんにちは、株式会社Pentagonでアプリを開発している小寺です。
Flutterを用いてOCR(英文のテキスト文字認識)を3つの方法で実装したので比較していきます。
【こんな人に読んで欲しい】
・OCRを実装しようとしている方
・FlutterでOCRする際に何を使うか迷っている方
【この記事を読むメリット】
・OCRの実装方法が理解できる
・実際に検証した精度がわかる

まず、検証した方法は

  1. google_mlkit_image_labeling + google_mlkit_text_recognition
  2. google_mlkit_text_recognition
  3. CloudVisionAPI

上記3点です。
OCRを行うことで画像からテキストを検出することが可能になります。
【結論】
結論からすると、CloudVisionAPIが最も精度が高く多少角度やブレがあったとしても英文のテキスト検出が可能でした。
CloudVisionAPIは設定が少し難しいこともあります、かなり精度が高いのでOCRを実装するならCloudVisionAPIがおすすめです。

目次

導入したパッケージ

CloudVisionAPIを利用するにあたって画像を撮影したり、ギャラリーから画像を読み込むためにimage_pickerを追加しています。その他にはCloud Functionsの機能を必要とするためfirebaseのパッケージも利用しています。

firebase_core: ^2.3.0
firebase_auth: ^4.1.3
image_picker: ^0.8.4+2
cloud_functions: ^4.0.5

上記4点をpubspec.yaml内に記載した後、Terminalでflutter pub getを実行します。

<key>NSPhotoLibraryUsageDescription</key>
<string>ライブラリにアクセスします</string>
<key>NSCameraUsageDescription</key>
<string>カメラにアクセスします</string>

image_pickerを使用する際の注意点としてRunner > info.plistへ上記の追加を行なってください。
端末のカメラとギャラリーを利用しますという説明をしています。
上記を設定しないと使用する許可がないので注意してください。

pubspec.yamlの変更

dart: ">=2.18.2 <3.0.0"

dartのversionを上記に変更しました。

全体のコード

import 'dart:convert';
import 'dart:io';

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  runApp(App());
}

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

// Firebaseの初期化処理を実行
class _AppState extends State<App> {
  Future<FirebaseApp> _initialize() async {
    return await Firebase.initializeApp();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initialize(),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(
              child: const Text(
            '読み込みエラー',
            //textDirection: TextDirection.ltr がないとエラーになります
            textDirection: TextDirection.ltr,
          ));
        }
        if (snapshot.connectionState == ConnectionState.done) {
          return MyApp();
        }

        return const Center(
            child: Text(
          '読み込み中...',
          //textDirection: TextDirection.ltr がないとエラーになります
          textDirection: TextDirection.ltr,
        ));
      },
    );
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);

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

class _MyHomePageState extends State<MyHomePage> {
  File? _image;
  final _picker = ImagePicker();
  String? _result;
  @override
  void initState() {
    super.initState();
    _signIn();
  }

  void _signIn() async {
    await FirebaseAuth.instance.signInAnonymously();
  }

  Future _getImage(FileMode fileMode) async {
    late final _pickedFile;
    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 {
        null;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('OCR'),
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(16.0),
          child: SingleChildScrollView(
            child: Column(children: [
              if (_image != null) Image.file(_image!, height: 400),
              if (_image != null) _analysisButton(),
              Container(
                  height: 1500,
                  child: SingleChildScrollView(
                      scrollDirection: Axis.vertical,
                      child: Text((() {
                        if (_result != null) {
                          return _result!;
                        } else if (_image != null) {
                          return 'ボタンを押すと解析が始まります';
                        } else {
                          return '画像を選択';
                        }
                      }())))),
            ]),
          ),
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          FloatingActionButton(
            onPressed: () => _getImage(FileMode.CAMERA),
            child: Icon(Icons.camera_alt),
          ),
          FloatingActionButton(
            onPressed: () => _getImage(FileMode.GALLERY),
            child: Icon(Icons.folder_open),
          ),
        ],
      ),
    );
  }

  Widget _analysisButton() {
    return ElevatedButton(
      child: const Text('解析'),
      onPressed: () async {
        List<int> _imageBytes = _image!.readAsBytesSync();
        String _base64Image = base64Encode(_imageBytes);

        HttpsCallable _callable = FirebaseFunctions.instance.httpsCallable(
            'annotateImage',
            options:
                HttpsCallableOptions(timeout: const Duration(seconds: 300)));
        final params = {
          "image": {"content": "$_base64Image"},
          "features": [
            {"type": "TEXT_DETECTION"}
          ],
          "imageContext": {
            "languageHints": ["en"]
          }
        };
        final _text = await _callable(params).then((v) {
          return v.data[0]["fullTextAnnotation"]["text"];
        }).catchError((e) {
          return '読み取りエラーです';
        });
        setState(() {
          _result = _text;
        });
      },
    );
  }
}

enum FileMode {
  CAMERA,
  GALLERY,
}

Firebaseの設定

Firebaseの登録

次に行ったことはFirebaseの設定です。
CloudVisionAPIを利用するにはFirebaseを使用するためまずはFirebaseの登録をします。
まだ登録していない方は下記から登録してください。
Firebaseの登録
今回はOCRの作成なのでfirebaseの登録などの説明は省略させていただきます。
CloudVisionAPIを利用するにはfirebaseを有料プランにアップグレードする必要があります。Blazeという従量制のプランになっていれば成功です。有料といっても月に1000回までのアクセスだと無料です。

Authentication

次にfirebaseの機能を使用するために匿名でのログインを許可します。
方法としてはAuthentication > Sign-in method > 匿名を有効にします。

上記の画像のようになれば成功です。

Machine Learning

CloudVisionを使用するためにcloud APIsを有効化します。
Machine Learning > APIs > CloudベースのAPIを有効化を選択してください。
こちらはfirebaseが無料プランではできないので注意してください。

上記の画像の「有効にする」をクリックすると完了です。

Firebase CLI ツール

firebaseをterminal上で操作するためにFirebase CLIツールをインストールします。

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

terminal上で上記を実行した後に「firebase login」をterminalで実行しログイン成功と表示されると完了です。

CloudVisionAPIのサンプル取得

「Cloud Vision API」を呼び出すFunctionのサンプルコードを取得します。
Firebase

上記のfirebaseの公式ページからドキュメント > サンプル > CloudFunctionsのクイックスタートを押すとgit hubのページに遷移するのでgit cloneでサンプルを取得しましょう。以下のコードをterminalで実行してください。

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

サンプルコードが取得できたらサンプルコード内にある「vidsion-annotate-images」> 「functions」フォルダに移動して「npm install」を実行します。
その後1つ上の階層である「vision-annotate-image」フォルダに移動して「firebase init」を実行してください。
実行するといくつか質問がされるので回答してください。
まず初めにFirebaseの利用するサービスを尋ねられるので「Functions」を選択してください。
その後firebaseのプロジェクトについて質問されるので「Use an existing project」を選び自分の作成したアプリ名を選択してください。
次に「Would you like to initialize a new codebase, or overwrite an existing one?」とあるのでOverwriteを選択します。
「What language would you like to use to write Cloud Functions?」と作成時に使用したい言語を聞かれるので「TypeScript」を選びます。
その後いくつか質問があると思いますが全てNoで回答してください。

index.tsの変更

「vision-annotate-image」 > 「functions」 > 「src」の中にあるindex.tsを以下のように変更しました。
こちらを変更する前はエラーで動きませんでした。

import * as functions from "firebase-functions";
import vision from "@google-cloud/vision";

const client = new vision.ImageAnnotatorClient();

// This will allow only requests with an auth token to access the Vision
// API, including anonymous ones.
// It is highly recommended to limit access only to signed-in users. This may
// be done by adding the following condition to the if statement:
//    || context.auth.token?.firebase?.sign_in_provider === 'anonymous'
// 
// For more fine-grained control, you may add additional failure checks, ie:
//    || context.auth.token?.firebase?.email_verified === false
// Also see: https://firebase.google.com/docs/auth/admin/custom-claims
export const annotateImage = functions.https.onCall(async (data, context) => {
  console.log('annotateImage start');
  if (!context.auth) {
    throw new functions.https.HttpsError(
      "unauthenticated",
      "annotateImage must be called while authenticated."
    );
  }
  console.log('authenticated');
  try {
    return await client.annotateImage(JSON.parse(JSON.stringify(data)));
  } catch (e) {
    // throw new functions.https.HttpsError("internal", e.message, e.details);
    if (e instanceof Error) {
      // Error型であることを確認したらエラーメッセージを投げる。e.detailsはエラーになるので削除
      console.log('internal error');
      console.log(e.message);
      throw new functions.https.HttpsError("internal", e.message);
    } else {
      // Error型でなかった場合も何らかthrowを書かないとエラーになる
      console.log('other error');
      throw console.log("other issues");
    }
  // }
});

Firebaseプロジェクトにdeployする

firebase deploy --only functions:annotateImage

上記をterminalで実行してFirebaseにFunctionをデプロイします。
デプロイが成功するとfirebaseプロジェクトのFunctionsに登録されているのが確認できます。

参考にしたもの

https://halzoblog.com/flutter-ocr-cloud-vision-api/
上記のリンクを参考にしました。
そのまま使用するとエラーが出て動かなかったため、一部コードを変更しております。

まとめ

OCR検証は画像の角度やブレによって多少精度は落ちますが使用した感はとても良かったという印象です。設定が少し難しいですがCloudVisionAPIは私の中で最も精度が高く使いやすいと思ったのでOCRを用いて画像からテキストを認識する際はぜひ挑戦してみてください。

おまけ

Flutterに関する知識を深めたい方には、『Flutterの特徴・メリット・デメリットを徹底解説』という記事がおすすめです。

この記事では、Flutter アプリ開発の基本から、Flutter とは何か、そして実際のFlutter アプリ 事例を通じて、その将来性やメリット、デメリットまで詳しく解説しています。
Flutterを使ったアプリ開発に興味がある方、またはその潜在的な可能性を理解したい方にとって、必見の内容となっています。

ぜひ一度ご覧ください。

採用情報はこちら
目次