📹

AndroidでOpenCVを使ったリアルタイム画像処理アプリを作る

2023/03/26に公開

はじめに

この度サポーターズ様主催の技育CAMPに参加しました
そこでAndroidでリアルタイム画像処理アプリを作成したので記事を書きます.

Androidのリアルタイム画像処理でめんどくさい点はいくつかあります.

  • 端末の回転
  • 画像の処理速度
  • 画像の型
  • 他センサとの競合

環境

Android Studuio: Electric Eel | 2022.1.1 Patch 1
Kotlin: 1.7.21
OpenCV: 4.7.0
cameraX: 1.3.0-alpha02

アプリの流れ

全体的な流れ

MainFragmentとMainViewModelと画像処理クラスの3つを使うとします.
MainViewModelはCameraXと画像処理クラスのインスタンスを持ちます.
画像処理クラスはCameraXから画像を受け取り,画像処理後自身の持つLiveDataにPostします.
MainFragmentは画像処理クラスのLiveDataを監視し,変更があったらImageViewに表示します.

画像処理の流れ

CameraXからの入力画像はImageProxy
OpenCVで画像処理するときはMat
ImageViewに表示するときはbitmap

これらの型変換をする必要があります.

目次

  1. Android StudioにOpenCVを入れる
  2. CameraXの設定
  3. 画像の型変換と修正
  4. 画像を処理するクラスを作る
  5. 実際に使用する時の注意点

Android StudioにOpenCVを入れる

手順

以下を参考にしてください.
Android StudioでOpenCVを使う

このようなエラーが出た場合の解決法を載せておきます.

Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.7.1, expected version is 1.5.1.
解決法(クリックすると表示されます)

原因

plugins {
    id 'com.android.application' version '7.1.2' apply false
    id 'com.android.library' version '7.1.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.5.30' apply false
}
  • Kotlinのバージョンが低い.(一番下のやつ)

しかしエラー内容が

そのメタデータのバイナリバージョンは1.7.1、期待されるバージョンは1.5.1です。

僕のKotlinバージョンは1.5.30だし
2023/01/16の時点でKotlin最新バージョンは1.7.0

ともかくアップデート

  1. Android Studio起動時の画面
  2. Pluginsを選択
  3. kotlinを見つけて(検索もできる)バージョンアップ

この作業をした後以下のエラーが起こる可能性があります.
その解決法も載せておきます.
Android Studioで表示だけエラーになるバグ

OpenCVが導入できたら確認しましょう.
確認でよく使うバージョン確認をしてみましょう.
バージョンはOpenCVLoader.OPENCV_VERSIONで確認できます.

Log.d("openCV", OpenCVLoader.OPENCV_VERSION)
2023-01-16 17:15:30.633 20985-20985/? D/openCV: 4.7.0

また,gradleやフォルダが追加されます.

ソースコード

OpenCVのバージョンを確認するMainActivity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import org.opencv.android.OpenCVLoader

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d("openCV", OpenCVLoader.OPENCV_VERSION)
    }
}

CameraXの設定

CameraXの設定のうち,今回重要になるのは

  • 入力画像の渡し先クラス
  • 使用カメラ

これらをライフサイクルと同期させます.

MainViewModel.kt
fun startCamera(fragment: Fragment) {
    val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> = ProcessCameraProvider.getInstance(activity)
    val context: Context = activity

    cameraProviderFuture.addListener({
        try {
            val cameraProvider = cameraProviderFuture.get()
            val preview = Preview.Builder().build()
            cameraProvider.unbindAll()

            // 各フレームを解析できるAnalysis
            val imageAnalysis = ImageAnalysis.Builder()
		// 画像Analysisクラスに送る画像サイズはここで決める
                // .setTargetResolution(Size(1920, 1920))
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()

            val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
	    
            // 画像を送るクラスのインスタンスを渡す.今回imageProcessorというクラスを設定
            imageAnalysis.setAnalyzer(cameraExecutor, imageProcessor)

            // 背面カメラを設定
            val cameraSelector = if (backCamera) {
                CameraSelector.Builder()
                    .requireLensFacing(CameraSelector.LENS_FACING_BACK)
                    .build()
            }

            // これらの設定を使ってLifecycle化
            val camera = cameraProvider.bindToLifecycle(
                (fragment as LifecycleOwner),
                cameraSelector,
                preview,
                imageAnalysis
            )
        } catch (e: Exception) {
            Log.e(LOG_NAME, "[startCamera] Use case binding failed", e)
        }
    }, ContextCompat.getMainExecutor(context))
}

画像の型変換と修正

ImageProxy to Mat

ImageProxy.toMat()
fun ImageProxy.toMat(): Mat {
    val image = this
    val yuvType = Imgproc.COLOR_YUV2BGR_NV21
    val mat = Mat(image.height + image.height / 2, image.width, CvType.CV_8UC1)
    val data = ByteArray(image.planes[0].buffer.capacity() + image.planes[1].buffer.capacity())
    image.planes[0].buffer.get(data, 0, image.planes[0].buffer.capacity())
    image.planes[1].buffer.get(data, image.planes[0].buffer.capacity(), image.planes[1].buffer.capacity())
    mat.put(0, 0, data)
    val matRGBA = Mat()
    Imgproc.cvtColor(mat, matRGBA, yuvType)
    return matRGBA
}

回転

入力された画像は端末の回転や外カメラと内カメラの切り替えなどで回転します.
それを修正するクラスが必要です.

