LoginSignup
14
17

More than 1 year has passed since last update.

Androidで文字認識(数字)をやってみた

Last updated at Posted at 2019-08-29

動機、目標

歩数計記録アプリを作っているが、手入力が面倒なので、歩数計の数値をカメラにかざして認識できたら楽かなーと思い、調べてみることに。

1. ML Kit

FirebaseのML Kit。
公式ドキュメントはこちら

本命。

(1) Firebaseコンソールでプロジェクトを作る

  • 下記画面で「新しいプロジェクト」をクリック

kotlin_e1_01.png

  • プロジェクト名を入力して、下記設定で[次へ]をクリック
    • 「Firebase向けアナリティクス云々」の箇所は、チェックしても、しなくてもサンプルアプリの場合は問題ないと思います。

kotlikn_e1_02.png

  • 任意の設定にして[プロジェクトを作成]をクリック
    • 各項目は、サンプルアプリの場合は、チェックしてもしなくても、どちらでも良いかと思います。
    • リリースするアプリの場合は、アナリティクスを使うのであれば、諸々必要な設定をして、様々な規約に同意する必要があります。

kotlin_e1_03.png

  • しばし待って、プロジェクトが作成されたら、「次へ」をクリック

kotlin_e1_05.png

  • ドロイド君アイコンをクリック

kotlin_e1_06.png

  • 必要な情報を入力して、「アプリを登録」をクリック
    • Androidパッケージ名: app/build.gradleにあるapplicationId "xxxx.yyyy.zzzz"""の中をコピペ
    • 名前は任意でOK
    • 証明書のSHA1はサンプルアプリであれば不要。必要な場合はググれば情報がある

kotlin_e1_07.png

  • google-services.jsonをDLしてプロジェクトに置いて、「次へ」をクリック
    • 説明にあるとおり、appディレクトリ直下に置くこと

kotlin_e1_08.png

  • プロジェクトルートディレクトリにあるbuild.gradleのdependenciesに下記を追加
root/build.gradle
dependencies{
    ...
    classpath 'com.google.gms:google-services:4.2.0'
}
  • app/build.gradleのdependenciesに下記を追加
app/build.gradle
dependencies{
    ...
    implementation 'com.google.firebase:firebase-core:17.0.0'
}
  • app/build.gradle最下部に下記を追加
app/build.gradle
apply plugin: 'com.google.gms.google-services'
  • Gradle Syncを実行

syncが成功しましたか?
成功しない場合、パッケージ名が合ってないなど(大文字小文字の違いも見ます)の場合が多いので、よく確認して下さい。

アプリを実行してみましょう。通信するので、端末がネットワークに繋がっている必要があります。

こんな画面に変われば、FirebaseSDKの設定は完了。

kotlin_e1_09.png

(2) ML Kitの導入

a. app/build.gradleのdependenciesに以下を追記

app/build.gradle
dependencies {
  // ...

  implementation 'com.google.firebase:firebase-ml-vision:21.0.0'
}

b. AndroidManifest.xmlの、<application>タグ内に下記を追記

<application ...>
  ...
  <meta-data
      android:name="com.google.firebase.ml.vision.DEPENDENCIES"
      android:value="ocr" />
  <!-- To use multiple models: android:value="ocr,model2,model3" -->
</application>

(3) 画像から FirebaseVisionImage オブジェクトを作成

a. サンプル画像の用意

  • 取り敢えず、カメラで歩数計なんかを撮影して、まずは認識しやすそうなフィルタ等の加工も掛けておきましょう。 こんな感じで作っておきました。なんか認識しづらそうなので既に不安になっています(汗)

IMG_20190622_165554.jpg

b. パスを確認

  • アルバムアプリなどから確認しておきます。

kotlin_e1_20.png

※将来的には、カメラフォルダなどの写真を一覧表示して、ユーザーに選択させるようにすべき。

  • プログラムでBitmapを取得する
        // ストレージから固定ファイルを読込
        val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
        val file = File(dir, "IGM_20190622_165554.jpg")
        if (!file.exists()) return

        val bitmap = BitmapFactory.decodeFile(file.absolutePath)

(4) FirebaseVisionTextRecognizer のインスタンスを取得

        val detector = FirebaseVision.getInstance().onDeviceTextRecognizer

