9
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

素人が2週間でAndroidとIOSで動くOCRアプリ作った話 [flutter編]

Last updated at Posted at 2021-05-06

私は入社時にお勤めている会社の自由な開発雰囲気と支援の中でクロスプラットフォームのアプリを作りました。販売しているわけではないので、会社の許可を得た記載しました。何も知らない状態から役2週間(週末除いたら十日程度)で、名刺などの印刷物の写真を撮って、スマホのアドレス帳に保存してくれる「OCR(光学文字認識)アプリ」を作りました。今からのその時の経験をご紹介したいと思います。

お先に

結果から見せると、このような物が作れました。当時に社内の発表会で使ったものなので少し不要な情報もありますがただの参考用で軽く見てくださいね。

# クロスプラットフォームとは? まず、クロスプラットフォームについて簡単に説明します。 スマホアプリでのクロスプラットフォームとは、AndroidやIOSなどの複数のOS(すなわちプラットフォーム)に対応するフレームワークのことです。コードの一元化が肝心な特徴となります。つまり、アプリを一つ作ったことで動くし、アプリ補修をしても一気でできます。KotlinやSwiftなどでNative開発をするときより、コストは下がります。 私はこのようなクロスプラットフォームを支援するフレームワークの中で、比較的に最近(2018)にGoogleから公開されたFlutterでAndroidとIOSで動くアプリを作りました。

Flutterの概要

区分 内容
長所  スピードがRNより早く、UIエンジンが固有のもの
短所  プラグイン数が比較的に少ない
支援  Google
言語  Dart (JAVAとJAVASCRIPTのどこか)
公式  https://flutter.dev/docs
ケース https://flutter.dev/showcase

実際の開発スケジュール

image.png

実装

1. 開発環境構築の手順

① flutter SDK インストール

② Android Studio(SDK)インストールとセットアップ

③ Flutterプラグインインストール

デバッグをしやすくするPluginを公式支援するIDEは
VSCODEとAndroid Studioのみ。軽いやつでやりましょう。

④ path追加


$Env:Path += ";C:\Dev\dev.flutter-OCR\flutter/bin"
setx path "%PATH%;"

⑤ 確認

コマンド
flutter doctor

⑥ 新規プロジェクトとEmulator起動

flutter: new project プロジェクトを作る
VSCODEではF5キーで実行コマンド

※ざっくり見ておくといい資料

プロジェクト構造とRouting

Flutterを開発したGoogleでお勧めするパターンはblocですが、自分が作ろうとするアプリはそこまで画面数や複雑でないだろうと思いデザインパターンとかファイル構造を自ら考えて作ってみました。
ちゃんとFlutterでアプリを作りたい方はデザインパターンを適用して作ってくださいね。

フォルダー構成

.
├── README.md
├── android
│   ├── app
│   │   ├── build.gradle
│   │   └── src
│   ├── build.gradle
│   ├── gradle
│   │   └── wrapper
│   ├── gradle.properties
│   ├── settings.gradle
│   └── settings_aar.gradle
├── assets
│   ├── default_ocr_icon.png
│   └── ot_launcher.png
├── flutter_module
│   ├── README.md
│   ├── lib
│   │   └── main.dart
│   ├── pubspec.lock
│   ├── pubspec.yaml
│   ├── settings.gradle
│   └── test
│       └── widget_test.dart
├── ios
│   ├── Flutter
│   │   ├── AppFrameworkInfo.plist
│   │   ├── Debug.xcconfig
│   │   └── Release.xcconfig
│   ├── Runner
│   │   ├── AppDelegate.swift
│   │   ├── Assets.xcassets
│   │   ├── Base.lproj
│   │   ├── Info.plist
│   │   └── Runner-Bridging-Header.h
│   ├── Runner.xcodeproj
│   │   ├── project.pbxproj
│   │   ├── project.xcworkspace
│   │   └── xcshareddata
│   └── Runner.xcworkspace
│       └── contents.xcworkspacedata
├── lib
│   ├── constant.config.dart
│   ├── controller
│   │   ├── file_controller.dart
│   │   ├── mlkit_controller.dart
│   │   └── ncp_controller.dart
│   ├── exporter.config.dart
│   ├── frame
│   │   ├── camera_dev1.dart
│   │   ├── camera_dev2.dart
│   │   ├── explore_dev2.dart
│   │   ├── explorer.dart
│   │   ├── explorer_dev1.dart
│   │   ├── home.dart
│   │   ├── ocr.dart
│   │   └── sub
│   ├── main.dart
│   ├── model
│   │   ├── model.dart
│   │   └── ocr_model.dart
│   ├── services
│   │   └── database.dart
│   └── theme.dart
├── local.properties
├── myapp
│   ├── README.md
│   ├── android
│   │   ├── app
│   │   ├── build.gradle
│   │   ├── gradle
│   │   ├── gradle.properties
│   │   └── settings.gradle
│   ├── ios
│   │   ├── Flutter
│   │   ├── Runner
│   │   ├── Runner.xcodeproj
│   │   └── Runner.xcworkspace
│   ├── lib
│   │   └── main.dart
│   ├── pubspec.lock
│   ├── pubspec.yaml
│   └── test
│       └── widget_test.dart
├── pubspec.lock
├── pubspec.yaml
└── test
    └── widget_test.dart

