26
Help us understand the problem. What are the problem?

posted at

updated at

【Android】CameraX試してみた

はじめに

2019/5月のGoogle I/Oで紹介された CameraX が気になったので、試して見ました。 :camera_with_flash:
(実際にはチュートリアルをちょっと改造しただけです・・・。:innocent:

CameraXとは

CameraX は、カメラアプリの開発を容易にすることを目的とした Jetpack ライブラリです。新しいアプリの場合は、CameraX から始めることをおすすめします。ほとんどの Android デバイスで機能する一貫性のある使いやすい API を提供するとともに、Android 5.0(API レベル 21)への下位互換性も備えています。

公式より引用

要するに、今までのCameraやCamera2を使いやすくして、さらに古いAndroid(5.0)でも使えるようにしたよ、というライブラリになります。

チュートリアル

公式がチュートリアルを出しています。
https://codelabs.developers.google.com/codelabs/camerax-getting-started/

今回はこれを進めていきました。

対応環境

  • AndroidAPI21以上の端末 or エミュレータ
  • Android Studio ArcticFox2020.3.1以降

1. プロジェクトの作成

Gradleへの記述

KTSで書いているので、groovyをお使いの方は変換して記述してください :bow:

以下を記述します。CameraX は Camera2 の機能を使用しているので、Camera2も含めます。
1.1.0-beta01からはすべてのライブラリが同じバージョンで導入出来る様になったようです。(今まではそれぞれ違うバージョンが必要だったとか)

build.gradle.kts
val cameraxVersion = "1.1.0-beta03"
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation("androidx.camera:camera-video:${cameraxVersion}")

implementation("androidx.camera:camera-view:${cameraxVersion}")
implementation("androidx.camera:camera-extensions:${cameraxVersion}")

レイアウト作成

androidx.camera.view.PreviewView のViewにプレビューが表示されます。
自分は画面いっぱいに画面が表示されて欲しかったので、縦横を0dpで指定しています。

activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout
        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/camera"
            android:layout_height="0dp"
            android:layout_width="0dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

Activityクラスの作成

ベースとなるActivityクラスを作成します。

MainActivity.kt
class MainActivity {
    private lateinit var viewBinding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
    }
}

2. 必要なパーミッションを制御する

ActivityManifest.xmlに以下を追記して、必要なpermissionを設定します。

ActivityManifest.xml
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
   android:maxSdkVersion="28" />

次に、定数群を作成します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    companion object {
        private val TAG = MainActivity::class.java.simpleName
        private const val REQUEST_CODE_PERMISSIONS = 10
        // 必要なpermissionのリスト
        private val REQUIRED_PERMISSIONS =
            mutableListOf (
                Manifest.permission.CAMERA,
                Manifest.permission.RECORD_AUDIO
            ).apply {
                // WRITE_EXTERNAL_STORAGEはPie以下で必要
                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
                    add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
                }
            }.toTypedArray()
    }
}

そして、必要なpermissionがすべて反映されているか確認するメソッドを実装

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
    }
    //...
}

permissionの許可結果を受け取るメソッドをオーバーライド

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults:
        IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                // カメラ開始処理
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }
    //...
}

最後にonCreate内でpermission許可要求を出します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    override fun onCreate(savedInstanceState: Bundle?) {

        // ViewBindingの設定など...

        if (allPermissionsGranted()) {
            // permissionは得られているので、カメラ始動
        } else {
            // permission許可要求
            ActivityCompat.requestPermissions(
                this, 
                REQUIRED_PERMISSIONS, 
                REQUEST_CODE_PERMISSIONS
            )
        }
    }
    //...
}

このpermission要求の書き方、スマートな感じで良いですね(語彙力)

3. カメラが映るようにする

ついにカメラの映像を画面に反映させます。

まずは、カメラを始動させるためのstartCameraメソッドを実装します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({
            // ライフサイクルにバインドするために利用する
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            // PreviewのUseCase
            val preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewBinding.camera.surfaceProvider)
                }

            // アウトカメラを設定
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                // バインドされているカメラを解除
                cameraProvider.unbindAll()
                // カメラをライフサイクルにバインド
                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview
                )

            } catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }
        }, ContextCompat.getMainExecutor(this))
    }
}

あとはカメラを起動したいタイミングでstartCameraを呼び出します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    override fun onCreate(savedInstanceState: Bundle?) {

        // ViewBindingの設定など...

        if (allPermissionsGranted()) {
            // permissionは得られているので、カメラ始動
            startCamera()
        } else {
            // permission許可要求
            ActivityCompat.requestPermissions(
                this,
                REQUIRED_PERMISSIONS,
                REQUEST_CODE_PERMISSIONS
            )
        }
    }
    //...
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults:
        IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                // カメラ開始処理
                startCamera()
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }
    //...
}

これだけでカメラが映るようになるのか・・・(感動)
これで、ライフサイクルに紐付いてくれているので、スリープしたときにも勝手にカメラがOFFになってくれます。めっちゃ便利。 :upside_down:

4. 写真を撮れるようにする

先ほどのはプレビューだけですので、写真を撮れるようにします。

まずは写真を撮るためのボタンを追加します。

activity_main.xml
<ImageButton
        android:id="@+id/capture_button"
        android:layout_width="72dp"
        android:layout_height="72dp"
        android:layout_margin="24dp"
        app:srcCompat="@android:drawable/ic_menu_camera"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