(5) OCRをプロセス

        val result = detector.processImage(image)
            .addOnSuccessListener { firebaseVisionText ->
                // Task completed successfully
                val string = processTextBlock(firebaseVisionText)
            }
            .addOnFailureListener {
                // Task failed with an exception
                // ...
                Toast.makeText(context, "OCR出来ない!", Toast.LENGTH_LONG).show()
            }
    private fun processTextBlock(result: FirebaseVisionText): String {
        val resultText = result.text
        for (block in result.textBlocks) {
            val blockText = block.text
            Log.d("MYOCR", "blockText=$blockText")
            val blockConfidence = block.confidence
            val blockLanguages = block.recognizedLanguages
            val blockCornerPoints = block.cornerPoints
            val blockFrame = block.boundingBox
            for (line in block.lines) {
                val lineText = line.text
                Log.d("MYOCR", "lineText=$lineText")
                return lineText
            }
        }
        return resultText
    }

デバイスモデルの版のコードでそのまま実装した結果

  • 撮影して、加工しない写真は、認識できず。
  • 明度を調整した加工は、認識できた物もあり。
  • 手書きのものを撮影した写真も一部を認識。

画像例と実行結果

(ⅰ)画像フィルター編集(明るさ、彩度などいろいろ編集)
kotlin_e1_devapi_1.jpg

(ⅱ)画像の明るさだけMAXに編集
kotlin_e1_devapi_4.jpg

(ⅲ)印字された番号を撮影(加工無し)
kotlin_e1_devapi_3.jpg

※数字の"0"がいくつか"O(大文字のアルファベット)"に誤認識されている

(ⅳ)手書き文字(加工無し)
kotlin_e1_devapi_2.jpg

※先頭の"1"が"I"に誤認識されている

(ⅴ)歩数計を置いて撮影(無加工)
kotlin_e1_devapi_5.jpg

(ⅵ)歩数計を手に持って撮影(無加工)
kotlin_e1_devapi_6.jpg

(6) クラウドモデルを試してみる。

  • Blazeにアップグレード。

    • 1000Unit/月まで無料だけど、それ以上は課金されるので注意。
  • FirebaseVisionTextRecognizer のインスタンスを取得するコードを下記に変更

        val detector = FirebaseVision.getInstance().cloudTextRecognizer

クラウドモデル版の実行結果

  • 元画像はやはりそのままでは厳しい写真も発生
  • 明るさを最大に上げるとほぼ大丈夫

(ⅰ)画像フィルター編集(明るさ、彩度などいろいろ編集)
kotlin_e1_cloud_1.jpg

(ⅱ)画像の明るさだけMAXに編集
kotlin_e1_cloud_4.jpg

(ⅲ)印字された番号を撮影(加工無し)
kotlin_e1_cloud_3.jpg

(ⅳ)手書き文字(加工無し)
kotlin_e1_cloud_2.jpg

※先頭の"1"と"2"が無視された・・・(字が汚いせい!?)

(ⅴ)歩数計を置いて撮影(無加工)
kotlin_e1_cloud_5.jpg

※暗さのせいか、先頭の1文字が無視された

(ⅵ)歩数計を手に持って撮影(無加工)
kotlin_e1_cloud_6.jpg

※出来そうなのになぜだろう?

(7) 所感

  • カメラで撮影後、切り取りやフィルター調整する機能を搭載しないと、多分使い物にならない。
  • デバイスAPIだけでやるならば、デジタル数字の精度を上げる方法を探さないとだめかと思う。

  • また、アプリを公開するなら無料分に収まらず課金される可能性があるので、広告載せるとか有料にしないといけなくなる。

(8) その他のオプション

a. MLKit Custom

カスタムの機械学習モデルを使い、デバイスAPIで使用可能らしい

kotlin_e1_21.png

※サンプル実行まで至らず。

b. MLKit AutoML

学習データをアップすることで自動的にモデルを作成してくれるらしい。(新機能)

kotlin_e1_30.png

kotlin_e1_31.png

kotlin_e1_32.png

※サンプル実行まで至らず

こういう画像を数字として学習させるには、どういう画像を用意してどうラベル付けていけば良い?

