スクリーンショット_2018-08-06_14

FlutterでFirebase ML を使った、文字起こしをやってみる

今回はFirebase ML の文字起こしをFlutterで実現してみます。

この記事は下記の記事を参考にチュートリアルを作成したものです。

完成品

完成品はこちら

前提

前提としてFlutterの開発環境を作成しておきましょう。

上の記事は英語ですが、頑張って読んでいただいて環境構築していただけたらと思います。

サーバーサイドの開発環境を作るより圧倒的に楽です。

プロジェクトを作成


プロジェクトを作成したら、色々コメントが書かれたプログラムが書かれていますが無駄なので今回は消してしまいましょう。

画面の真ん中に「Hello World」って表示します。

import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
     title: 'Flutter Demo',
     theme: new ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: new MyHomePage(),
   );
 }
}
class MyHomePage extends StatefulWidget {
 @override
 _MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text("ML Kit Sample"),
     ),
     body: new Center(
       child: Text("Hello World"),
     ),
   );
 }
}


Firebaseの方の準備

上記のサイトからFirebaseのプロジェクトを作成しましょう。

今回はiOSで作成しますので、Androidでやりたい方はこの辺を参考にFirebaseとアプリを紐づけてください。

iOSで作成を選択すると下記の画面が表示され手順通り追加していきましょう。

Bundle IDはXcodeでFlutterプロジェクトのiosフォルダの中にあるRunner.xcodeprojを開いて赤枠で囲ってある箇所をコピペしたら完了です。

アプリニックネームはお好きな文字、App Store ID は空白で構いません。

できたら「アプリ登録」をクリックします。

次に「GoogleService-Info.plist」を下記の位置に設置し、残りステップは全てスキップで構いません。

プラグインの導入

Firebase ML Kit のプラグインと今回はカメラも取り扱うので、image_image_pickerとpath_providerも使います。

pubspec.ymlを編集します。

name: flutter_ml_ki_sample
description: A new Flutter application.

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2
  path_provider: 0.3.0
  mlkit: ^0.2.2
  image_picker: ^0.4.4

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:

  uses-material-design: true

(デフォルトで書かれている不要なコメントは削除しています。)

アプリが実行状態の場合は一度実行をストップしてから、Packages getをクリックしましょう。

これで何もエラーがでずに再実行できたら成功です。

カメラで撮影する処理を追加する

ここまでできたら一気にいきます。

レイアウトとカメラを起動する処理を書いていきます

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.red,
      ),
      home: new MyHomePage(),
    );
  }
}

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

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: AppBar(
        title: Text("文字読み取り"),
      ),
      body: SingleChildScrollView(
        child: Column(
          children: <Widget>[
            _startCamera(),
          ],
        ),
      ),
    );
  }

  Widget _startCamera() {
    return Container(
      margin: EdgeInsets.only(top: 10.0),
      child: Row(
        children: <Widget>[
          Expanded(
            child: Padding(
                padding: EdgeInsets.symmetric(horizontal: 8.0),
                child: RaisedButton(
                  color: Colors.blue,
                  textColor: Colors.white,
                  splashColor: Colors.blueGrey,
                  onPressed: () {
                    _onPickImageSelected();
                  },
                  child: Text("start camera"),
                )),
          ),
        ],
      ),
    );
  }

  void _onPickImageSelected() async {

    var imageSource = ImageSource.camera;

    try {
      final file =
      await ImagePicker.pickImage(source: imageSource);
      if (file == null) {
        throw Exception('ファイルを取得できませんでした');
      }

    } catch(e) {
    }
  }

}

これだけではカメラはパーミッションがないため開きません。

なのでimage_pickerに記載されている手順でパーミッション許可の処理を書きましょう。

2番目の記載の info.plistに「NSCameraUsageDescription」を追加し、説明文を書きましょう。

またここからの実装はシュミレータではできないので、実機にインストールして行ってください。

Androidの方は設定がいらないようです。

下記の操作できたら成功です。

写真撮影後の文字起こし

次に文字起こし機能を実装します。

「DetailWidget.dart」を作成しましょう。

import 'dart:io';

import 'package:flutter/material.dart';

class DetailWidget extends StatefulWidget {

  DetailWidget(this._file);
  File _file;

  @override
  _DetailWidgetState createState() => new _DetailWidgetState();
}

class _DetailWidgetState extends State<DetailWidget> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: AppBar(
        title: Text("文字読み取り"),
      ),
      body: Center(
        child: Text("Detail page"),
      )
    );
  }
}

そしてmain.dartで写真撮影後に画面遷移する処理を追加します。
_onPickImageSelectedを下記のように編集しましょう。

importで先ほどで作った「DetailWidget」をimportすることも忘れずに

import 'package:flutter_ml_ki_sample/DetailWidget.dart';