Routing

main.dart
import 'package:myapp/exporter.config.dart';

// メイントリガー関数で、引数ははWidgetを持つ
// Use arrow notation for one-line functions or methods.
void main() => runApp(MyApp());

class MyApp extends StatelessWidget{
   @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'myapp', // 必須Property
      theme: new ThemeData(
        primarySwatch: Colors.green
      ),
      home: Home(),
      routes: Router.getRouter()
    );
  } 
}

exporter.config.dartという外部クラスでRouterをいれて使っています。

exporter.config.dart
export 'package:flutter/material.dart'; // flutter基礎ライブラリ(ファイル単位)
export './frame/home.dart';
export './constant.config.dart';
export 'package:fluttertoast/fluttertoast.dart';
// import
import 'package:flutter/material.dart'; // flutter基礎ライブラリ(ファイル単位)
import './frame/camera_dev1.dart';
import './frame/editForm.dart';
import './frame/ocr.dart';

class Router {
  static getRouter(){
    return <String, WidgetBuilder> { 
        '/ocr': (BuildContext context) => Ocr(),
        '/camera_dev1': (BuildContext context) => Camera(),
        '/editForm': (BuildContext context) => EditForm(),
    };

  }
}

Ocr()とCamera()とEditForm()の3つの画面がありますね。

本記事ではアプリ開発経験だけ書くつもりでしたが、やはりコードベースで伝えたいと思うので

それらの作り方を少しだけ確認してみましょう。

2. 開発タスク

私は下記のような三つの課題を設定しました。大きく分けて3つの課題がありました。

  • 課題 1:写真を撮る画面とその機能を実装
  • 課題 2:取った写真を選択する画面とその機能を実装
  • 課題 3:選択した写真のデータを分析ツールへ渡し、返して表示する画面とその機能を実装

それぞれ確認してみましょう。

課題 1:写真を撮る画面とその機能を実装

OCRアプリなので写真を撮影する画面が第一です。

カメラ機能のスペックはいくつかありあすが、専用の撮影画面に遷移するように実装したものを紹介します。
(チュートリアルとかでよく見られるコードの処理順だと思います)

即時に撮影すかとか、Zoomしたいとか、画像領域を四角で表示させたいとかの場合はここにて機能を追加する必要があります。

camera_dev1.dart
import 'dart:async';
import 'dart:io';

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
class Camera extends StatefulWidget {
  @override
  _CameraState createState() => new _CameraState();
}

class _CameraState extends State<Camera> {
  CameraController controller;

  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

  @override
  void initState() {
    super.initState();
    availableCameras().then((cameras) {
      CameraDescription rearCamera = cameras.firstWhere(
              (desc) => desc.lensDirection == CameraLensDirection.back, orElse: () => null);
      if (rearCamera == null) {
        return;
      }

      controller = new CameraController(rearCamera, ResolutionPreset.high);
      controller.initialize().then((_) {
        if (!mounted) {
          return;
        }
        setState(() {});
      });
    });
  }