デメリット
- 色んな歩数計の画像準備が必要になる
- 汎用的にアプリの機能に入れるには、ハードルが高いか、ユーザー自身に学習データをアップさせるような機能まで入れたら凄いかも知れない

2. Cloud Vision API

参考ページ
https://qiita.com/rui_qma/items/1eee6c1a096d59c30a0b

FirebaseのML Kitと同じようなので、実装は見送り。

実際、下記画面で、[Cloud APIの使用状況]をクリックしてみると、

kotlin_e1_22.png

思いっきり、名前 Cloud Vision APIと書いてあります^^;

kotlin_e1_23.png

3. tess-two

(1) 導入

下記ページを参考に実装してみた。それほど難しくは無い。
※以前はndk buildが必要だったようだが、随分簡単になっている

(2) 実行結果

(ⅰ)画像フィルター編集(明るさ、彩度などいろいろ編集)
kotlin_e1_tesstwo_1.jpg

(ⅱ)画像の明るさだけMAXに編集
kotlin_e1_tesstwo_4.jpg

(ⅲ)印字された番号を撮影(加工無し)
kotlin_e1_tesstwo_3.jpg

(ⅳ)手書き文字(加工無し)
kotlin_e1_tesstwo_2.jpg

※罫線を誤認識?

(ⅴ)歩数計を置いて撮影(無加工)
kotlin_e1_tesstwo_5.jpg

(ⅵ)歩数計を手に持って撮影(無加工)
kotlin_e1_tesstwo_6.jpg

(3) 所感

用意済みの学習データをダウンロードして使わせて貰えるのだが、文字認識の精度があまり良く無さそう。
精度を上げるために皆さん学習データを色々編集しているようです。

実際、実行してみると、誤認識率が高く、特に歩数計を撮影した写真はほぼアウトだった。
白い紙に書かれた印字されたものはなんとかなるかも知れないレベル。
模様だったり枠だったり影だったりが繊細に認識されてしまい、それを何とか英文字に当てはめようとしてしまう印象を受けた。

まとめと反省

  • 自分が使うだけのオレオレアプリなら、MLKitの課金されない範囲でやるにはなんとかなりそうなクオリティでやれそう。

  • CodeLabsにMLKitがあった(https://codelabs.developers.google.com/codelabs/mlkit-android)

    • ベースアプリの作成に手一杯だったが、これをやるのが先だったかも知れない。

画像選択機能

画像を選択ダイアログで選べるようにしました。表示するのはいわゆる端末のカメラロールに保存された物だけです。DCIM/Cameraフォルダに固定しているのはPixel3aの保存先がそこだったからです。色んな端末、OSに対応するとなると、内蔵ストレージすべてから画像を検索したり、フォルダを選ばせたりと、実装が必要な機能が半端なく増えます。

画像選択用Fragment

Activityを分けず、Fragmentだけの追加にしましたが、ちゃんと設計するなら別Activityにするのが良いかと。

OcrSourceSelectFragment.kt
class OcrSourceSelectFragment : Fragment() {

    companion object {
        const val TAG = "OcrSourceSelectFragment"
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val viewModel = ViewModelProviders.of(this).get(OcrViewModel::class.java)

        val binding: FragmentSelectOcrsrcBinding =
            DataBindingUtil.inflate(
                inflater,
                jp.les.kasa.sample.mykotlinapp.R.layout.fragment_select_ocrsrc,
                container,
                false
            )

        val root = binding.root

        binding.lifecycleOwner = this
        binding.viewmodel = viewModel

        // RecyclerView(2x2のGridLayout)
        root.bitmap_list.layoutManager = GridLayoutManager(requireContext(), 2)

        val adapter = BitmapRecyclerAdapter(viewModel.bitmapSourceList.value!!, viewModel)
        root.bitmap_list.adapter = adapter

        Handler().post {
            // 画像のリストを取得
            val list = getSrcBitmaps()
            viewModel.bitmapSourceList.postValue(list)
        }

        // 画像選択を監視
        viewModel.ocrBitmapSource.observe(this, Observer { bitmap ->
            val viewModel2 = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)
            bitmap?.let {
                viewModel2.ocrSource(bitmap)
            }
            // 選択したら戻る
            fragmentManager?.popBackStack()
        })

        return binding.root
    }

}

