【Kotlin】Firebase + CameraX でリアルタイム文字認識


Firebase ML Kit + CameraX でリアルタイム文字認識してBottomSheetに表示します。

#デモ

完成形はこんな感じです。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">CameraX+MLkitでリアルタイム文字認識 <a href="https://t.co/rjxxYrfIb4">pic.twitter.com/rjxxYrfIb4</a></p>&mdash; marica (@tama_Ud) <a href="https://twitter.com/tama_Ud/status/1277065359669907457?ref_src=twsrc%5Etfw">June 28, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

#おおまかな処理の流れ

カメラ起動
↓
MLKitでリアルタイム文字認識
↓
BottomSheetに認識したテキストをリアルタイム表示

#さっそく作る
#### 開発環境
・Windows 10
・Android Studio 3.6.3

#### 事前準備
**1. Firebaseの設定**

今回はテキスト認識とラベリングを使用します。
以下URLを参照して設定してください。

・テキスト認識
https://firebase.google.com/docs/ml-kit/android/recognize-text?hl=ja
今回はデバイスモデルを使用します。
デバイスモデルで認識可能な言語はラテン文字のみです。
その他日本語などを認識したい場合はクラウドモデルを使いましょう。（月1000回まで無料のようです）

・ラベリング
https://firebase.google.com/docs/ml-kit/android/label-images?hl=ja

**2. CameraXの設定** 

build.gradle(Module.app)ファイルのdependenciesブロックに以下を追記します。

```kotlin:build.gradle(Module.app)
dependencies {
    …
    def camerax_version = "1.0.0-beta03"
    // CameraX core library using camera2 implementation
    implementation "androidx.camera:camera-camera2:$camerax_version"
    // CameraX Lifecycle Library
    implementation "androidx.camera:camera-lifecycle:$camerax_version"
    // CameraX View class
    implementation "androidx.camera:camera-view:1.0.0-alpha10"
    …
}
```

同じくbuild.gradle(Module.app)ファイルのandroidブロック末尾に以下を追記します。

```kotlin:build.gradle(Module.app)
android {
    …
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}
```

#### 3. AndroidManifestに追記
```xml:AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.websarva.wings.android.your_project_name">

    <uses-feature android:name="android.hardware.camera.any" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
     …>
     …
    <meta-data
        android:name="com.google.firebase.ml.vision.DEPENDENCIES"
        android:value="ocr, label" />
    </application>
</manifest>
```


以上を書き込んだらAndroidStudioの "SyncNow" ボタンをクリックし、無事ビルドされることを確認します。

#### レイアウトを作成する

activity_mainの内容を以下に置き換えます。
LinearLayoutでBottomSheetを作っています。

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <LinearLayout
        android:id="@+id/bottomSheetLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/cardview_light_background"
        android:orientation="vertical"
        app:behavior_hideable="false"
        app:behavior_peekHeight="200dp"
        app:layout_behavior="@string/bottom_sheet_behavior">

        <TextView
            android:id="@+id/bottomSheetText"
            android:layout_width="300dp"
            android:layout_gravity="center"
            android:layout_height="wrap_content"
            />
    </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
```


#### メイン処理を書く

大枠としてはこんな感じです。
これから具体的な処理を肉付けしていきます。

```kotlin:MainActivity.kt

typealias ODetection = (odt: Array<String?>) -> Unit
private const val TAG = "CameraXBasic"

class MainActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "CameraXBasic"
        private const val REQUEST_CODE_PERMISSIONS = 10
        private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // TODO: not yet implement
    }

    //MARK:  ===== カメラ起動 =====
    private fun startCamera() {
        // TODO: not yet implement
    }

    class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer {
        //TODO: not yet implement
    }
}
```

カメラの処理を書いていきます。
onCreate内に以下の処理を書き足します。

```kotlin:onCreate(MainActivity.kt)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // BottomSheetを設定
        bottomSheetBehavier = BottomSheetBehavior.from(bottomSheetLayout)

        // Request camera permissions
        if (allPermissionsGranted()) {
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }
        // Setup the listener for take photo button
        outputDirectory = getOutputDirectory()
        cameraExecutor = Executors.newSingleThreadExecutor()
    }
```

onCreateの下に以下のメソッドを書き足します。


```kotlin:MainActivity.kt

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults:
        IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startCamera()
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    private fun getOutputDirectory(): File {
        val mediaDir = externalMediaDirs.firstOrNull()?.let {
            File(it, resources.getString(R.string.app_name)).apply { mkdirs() } }
        return if (mediaDir != null && mediaDir.exists())
            mediaDir else filesDir
    }