  @override
  void dispose() {
    controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {

    Widget preview;
    if (!_isReady()) {
      preview = new Container();
    } else {
      preview = new Container(
          child: new Center(
              child: new AspectRatio(
                  aspectRatio: controller.value.aspectRatio,
                  child: new CameraPreview(controller))
          )
      );
    }
    return new Scaffold(
        key: _scaffoldKey,
        appBar: AppBar(
          title: const Text("Camera"),
        ),
        body:
          Column(
            children: <Widget>[
              Expanded(
                child: Container(child: preview, color: Colors.black,),
              ),
              Padding(padding: const EdgeInsets.all(5.0)),
              IconButton(
                icon: new Icon(Icons.camera_alt),
                tooltip: 'Camera',
                onPressed: () { _onPressedCamerIcon(); }
              ),
            ]
        )
    );
  }

  bool _isReady() {
    return controller != null && controller.value.isInitialized;
  }

  _onPressedCamerIcon() {
    _takePicture().then((String filePath) {
      _showSnackBar('Picture saved to $filePath');
    }).catchError((e) {
      _showSnackBar(e);
    });
  }

  Future<String> _takePicture() async {
    if (!_isReady()) {
      throw("Camera controller is not initialized.");
    }

    final Directory extDir = await getExternalStorageDirectory();
    final String dirPath = '${extDir.path}';
    await Directory(dirPath).create(recursive: true);

    final String filePath = '$dirPath/${_timestamp()}.jpg';

    if (controller.value.isTakingPicture) {
      // A Camer is already pending, do nothing.
      throw("Camera is already pending.");
    }

    await controller.takePicture(filePath);

    return filePath;
  }

  String _timestamp() => new DateTime.now().millisecondsSinceEpoch.toString();

  void _showSnackBar(String text) {
    _scaffoldKey.currentState.showSnackBar(SnackBar(
      content: new Text(text),
    ));
  }
}

実はこの辺、Flutterのレポジトリを提供しているPub.Devからすでにライブラリーを提供しています。
https://pub.dev/packages/camera

つまり import 'package:camera/camera.dart'; ここのおかげで実装できますね。

AndroidでカメラAPIを使う時よりももっと簡単で便利です。

課題 2:取った写真を選択する画面とその機能を実装

OCR()の出番です。上記のカメラ画面を開いたり、写真を選択したりする画面です。
特にここでは撮った写真をOCR分析のためのモヂュール渡す処理をしています。
実はここで写真をOpenCVなどで前処理(Pre-Process)をして認識しやすく編集したらよりいいです。
ですが自分の場合は開発期間関連ではgoogleのSaaSであるFirebaseのMLkit(Machine-Learning kit)を使いましたので
前処理しなくても立派な認識ができました。別途で学習させる必要がありませんでした。

ちなみに選択自体は``ImagePicker```で簡単にできます

課題 3:選択した写真のデータを分析ツールへ渡し、返して表示する画面とその機能を実装

本来ならここは別のコントローラーで分けた方がよかったですが、当時はなんだかそれができませんでした。多分、Dartの使い方がなれていなくてModule化が下手だったためだと思います。ここは是非改善したいとは思っていました。

ocr.dart
  Future<Null> _textOcr(context) async {
    String result = '';
    setState(() => this._mlResult = CONSTANT_NO_RESULT);
    if (await _pickImage() == false) {
      return;
    }
    
    final FirebaseVisionImage visionImage =
        FirebaseVisionImage.fromFile(this._imageFile);
    final TextRecognizer textRecognizer =
        FirebaseVision.instance.cloudTextRecognizer();
    final VisionText visionText =
        await textRecognizer.processImage(visionImage);
    final String text = visionText.text;

    debugPrint('Recognized text: "$text"');
    for (TextBlock block in visionText.blocks) {
      String text = block.text;
      List<RecognizedLanguage> languages = block.recognizedLanguages;

      for (TextLine line in block.lines) {
        String lineText = line.text;
        result += '\n $lineText \n';  
      }
    }
    if (result.length > 0) {
      setState(() => this._mlResult = result);
    }
    textRecognizer.close();
  }

感想

FlutterってDartという新たな言語で実装されるので最初は緊張していましたが、TypeScriptやKotlinと似ていてとても使い安い言語とフレームワークでした。また、googleのFirebaseではMLkitもとても初心者にも使いやすいライブラリーでした。私もこれをきっかけで、機械学習とNativeアプリなどに興味ができました。それで次回はIonicというweb基盤のクロスプラットフォームについて紹介したいと思います。私が配属しているOrganizationと会社の技術ブログでご確認くださいね。また、この技術と話題に興味ある方は是非とも色々とコメントくださいね。4649!

9
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?