class BitmapRecyclerAdapter(
    private var list: List<Bitmap>,
    private val viewModel: OcrViewModel
) :
    RecyclerView.Adapter<BitmapRecyclerAdapter.LogViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
        val binding: ItemBitmapBinding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.context), jp.les.kasa.sample.mykotlinapp.R.layout.item_bitmap, parent, false
        )
        return LogViewHolder(binding)
    }

    fun setList(newList: List<Bitmap>) {
        list = newList
        notifyDataSetChanged()
    }

    override fun getItemCount() = list.size

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        holder.binding.bitmap = list[position]
        holder.binding.viewmodel = viewModel
    }

    class LogViewHolder(val binding: ItemBitmapBinding) : RecyclerView.ViewHolder(binding.root)
}

画面のレイアウトxml

fragment_select_ocrsrc.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <import type="android.view.View"/>

        <variable name="viewmodel"
                  type="jp.les.kasa.sample.mykotlinapp.activity.logitem.ocr.OcrViewModel"/>
    </data>
    <FrameLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="8dp"
            android:background="#86141010">

        <androidx.recyclerview.widget.RecyclerView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:id="@+id/bitmap_list"
                android:visibility="@{viewmodel.bitmapSourceList.size > 0 ? View.VISIBLE : View.GONE }"
                app:items="@{viewmodel.bitmapSourceList}"/>
        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/text_empty"
                android:text="@string/error_no_image_sources"
                android:visibility="@{viewmodel.bitmapSourceList.size > 0 ? View.GONE : View.VISIBLE }"
                android:textSize="30sp"
                android:layout_gravity="center"/>
    </FrameLayout>
</layout>

ViewModel
RecyclerViewに表示する画像のリストと、選択結果のやりとり用のLiveDataを持ってます。

OcrViewModel.kt
class OcrViewModel : ViewModel() {
    private val _ocrBitmapSource = MutableLiveData<Bitmap>()

    // 選択した画像
    var ocrBitmapSource = _ocrBitmapSource as LiveData<Bitmap>

    // 画像のリスト
    val bitmapSourceList = MutableLiveData<List<Bitmap>>()

    init {
        bitmapSourceList.value = listOf()
    }


    @UiThread
    fun onClickBitmap(view: View) {
        if (view !is ImageView) return

        val sourceImage = view.drawable.toBitmap()
        _ocrBitmapSource.value = sourceImage
    }
}

画像リスト取得のコード

OcrUtils.kt
fun File.isImageFile(): Boolean {
    if (isDirectory) return false
    if (extension == "png") return true
    if (extension == "PNG") return true
    if (extension == "jpg") return true
    if (extension == "JPG") return true
    if (extension == "jpeg") return true
    if (extension == "JPEG") return true
    return false
}

/**
 * カメラのディレクトリにある固定画像を読み込み
 */
fun getSrcBitmaps(): List<Bitmap> {
    val root = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
    val dir = File(root, "Camera")
    val files = dir.walkTopDown().filter { it.isImageFile() }.sortedByDescending { it.lastModified() }

    val bitmapList = mutableListOf<Bitmap>()
    val options = BitmapFactory.Options()
    options.inSampleSize = 4

    run loop@{
        files.forEach { file ->
            if (file.exists()) {
                Log.d("MYOCR", "file = ${file.name}")
                val bitmap = BitmapFactory.decodeFile(file.absolutePath, options)
                bitmapList.add(bitmap)
                if (bitmapList.size > 6) return@loop
            }
        }
    }
    return bitmapList
}

ざっくり説明。

  • OcrViewModelで表示する画像リスト、選択結果の設定と監視をしています。
    • 選択結果を戻すのにLogItemViewModelを苦肉の策で使っていますが、この設計は自分でもあまり良くないと思いますね・・・やっぱりActivityを分けて、onActivityResultで結果をdataIntentとして受け取るのがスマートな気がします。
  • RecyclerViewにDatabindingで表示データを設定しています。
    • 表示データが無いときにはRecyclerViewは非表示にし、代わりに「データが無い」テキストを表示しています。こういうこともDatabindingで書けるんで便利です。
    • アイテムの選択もDatabindingとViewModelの連携で済ませています。ホントにコードが減って便利です。
  • 画像のリストを取得する箇所など、本来であれば非同期にすべき箇所がありますが、やっていません。