```

startCamera内に以下を書き足します。

```kotlin:startCamera(MainActivity.kt)
    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        val frameLayout = FrameLayout(this)
        cameraProviderFuture.addListener(Runnable {
            // Used to bind the lifecycle of cameras to the lifecycle owner
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            // Preview
            preview = Preview.Builder()
                .build()

            imageCapture = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                .build()

            imageAnalyzer = ImageAnalysis.Builder()
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()
                .also {
                    // OCRの結果
                    it.setAnalyzer(cameraExecutor, ImageAnalyze { txtArr ->
                        var showTxt = ""
                        frameLayout.removeAllViews()
                        for (txt in txtArr){
                            txt?.let{
                                showTxt += " $txt"
                            }
                        }
                        bottomSheetText.text = showTxt
                        Log.d(TAG, "listener fired!: $showTxt")
                    })
                }

            // Select back camera
            val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build()

            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()

                // Bind use cases to camera
                camera = cameraProvider.bindToLifecycle(
                    this, cameraSelector, preview, imageCapture, imageAnalyzer)
                preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo))
            } catch(exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(this))
    }
```

画像処理部分を書いていきます。
インナークラスであるImageAnalyzeに以下を追記します。

```kotlin:ImageAnalyze(MainActivity.kt)
    class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer {
        val options = FirebaseVisionOnDeviceImageLabelerOptions.Builder()
            .setConfidenceThreshold(0.7f)
            .build()
        val labeler = FirebaseVision.getInstance().getOnDeviceImageLabeler(options)

        val detector = FirebaseVision.getInstance()
            .onDeviceTextRecognizer

        private fun degreesToFirebaseRotation(degrees: Int): Int = when (degrees) {
            0 -> FirebaseVisionImageMetadata.ROTATION_0
            90 -> FirebaseVisionImageMetadata.ROTATION_90
            180 -> FirebaseVisionImageMetadata.ROTATION_180
            270 -> FirebaseVisionImageMetadata.ROTATION_270
            else -> throw Exception("Rotation must be 0, 90, 180, or 270.")
        }

        // フレームごとに呼ばれる
        override fun analyze(image: ImageProxy) {
            // Pass image to an ML Kit Vision API
            doObjectClassification(image)
        }
```

さらにImageAnalyzeクラスにラベリング用処理を書き足します。
認識結果が "Paper" の時のみOCR処理が走るようにします。

```kotlin:ImageAnalyze(MainActivity.kt)
        // 画像分類
        @SuppressLint("UnsafeExperimentalUsageError")
        private fun doObjectClassification(proxy: ImageProxy) {
            val mediaImage = proxy.image ?: return
            val imageRotation = degreesToFirebaseRotation(proxy.imageInfo.rotationDegrees)
            val image = FirebaseVisionImage.fromMediaImage(mediaImage, imageRotation)
            labeler.processImage(image)
                .addOnSuccessListener { labels ->
                    // Task completed successfully
                    for (label in labels) {
                        val text = label.text
                        Log.d(TAG, "text: $text")
                        if (text == "Paper") {
                            doTextRecognition(image)
                        } else {
                            // do something
                        }
                    }
                    proxy.close()
                }
                .addOnFailureListener { e ->
                    // Task failed with an exception
                    Log.e(TAG, e.toString())
                    proxy.close()
                }
        }
```

さらにImageAnalyzeクラスにテキスト認識用処理とパース処理を書き足して完成です。
Runしてみましょう。

```kotlin:ImageAnalyze(MainActivity.kt)

        //文字認識 - 書類に書かれた文字のみ認識する
        private fun doTextRecognition(image: FirebaseVisionImage) {
            val result = detector.processImage(image)
                .addOnSuccessListener { firebaseVisionText ->
                    // Task completed successfully
                    parseResultText(firebaseVisionText)
                    Log.d(TAG, "OCR Succeeded!")
                }
                .addOnFailureListener { e ->
                    // Task failed with an exception
                    Log.d(TAG, "OCR Failed...")
                    Log.e(TAG, e.toString())
                }
        }
        // パース - OCRで認識された文字列をParseする
        private fun parseResultText(result: FirebaseVisionText) {
            var resultTxtList:Array<String?> = arrayOf(null)
            for (block in result.textBlocks) {
                val blockText = block.text
                resultTxtList += blockText
            }
            Log.d("RESULT_TEXT",resultTxtList.toString())
            listener(resultTxtList)
        }
```

### さいごに
CameraX + FIrebaseの組み合わせでかんたんにリアルタイム画像処理アプリが作れました。
従来のCameraライブラリよりもより楽に実装することができるので、今後も使っていきたいです。

##### * 2020/7/23 更新
サンプルコードをgithubに公開しました。
https://github.com/tamaUdon/Realtime-OCR-XCamera