Firebase ML Kit + CameraX でリアルタイム文字認識してBottomSheetに表示します。
#デモ
完成形はこんな感じです。
CameraX+MLkitでリアルタイム文字認識 pic.twitter.com/rjxxYrfIb4
— marica (@tama_Ud) June 28, 2020
#おおまかな処理の流れ
カメラ起動
↓
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ブロックに以下を追記します。
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ブロック末尾に以下を追記します。
android {
…
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
3. AndroidManifestに追記
<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 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>
メイン処理を書く
大枠としてはこんな感じです。
これから具体的な処理を肉付けしていきます。
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内に以下の処理を書き足します。
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の下に以下のメソッドを書き足します。
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内に以下を書き足します。
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に以下を追記します。
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処理が走るようにします。
// 画像分類
@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してみましょう。
//文字認識 - 書類に書かれた文字のみ認識する
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