fixMatRotation
@RequiresApi(Build.VERSION_CODES.R)
fun fixMatRotation(matOrg: Mat, context: Context): Mat {
    val mat: Mat
    val display = context.display
    val rotation = display?.rotation
    when (rotation) {
        Surface.ROTATION_0 -> {
            mat = Mat(matOrg.cols(), matOrg.rows(), matOrg.type())
            Core.transpose(matOrg, mat)
            Core.flip(mat, mat, 1)
        }
        Surface.ROTATION_90 -> mat = matOrg
        Surface.ROTATION_270 -> {
            mat = matOrg
            Core.flip(mat, mat, -1)
        }
        else -> {
            mat = Mat(matOrg.cols(), matOrg.rows(), matOrg.type())
            Core.transpose(matOrg, mat)
            Core.flip(mat, mat, 1)
        }
    }
    return mat
}

もし内カメと外カメを切り替えなどで,画像が上下反転する場合は

Core.flip(mat, mat, 0)

を一回するといい感じになります.

Mat to bitMap

Mat.toBitmap()
fun Mat.toBitmap(): Bitmap {
    val bmp = Bitmap.createBitmap(cols(), rows(), Bitmap.Config.ARGB_8888)
    Utils.matToBitmap(this, bmp)
    return bmp
}

使い方

このImageProcessorクラスはあとで書きます.

ImageProcessor.kt

// 出力する画像
private val _image =
        MutableLiveData<Bitmap>(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888))
val image: LiveData<Bitmap> = _image

@RequiresApi(Build.VERSION_CODES.R)
override fun analyze(image: ImageProxy) {
    image.use {
	// ImageProxy to Mat
        val mat = image.toMat()
	// 画像の回転を修正
        var rMat = fixMatRotation(mat ,context)
        // このあたりでMat to Matの画像処理.
	
	// Mat to bitmap
        val bitmap = rMat.toBitmap()
        _image.postValue(bitmap)
    }
}

画像を処理する

CameraXから画像を受け取り処理してLiveDataにPostします

まず,画像をCameraXから受け取るクラスはImageAnalysis.Analyzerを継承する必要があります.
class ImageProcessor(val context: Context): ImageAnalysis.Analyzer

そしてoverride fun analyze(image: ImageProxy)を実装します.
このanalyze関数に画像が送られてきます.

また,画像処理やLiveDataへのPostが終わったら必ずimage.close()してください.
これをしないと次の画像が送られてきません.
今回のサンプルではimage.useを使用してほぼ同じ処理をしています.

ImageProcessor.kt

class ImageProcessor(val context: Context): ImageAnalysis.Analyzer {

    // 出力する画像
    private val _image =
        MutableLiveData<Bitmap>(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888))
    val image: LiveData<Bitmap> = _image

    // ここに毎フレーム画像が渡される
    @RequiresApi(Build.VERSION_CODES.R)
    override fun analyze(image: ImageProxy) {
        image.use {
	    // ImageProxy to Mat
            val mat = image.toMat()
	    // 画像の回転を修正
            var rMat = fixMatRotation(mat ,context)
            // このあたりでMat to Matの画像処理.
	
	    // Mat to bitmap
            val bitmap = rMat.toBitmap()
            _image.postValue(bitmap)
        }
    }
}

画像を表示

Fragment の持つ ViewModel の持つ 画像処理クラス の持つ LiveData<bitMap> を監視します
Fragmentにobserveを作りましょう.
ただし,runOnUiThreadを使う必要があります.
(画像表示に少し時間がかかりますが,絶対UIスレッドで実行する必要があるため)

MainFragment.kt
MainViewModel.imageProcessor.image.observe(viewLifecycleOwner){
    activity.runOnUiThread {
        binding.imageView.setImageBitmap(it)
    }
}

実際に使用する時の注意点

画像処理にかかる時間

  • このプログラムだと画像処理が終わって表示されるまで新しく画像は入力されません.
  • つまり一枚の画像にかかる処理時間が1秒の場合,ImageViewは1秒に一回表示されます.

  • 処理が重い重くないの判定は2重forループがあるかどうかで大体判定できます.
  • すでに提供されている画像処理ライブラリがある軽量な処理のみをおすすめします.
  • もしどうしても重い処理をする場合thread処理をしましょう.
    • しかしthreadをたくさん立てすぎると CPUが持ちません.
    • なので僕は一秒に一回以上threadを立てないようにしています.
    • 以下に便利な関数を置いておきます.
    private var shouldUseThread: Boolean = true
    private var timeStamp = System.currentTimeMillis()
    
    // 呼ぶと1度だけtrueを返してくれる.それ以降falseを返す
    // 1000ms経過するとまた1度だけtrueを返してくれる
    private fun isThreadAllowed(): Boolean {
        val now = System.currentTimeMillis()
        // 一定時間経過したら
        if (now - timeStamp > 1000) {
            shouldUseThread = true
            timeStamp = now
        }
        return if (shouldUseThread){
            shouldUseThread = false
            return true
        } else {
            return false
        }
    }

他のライブラリやセンサとの競合

  • 例えばcameraXとAudioRecordはPixel5で競合しました.
    • 詳しくはわかりませんが,audioRecord.startRecording()をした瞬間にcameraXから入力される画像が写ったものがわからない程変化しました.
    • この現象はPixel7では起こりませんでした.
    • 詳しいことはわかりませんが,特別な理由がなければMediaRecorderをおすすめします.

Discussion