ImageCaptureというUseCaseを使う事で写真を撮ります。
まずはメンバーとしてImageCaptureを持たせます。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private var imageCapture: ImageCapture? = null
    //...
}

定数として保存するファイル名のフォーマットを定義します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    companion object {
        //...
        private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
        //...
    }
}

写真を撮るためのtakePhotoメソッドを実装します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private fun takePhoto() {
        // imageCaptureがまだセットされていないときは早期リターン。
        // セットされていない時はプレビューもされていないはず。
        val imageCapture = imageCapture ?: return

        // 写真の名前の設定(タイムスタンプ)とMediaStoreの設定
        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
            .format(System.currentTimeMillis())
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, name)
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
            }
        }

        // 保存するオプションを作成
        val outputOptions = ImageCapture.OutputFileOptions
            .Builder(
                contentResolver,
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                contentValues
            )
            .build()

        // 画像のキャプチャ
        // 結果はImageCapture.OnImageSavedCallbackのコールバックで返ってくる
        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(this),
            object : ImageCapture.OnImageSavedCallback {
                override fun onError(exc: ImageCaptureException) {
                    Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
                }

                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val msg = "Photo capture succeeded: ${output.savedUri}"
                    Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                    Log.d(TAG, msg)
                }
            }
        )
    }
    //...
}

次に、ImageCaptureを設定するために、startCameraでライフサイクルにバインドするときにImageCaptureも渡す様にします。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private fun startCamera() {
        //...
        cameraProviderFuture.addListener({
            //...
            imageCapture = ImageCapture.Builder()
                .build()
            //...
            try {
                //...
                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCapture
                )

            } catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }
        }, ContextCompat.getMainExecutor(this))
    }
    //...
}

最後にボタンを押したタイミングで写真を撮る用にします。

MainAcitivity.kt
class MainActivity : AppCompatActivity() {
    //...
    override fun onCreate(savedInstanceState: Bundle?) {

        // カメラ始動処理など

        viewBinding.captureButton.setOnClickListener {
            takePhoto()
        }
    }
    //...
}

カメラボタンを押すと写真が撮られて、Toastで表示されたディレクトリへ保存されます。
便利!!!!

5. 解析

解析と書きましたが「写っているものが何か」という解析ではなく、ピクセルの平均値を求めるという解析です。
ML Kit等と組み合わせることで画像解析も可能になるとは思いますが、今回は触れません。

リスナー用のtypealiaceを作成します。

MainAcitivity.kt
//...

typealias LumaListener = (luma: Double) -> Unit

//...

分析に利用する用のExecutorServiceを設定します。
onCreateでExecutorが起動するようにし、onDestroyでシャットダウンする用にします。

MainAcitivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private lateinit var cameraExecutor: ExecutorService
    //...
    override fun onCreate(savedInstanceState: Bundle?) {
        //...
        cameraExecutor = Executors.newSingleThreadExecutor()
    }
    //...
    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
    }
    //...
}

ImageAnalysis.Analyzerを実装したLuminosityAnalyzerクラスをMainActivityのインナークラスとして作成します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private class LuminosityAnalyzer(
        private val listener: LumaListener
    ) : ImageAnalysis.Analyzer {

        private fun ByteBuffer.toByteArray(): ByteArray {
            rewind()    // バッファを0に戻す
            val data = ByteArray(remaining())
            get(data)   // バッファをバイト配列へコピー
            return data // バイト配列の取得
        }

        override fun analyze(image: ImageProxy) {

            val buffer = image.planes[0].buffer
            val data = buffer.toByteArray()
            val pixels = data.map { it.toInt() and 0xFF }
            val luma = pixels.average()

            listener(luma)

            image.close()
        }
    }
    //...
}

最後に、LuminosityAnalyzerを設定するために、startCameraでライフサイクルにバインドするときにLuminosityAnalyzerも渡す様にします。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    //...
    private fun startCamera() {
        //...
        cameraProviderFuture.addListener({
            //...
            val imageAnalyzer = ImageAnalysis.Builder()
                .build()
                .also {
                    it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma ->
                        Log.d(TAG, "Average luminosity: $luma")
                    })
                }
            //...
            try {
                //...
                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    imageCapture,
                    imageAnalyzer
                )

            } catch (exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }
        }, ContextCompat.getMainExecutor(this))
    }
    //...
}

これでピクセル数がログに出力されるようになります!
これは結構夢が広がりますね!!!

D/MainActivity: Average luminosity: 121.706884765625

チュートリアルは以上

7. 動画の撮影8. 動画の撮影と別のUseCaseを組み合わせる は今回は扱いません。
(チュートリアルのコードが動かなかったので、一旦ここまでにします。)

リファクタリングの余地はありそうですが、最後に動作テストをして終わりです :thumbsup:

CameraXを試してみて

Cameraライブラリはちょっとかじったことがあるのですが、段違いなぐらい楽になっていると感じました!(Camera2は未経験)
あと、チュートリアルがかなり親切で、英語がわからない自分でも理解しながら進める事ができました。ありがとうGoogle。

途中でも記載しましたが、ML Kitとかと組み合わせて画像解析を行うのも楽しそうだなと思いました。 :camera_with_flash:

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
26
Help us understand the problem. What are the problem?