続いて、以下は、上記のFragmentで画像を確定したら、OCR結果を表示し、読取り文字列を修正してカウント数値を確定させるためのダイアログのコードです。

OcrResultDialogFragment.kt
class OcrResultDialogFragment : DialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        val view = View.inflate(context, R.layout.dialog_ocr_result, null)

        view.image_ocr_source.setImageBitmap(viewModel.ocrBitmapSource.value)
        view.text_ocr_result.setText(viewModel.ocrResultText.value)

        // AlertDialogのセットアップ
        builder.setView(view)
            .setNegativeButton(android.R.string.cancel, null)
            .setPositiveButton(android.R.string.ok) { _, _ ->
                // ポジティブボタンでViewModelに数字をセット
                try {
                    viewModel.ocrResultTextToEdit(view.text_ocr_result.text.toString())
                } catch (e: NumberFormatException) {
                    Toast.makeText(activity, "数値以外の文字があります", Toast.LENGTH_LONG).show()
                }
            }
        return builder.create()
    }
}

最後に、OCR用画像選択画面を呼び出すコードです。数値を入力するテキストエリアの隣に、カメラアイコンのボタンを置きました。(カメラだけど撮影はしないじゃん、というのはおいといて下さい^^;)

kotlin_ocr_01.png

LogInputFragment.kt
@RuntimePermissions
class LogInputFragment : Fragment() {
....
   override fun onCreateView(
        ....

        // カメラボタン
        contentView.button_camera.setOnClickListener {
            onCameraButtonWithPermissionCheck()
        }
        ....
    }

    @NeedsPermission(permission.READ_EXTERNAL_STORAGE, permission.WRITE_EXTERNAL_STORAGE)
    internal fun onCameraButton() {
        val transaction = fragmentManager?.beginTransaction()
        transaction?.add(R.id.logitem_container, OcrSourceSelectFragment(), OcrSourceSelectFragment.TAG)
        transaction?.addToBackStack(null)
        transaction?.commit()
    }

....
}

カメラフォルダを読み取るのに権限が必要なので、Permissionチェックの処理が必要です。今回は、Permission dispatcherというライブラリを使いました。権限が必要なメソッドにアノテーションを付けて、xxxxWithPermissionCheckというsuffixを付けた関数名で呼び出すだけOKという、とても便利なライブラリです。

dependencyの書き方はこちら。

app/build.gradle
    implementation "com.github.hotchemi:permissionsdispatcher:3.3.1"
    kapt "com.github.hotchemi:permissionsdispatcher-processor:3.3.1"

今後の課題

もし、画像認識を本格的に取り入れるならば・・・

  • 必要な箇所はちゃんと非同期にする
  • カメラでその場で撮影する機能(プレビューの利用、またはカメラアプリそのものとの連携)
  • 画像の明度を変更する、トリミングする、程度の画像編集機能、またはそれらのアプリとの連携機能
  • 文字(特に数値)認識に特化したライブラリ、データセットなどの模索

等々が必要となるでしょう。

正直言って、カメラを使う機能は、多様な機種に対応していくのがかなり大変です。
数字が10桁とか行くようなものならまだしも、たかだか4〜5桁が歩数の限界と思いますので、覚えてパッと打ち込む方が、今回の場合は早いと思います。
手入力すら面倒な人は、そもそもスマホに連動したりクラウドにデータ保存できる高機能な歩数計を買うでしょう(笑)(というかスマホそのものに歩数記録機能があったりしますね)

ちょっと機械学習、文字認識に触れてみたかったのでやってみた次第ですが、アプリの要件からはいったん見送ることにします。

でもこういう実験的な実装は、楽しいので個人的には大好きです。

ソースコード

今回のソースコード全体は以下にあります。

※google-servies.jsonというのがないと、ビルドできません。落としてくる方法は上記にありますが、パッケージ名などがFirebase上の物と一致しないとだめなので、よく注意してください。

14
17
1

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
14
17