void _onPickImageSelected() async {
    var imageSource = ImageSource.camera;

    try {
      final file = await ImagePicker.pickImage(source: imageSource);
      if (file == null) {
        throw Exception('ファイルを取得できませんでした');
      }

      Navigator.push(
          context, MaterialPageRoute(builder: (context) => DetailWidget(file)));
    } catch (e) {
    }
  }

写真撮影後、DetailWidgetに画面遷移できたら成功です。

ここから実際に撮影後、Firebase ML に文字を解析させ画面に表示します。

ここは全コードを載せます。

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:mlkit/mlkit.dart';

class DetailWidget extends StatefulWidget {
  DetailWidget(this._file);

  final File _file;

  @override
  _DetailWidgetState createState() => new _DetailWidgetState();
}

class _DetailWidgetState extends State<DetailWidget> {
  FirebaseVisionTextDetector _detector = FirebaseVisionTextDetector.instance;
  List<VisionText> _currentTextLabels = <VisionText>[];

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

    Timer(Duration(microseconds: 1000), () {
      this._analyzeLabels();
    });
  }

  _analyzeLabels() async {
    try {
      var currentTextLabels = await _detector.detectFromPath(widget._file.path);
      setState(() {
        _currentTextLabels = currentTextLabels;
      });
    } catch (e) {
      print(e.toString());
    }
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: AppBar(
          title: Text("文字読み取り"),
        ),
        body: Column(
          children: <Widget>[
            _settingImage(),
            _buildTextList(_currentTextLabels)
          ],
        ));
  }

  _settingImage() {
    return Expanded(
      flex: 2,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.black,
        ),
        child: Center(
          child: widget._file == null
              ? Text('No Image')
              : FutureBuilder(
                  future: _getImageSize(
                    Image.file(
                      widget._file,
                      fit: BoxFit.fitWidth,
                    ),
                  ),
                  builder: (context, snapshot) {
                    if (snapshot.hasData) {
                      return Container(
                        foregroundDecoration: TextDetectDecoration(
                            _currentTextLabels, snapshot.data),
                        child: Image.file(widget._file, fit: BoxFit.fitWidth,),
                      );
                    } else {
                      return CircularProgressIndicator();
                    }
                  },
                ),
        ),
      ),
    );
  }

  Future<Size> _getImageSize(Image image) {
    Completer<Size> completer = Completer<Size>();
    image.image.resolve(ImageConfiguration()).addListener(
        (ImageInfo info, bool _) => completer.complete(
            Size(info.image.width.toDouble(), info.image.height.toDouble())));
    return completer.future;
  }

  Widget _buildTextList(List<VisionText> texts) {
    if (texts.length == 0) {
      return Expanded(
          flex: 1,
          child: Center(
            child: Text('No text detected',
                style: Theme.of(context).textTheme.subhead),
          ));
    }

    return Expanded(
      flex: 1,
      child: Container(
        child: ListView.builder(
            padding: const EdgeInsets.all(1.0),
            itemCount: texts.length,
            itemBuilder: (context, i) {
              return _buildTextRow(texts[i].text);
            }),
      ),
    );
  }

  Widget _buildTextRow(text) {
    return ListTile(
      title: Text(
        "$text",
      ),
      dense: true,
    );
  }
}

class TextDetectDecoration extends Decoration {
  final Size _originalImageSize;
  final List<VisionText> _texts;

  TextDetectDecoration(List<VisionText> texts, Size originalImageSize)
      : _texts = texts,
        _originalImageSize = originalImageSize;

  @override
  BoxPainter createBoxPainter([VoidCallback onChanged]) {
    return _TextDetectPainter(_texts, _originalImageSize);
  }
}

class _TextDetectPainter extends BoxPainter {
  final List<VisionText> _texts;
  final Size _originalImageSize;

  _TextDetectPainter(texts, originalImageSize)
      : _texts = texts,
        _originalImageSize = originalImageSize;

  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    final paint = Paint()
      ..strokeWidth = 2.0
      ..color = Colors.red
      ..style = PaintingStyle.stroke;

    final _heightRatio = _originalImageSize.height / configuration.size.height;
    final _widthRatio = _originalImageSize.width / configuration.size.width;
    for (var text in _texts) {
      final _rect = Rect.fromLTRB(
          offset.dx + text.rect.left / _widthRatio,
          offset.dy + text.rect.top / _heightRatio,
          offset.dx + text.rect.right / _widthRatio,
          offset.dy + text.rect.bottom / _heightRatio);
      canvas.drawRect(_rect, paint);
    }
    canvas.restore();
  }
}

これで完成です。

ソースコードはこちら

宣伝

少しでも投げ銭いただけると次書くモチベーションが上がるのでお願いいたします。

Twitter↓ これからもFlutterについて呟くのフォローお願いします。


投げ銭はいりません。それより無料でできる拡散をしてください!! 感想をツイートしていただけることが一番嬉しいです!!